@cuvo-health-us/api 0.1.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `@cuvo-health-us/api`: the TypeScript client for the Cuvo Integrations API.
3
+ *
4
+ * Every type in here is generated from `api/openapi/v1.yaml`, which is generated from the zod
5
+ * schemas the server validates with. Nothing about the contract is written by hand twice.
6
+ */
7
+ export { type CuvoClient, type CuvoClientOptions, createCuvoClient, DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, } from "./client.js";
8
+ export { CuvoApiError, type Problem, type ProblemIssue, unwrap } from "./errors.js";
9
+ export { type CuvoEvent, type CuvoEventOf, type CuvoEventResourceOf, type CuvoEventResourceType, type CuvoEventResourceTypeOf, type CuvoEventType, type CuvoTypedEvent, type ExpandedResource, expandEvent, typedEvent, } from "./events.js";
10
+ export type { components, operations, paths } from "./generated/v1.js";
11
+ export { type ListPage, paginate } from "./pagination.js";
12
+ export { DEFAULT_TOLERANCE_SECONDS, parseEvent, type VerifySignatureOptions, verifySignature, } from "./webhooks.js";
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `@cuvo-health-us/api`: the TypeScript client for the Cuvo Integrations API.
3
+ *
4
+ * Every type in here is generated from `api/openapi/v1.yaml`, which is generated from the zod
5
+ * schemas the server validates with. Nothing about the contract is written by hand twice.
6
+ */
7
+ export { createCuvoClient, DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, } from "./client.js";
8
+ export { CuvoApiError, unwrap } from "./errors.js";
9
+ export { expandEvent, typedEvent, } from "./events.js";
10
+ export { paginate } from "./pagination.js";
11
+ export { DEFAULT_TOLERANCE_SECONDS, parseEvent, verifySignature, } from "./webhooks.js";
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Every list in the contract answers the same envelope and pages on the id of the last row.
3
+ * There is no total, so a caller who wants "all of them" writes the same loop every time. This
4
+ * is that loop.
5
+ */
6
+ export interface ListPage<Item> {
7
+ data: Item[];
8
+ has_more: boolean;
9
+ }
10
+ /**
11
+ * Walk a list endpoint to its end, one item at a time.
12
+ *
13
+ * ```ts
14
+ * const cases = paginate((starting_after) =>
15
+ * unwrap(
16
+ * await cuvo.GET("/v1/cases", {
17
+ * params: { query: { limit: 100, status: "in_review", starting_after } },
18
+ * }),
19
+ * ),
20
+ * );
21
+ * for await (const item of cases) console.log(item.id);
22
+ * ```
23
+ *
24
+ * The callback takes the cursor and returns a page, rather than this taking the filters itself,
25
+ * so the filters stay contextually typed by the endpoint: `status: "in_review"` is checked
26
+ * against that operation's enum instead of widening to `string`.
27
+ *
28
+ * The iterator is lazy. It fetches the next page only when the consumer asks for an item past
29
+ * the end of the current one, so `break` costs nothing.
30
+ */
31
+ export declare function paginate<Item extends {
32
+ id: string;
33
+ }>(listPage: (cursor: string | undefined) => Promise<ListPage<Item>>): AsyncGenerator<Item, void, undefined>;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Every list in the contract answers the same envelope and pages on the id of the last row.
3
+ * There is no total, so a caller who wants "all of them" writes the same loop every time. This
4
+ * is that loop.
5
+ */
6
+ /**
7
+ * Walk a list endpoint to its end, one item at a time.
8
+ *
9
+ * ```ts
10
+ * const cases = paginate((starting_after) =>
11
+ * unwrap(
12
+ * await cuvo.GET("/v1/cases", {
13
+ * params: { query: { limit: 100, status: "in_review", starting_after } },
14
+ * }),
15
+ * ),
16
+ * );
17
+ * for await (const item of cases) console.log(item.id);
18
+ * ```
19
+ *
20
+ * The callback takes the cursor and returns a page, rather than this taking the filters itself,
21
+ * so the filters stay contextually typed by the endpoint: `status: "in_review"` is checked
22
+ * against that operation's enum instead of widening to `string`.
23
+ *
24
+ * The iterator is lazy. It fetches the next page only when the consumer asks for an item past
25
+ * the end of the current one, so `break` costs nothing.
26
+ */
27
+ export async function* paginate(listPage) {
28
+ let cursor;
29
+ for (;;) {
30
+ const page = await listPage(cursor);
31
+ for (const item of page.data)
32
+ yield item;
33
+ const last = page.data.at(-1);
34
+ // An empty page that still claims there is more would loop forever on the same cursor. The
35
+ // server does not do that; a proxy answering from a stale cache might.
36
+ if (!page.has_more || last === undefined)
37
+ return;
38
+ // The other way to loop forever is a callback that ignores the cursor it was handed. That
39
+ // is a mistake in the caller's query, and it is worth saying so rather than paging silently
40
+ // over page one until the process dies.
41
+ if (last.id === cursor) {
42
+ throw new Error(`paginate() was handed the same page twice at ${cursor}. Pass the cursor through to the query as starting_after.`);
43
+ }
44
+ cursor = last.id;
45
+ }
46
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The retry ladder.
3
+ *
4
+ * It wraps `fetch` rather than sitting in openapi-fetch middleware, because middleware runs once
5
+ * per call and a retry has to re-issue the request the middleware already finished building:
6
+ * headers, idempotency key and all. Wrapping the transport means every attempt sends the exact
7
+ * bytes of the first one, which is the whole reason a replay is safe.
8
+ */
9
+ export declare const IDEMPOTENCY_KEY_HEADER = "Idempotency-Key";
10
+ export interface RetryingFetchOptions {
11
+ fetch: typeof globalThis.fetch;
12
+ /** Attempts after the first. `0` disables retrying. */
13
+ maxRetries: number;
14
+ /** Injected by the tests; a ladder measured in real seconds is otherwise untestable. */
15
+ sleep?: (ms: number) => Promise<void>;
16
+ random?: () => number;
17
+ now?: () => number;
18
+ }
19
+ /**
20
+ * `Retry-After` is either a whole number of seconds or an HTTP date. Anything else is ignored
21
+ * rather than guessed at, and the caller falls back to the backoff ladder.
22
+ */
23
+ export declare function parseRetryAfter(value: string | null, nowMs: number): number | undefined;
24
+ export declare function createRetryingFetch(options: RetryingFetchOptions): typeof globalThis.fetch;
package/dist/retry.js ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The retry ladder.
3
+ *
4
+ * It wraps `fetch` rather than sitting in openapi-fetch middleware, because middleware runs once
5
+ * per call and a retry has to re-issue the request the middleware already finished building:
6
+ * headers, idempotency key and all. Wrapping the transport means every attempt sends the exact
7
+ * bytes of the first one, which is the whole reason a replay is safe.
8
+ */
9
+ /** Doubling from here, capped, so three retries cost under four seconds. */
10
+ const BASE_DELAY_MS = 500;
11
+ const MAX_DELAY_MS = 8_000;
12
+ /** A server may name any delay; we will not sit on a socket for longer than this. */
13
+ const MAX_RETRY_AFTER_MS = 60_000;
14
+ /** The writes that need a key before a replay is safe. GET and HEAD are always replayable. */
15
+ const WRITE_METHODS = new Set(["POST", "PATCH", "PUT", "DELETE"]);
16
+ export const IDEMPOTENCY_KEY_HEADER = "Idempotency-Key";
17
+ /**
18
+ * Retryable statuses: the rate limit, and the server faults that a second attempt can clear.
19
+ *
20
+ * 501 is excluded although it is a 5xx. The contract uses it for `live_not_available`, a
21
+ * permanent answer for the whole of Phase 1, so retrying it would add seconds of backoff to
22
+ * every live call that was never going to succeed.
23
+ */
24
+ function isRetryable(status) {
25
+ return status === 429 || (status >= 500 && status !== 501);
26
+ }
27
+ /**
28
+ * `Retry-After` is either a whole number of seconds or an HTTP date. Anything else is ignored
29
+ * rather than guessed at, and the caller falls back to the backoff ladder.
30
+ */
31
+ export function parseRetryAfter(value, nowMs) {
32
+ if (value === null)
33
+ return undefined;
34
+ const header = value.trim();
35
+ if (header.length === 0)
36
+ return undefined;
37
+ const seconds = /^\d+$/.test(header) ? Number(header) : Number.NaN;
38
+ const milliseconds = Number.isNaN(seconds) ? Date.parse(header) - nowMs : seconds * 1000;
39
+ if (Number.isNaN(milliseconds))
40
+ return undefined;
41
+ return Math.min(Math.max(milliseconds, 0), MAX_RETRY_AFTER_MS);
42
+ }
43
+ /**
44
+ * Equal jitter: half the ceiling, plus a random share of the other half. Full jitter can return
45
+ * a delay of nearly zero, which turns a fleet of clients retrying a 503 back into the stampede
46
+ * the backoff exists to break up.
47
+ */
48
+ function backoffMs(attempt, random) {
49
+ const ceiling = Math.min(MAX_DELAY_MS, BASE_DELAY_MS * 2 ** attempt);
50
+ return ceiling / 2 + random() * (ceiling / 2);
51
+ }
52
+ /**
53
+ * A write without an `Idempotency-Key` must never be retried: the first attempt may have been
54
+ * applied and answered into a socket that died, and a second one would create a second patient.
55
+ * The client mints a key for every write, so in practice this refuses only a request that
56
+ * reached the transport some other way.
57
+ */
58
+ function mayRetry(request) {
59
+ if (!WRITE_METHODS.has(request.method.toUpperCase()))
60
+ return true;
61
+ return request.headers.has(IDEMPOTENCY_KEY_HEADER);
62
+ }
63
+ export function createRetryingFetch(options) {
64
+ const { fetch: transport, maxRetries, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), random = Math.random, now = Date.now, } = options;
65
+ return async (input, init) => {
66
+ // openapi-fetch always hands the transport a fully built Request; the other shapes are here
67
+ // so this stays a drop-in `fetch`.
68
+ const request = input instanceof Request && init === undefined ? input : new Request(input, init);
69
+ for (let attempt = 0;; attempt += 1) {
70
+ // A Request body can only be read once, so each attempt gets its own copy.
71
+ const response = await transport(request.clone());
72
+ if (attempt >= maxRetries || !isRetryable(response.status) || !mayRetry(request)) {
73
+ return response;
74
+ }
75
+ // Nothing will read this body, and an unread one holds the connection open.
76
+ await response.body?.cancel().catch(() => undefined);
77
+ const named = parseRetryAfter(response.headers.get("Retry-After"), now());
78
+ await sleep(named ?? backoffMs(attempt, random));
79
+ }
80
+ };
81
+ }
@@ -0,0 +1,43 @@
1
+ import { type CuvoTypedEvent } from "./events.js";
2
+ /**
3
+ * Webhook verification, the receiving half of the recipe in api/lib/webhooks/sign.ts:
4
+ *
5
+ * Cuvo-Signature: t=<unix seconds>,v1=<hex hmac_sha256(secret, `${t}.${rawBody}`)>
6
+ *
7
+ * Two properties this depends on. The digest covers `t`, so a captured delivery cannot be
8
+ * replayed under a fresh timestamp; and it covers the RAW body, so the bytes must be verified
9
+ * before any JSON parse. Read the body as text off the request and pass that string, never
10
+ * `JSON.stringify(req.body)`. A re-serialization changes key order and whitespace and every
11
+ * signature fails.
12
+ *
13
+ * During the 24 hour grace after a rotation the header carries TWO `v1=` values, the new secret
14
+ * first. Accepting when ANY of them matches is what lets a receiver move to the new secret
15
+ * whenever it likes inside that window.
16
+ */
17
+ /** The contract's replay window, in seconds. */
18
+ export declare const DEFAULT_TOLERANCE_SECONDS = 300;
19
+ export interface VerifySignatureOptions {
20
+ /** The delivery body as the exact bytes that arrived. */
21
+ rawBody: string;
22
+ /** The `Cuvo-Signature` header. A missing header is a failed verification, not a throw. */
23
+ signatureHeader: string | null | undefined;
24
+ /** The endpoint's signing secret, `whsec_…`. */
25
+ secret: string;
26
+ toleranceSeconds?: number;
27
+ /** Injected by the tests; a signature over a clock is otherwise untestable. */
28
+ now?: () => number;
29
+ }
30
+ /**
31
+ * True when the delivery was signed with this secret, recently. Reject the delivery with a 400
32
+ * when it is false, and never act on an unverified body.
33
+ */
34
+ export declare function verifySignature(options: VerifySignatureOptions): boolean;
35
+ /**
36
+ * Parse a verified delivery body into an event keyed by its `type`, so a handler branching on
37
+ * `event.type` gets the resource that type carries without a cast.
38
+ *
39
+ * Verify first. This does not check the signature, and it does not validate the payload against
40
+ * the contract; it checks only that the body is the shape an event has, so a proxy's error page
41
+ * fails here rather than three property accesses later.
42
+ */
43
+ export declare function parseEvent(rawBody: string): CuvoTypedEvent;
@@ -0,0 +1,78 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import { typedEvent } from "./events.js";
3
+ /**
4
+ * Webhook verification, the receiving half of the recipe in api/lib/webhooks/sign.ts:
5
+ *
6
+ * Cuvo-Signature: t=<unix seconds>,v1=<hex hmac_sha256(secret, `${t}.${rawBody}`)>
7
+ *
8
+ * Two properties this depends on. The digest covers `t`, so a captured delivery cannot be
9
+ * replayed under a fresh timestamp; and it covers the RAW body, so the bytes must be verified
10
+ * before any JSON parse. Read the body as text off the request and pass that string, never
11
+ * `JSON.stringify(req.body)`. A re-serialization changes key order and whitespace and every
12
+ * signature fails.
13
+ *
14
+ * During the 24 hour grace after a rotation the header carries TWO `v1=` values, the new secret
15
+ * first. Accepting when ANY of them matches is what lets a receiver move to the new secret
16
+ * whenever it likes inside that window.
17
+ */
18
+ /** The contract's replay window, in seconds. */
19
+ export const DEFAULT_TOLERANCE_SECONDS = 300;
20
+ /** Constant-time hex compare that tolerates a length mismatch instead of throwing on one. */
21
+ function matches(candidate, expected) {
22
+ const left = Buffer.from(candidate, "utf8");
23
+ const right = Buffer.from(expected, "utf8");
24
+ if (left.length !== right.length)
25
+ return false;
26
+ return timingSafeEqual(left, right);
27
+ }
28
+ /**
29
+ * True when the delivery was signed with this secret, recently. Reject the delivery with a 400
30
+ * when it is false, and never act on an unverified body.
31
+ */
32
+ export function verifySignature(options) {
33
+ const { rawBody, signatureHeader, secret, toleranceSeconds = DEFAULT_TOLERANCE_SECONDS, now = Date.now, } = options;
34
+ if (!signatureHeader)
35
+ return false;
36
+ let timestamp;
37
+ const candidates = [];
38
+ for (const part of signatureHeader.split(",")) {
39
+ const separator = part.indexOf("=");
40
+ if (separator === -1)
41
+ continue;
42
+ const key = part.slice(0, separator).trim();
43
+ const value = part.slice(separator + 1).trim();
44
+ if (key === "t")
45
+ timestamp = value;
46
+ else if (key === "v1")
47
+ candidates.push(value);
48
+ }
49
+ if (timestamp === undefined || candidates.length === 0)
50
+ return false;
51
+ if (!/^\d+$/.test(timestamp))
52
+ return false;
53
+ const age = Math.abs(now() / 1000 - Number(timestamp));
54
+ if (age >= toleranceSeconds)
55
+ return false;
56
+ const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
57
+ // Every candidate is compared; the set of signatures on the wire is not a secret, so there is
58
+ // nothing to leak by the number of comparisons, only by their duration.
59
+ return candidates.some((candidate) => matches(candidate, expected));
60
+ }
61
+ /**
62
+ * Parse a verified delivery body into an event keyed by its `type`, so a handler branching on
63
+ * `event.type` gets the resource that type carries without a cast.
64
+ *
65
+ * Verify first. This does not check the signature, and it does not validate the payload against
66
+ * the contract; it checks only that the body is the shape an event has, so a proxy's error page
67
+ * fails here rather than three property accesses later.
68
+ */
69
+ export function parseEvent(rawBody) {
70
+ const body = JSON.parse(rawBody);
71
+ const event = body;
72
+ if (typeof event?.id !== "string" ||
73
+ typeof event.type !== "string" ||
74
+ typeof event.resource?.id !== "string") {
75
+ throw new Error("The body is not a Cuvo event.");
76
+ }
77
+ return typedEvent(event);
78
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@cuvo-health-us/api",
3
+ "version": "0.1.0-next.0",
4
+ "type": "module",
5
+ "description": "TypeScript client for the Cuvo Integrations API, generated from the v1 contract.",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "dependencies": {
17
+ "openapi-fetch": "^0.17.0"
18
+ },
19
+ "devDependencies": {
20
+ "@biomejs/biome": "2.4.16",
21
+ "@types/node": "^20",
22
+ "ajv": "^8.20.0",
23
+ "ajv-formats": "^3.0.1",
24
+ "knip": "^6.15.0",
25
+ "openapi-typescript": "^7.13.0",
26
+ "tsx": "^4.20.0",
27
+ "typescript": "^5",
28
+ "vitest": "^4.1.10",
29
+ "yaml": "^2.9.0"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public",
33
+ "tag": "next"
34
+ },
35
+ "engines": {
36
+ "node": ">=20"
37
+ },
38
+ "scripts": {
39
+ "generate": "tsx scripts/generate-types.ts",
40
+ "check:generated": "tsx scripts/generate-types.ts --check",
41
+ "build": "tsc -p tsconfig.build.json && mkdir -p dist/generated && cp src/generated/v1.d.ts dist/generated/v1.d.ts",
42
+ "typecheck": "tsc --noEmit",
43
+ "lint": "biome check .",
44
+ "format": "biome check --write .",
45
+ "test": "vitest run",
46
+ "gates": "pnpm typecheck && pnpm lint && pnpm exec knip && pnpm test && pnpm check:generated"
47
+ }
48
+ }