From 9b5167aaa95e6b31997e39381402b43f0abe243f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:07:34 +0000 Subject: [PATCH 1/5] feat: add opensea health diagnostic command (DIS-144) Co-Authored-By: Chris K --- src/cli.ts | 2 + src/client.ts | 4 ++ src/commands/health.ts | 47 ++++++++++++++++++ src/commands/index.ts | 1 + src/sdk.ts | 36 +++++++++++++- src/types/index.ts | 6 +++ test/client.test.ts | 11 +++++ test/commands/health.test.ts | 93 ++++++++++++++++++++++++++++++++++++ test/sdk.test.ts | 25 ++++++++++ 9 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 src/commands/health.ts create mode 100644 test/commands/health.test.ts diff --git a/src/cli.ts b/src/cli.ts index dc1fbbb..d8fc2b1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,6 +4,7 @@ import { accountsCommand, collectionsCommand, eventsCommand, + healthCommand, listingsCommand, nftsCommand, offersCommand, @@ -81,6 +82,7 @@ program.addCommand(accountsCommand(getClient, getFormat)) program.addCommand(tokensCommand(getClient, getFormat)) program.addCommand(searchCommand(getClient, getFormat)) program.addCommand(swapsCommand(getClient, getFormat)) +program.addCommand(healthCommand(getClient, getFormat)) async function main() { try { diff --git a/src/client.ts b/src/client.ts index 41eba54..de0bc44 100644 --- a/src/client.ts +++ b/src/client.ts @@ -104,6 +104,10 @@ export class OpenSeaClient { getDefaultChain(): string { return this.defaultChain } + + getApiKeyPrefix(): string { + return `${this.apiKey.slice(0, 4)}...` + } } export class OpenSeaAPIError extends Error { diff --git a/src/commands/health.ts b/src/commands/health.ts new file mode 100644 index 0000000..52253e3 --- /dev/null +++ b/src/commands/health.ts @@ -0,0 +1,47 @@ +import { Command } from "commander" +import { OpenSeaAPIError, type OpenSeaClient } from "../client.js" +import type { OutputFormat } from "../output.js" +import { formatOutput } from "../output.js" +import type { HealthResult } from "../types/index.js" + +export function healthCommand( + getClient: () => OpenSeaClient, + getFormat: () => OutputFormat, +): Command { + const cmd = new Command("health") + .description("Check API key validity and connectivity") + .action(async () => { + const client = getClient() + const keyPrefix = client.getApiKeyPrefix() + + try { + await client.get("/api/v2/collections", { limit: 1 }) + const result: HealthResult = { + status: "ok", + key_prefix: keyPrefix, + message: "API key is valid and connectivity is working", + } + console.log(formatOutput(result, getFormat())) + } catch (error) { + let message: string + if (error instanceof OpenSeaAPIError) { + message = + error.statusCode === 401 || error.statusCode === 403 + ? `Authentication failed (${error.statusCode}): ${error.responseBody}` + : `API error (${error.statusCode}): ${error.responseBody}` + } else { + message = `Network error: ${(error as Error).message}` + } + + const result: HealthResult = { + status: "error", + key_prefix: keyPrefix, + message, + } + console.log(formatOutput(result, getFormat())) + process.exit(1) + } + }) + + return cmd +} diff --git a/src/commands/index.ts b/src/commands/index.ts index 5e7f937..00835a4 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,6 +1,7 @@ export { accountsCommand } from "./accounts.js" export { collectionsCommand } from "./collections.js" export { eventsCommand } from "./events.js" +export { healthCommand } from "./health.js" export { listingsCommand } from "./listings.js" export { nftsCommand } from "./nfts.js" export { offersCommand } from "./offers.js" diff --git a/src/sdk.ts b/src/sdk.ts index 12c5cd0..52dfd1c 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -1,4 +1,4 @@ -import { OpenSeaClient } from "./client.js" +import { OpenSeaAPIError, OpenSeaClient } from "./client.js" import type { Account, AssetEvent, @@ -9,6 +9,7 @@ import type { Contract, EventType, GetTraitsResponse, + HealthResult, Listing, NFT, Offer, @@ -32,6 +33,7 @@ export class OpenSeaCLI { readonly tokens: TokensAPI readonly search: SearchAPI readonly swaps: SwapsAPI + readonly health: HealthAPI constructor(config: OpenSeaClientConfig) { this.client = new OpenSeaClient(config) @@ -44,6 +46,7 @@ export class OpenSeaCLI { this.tokens = new TokensAPI(this.client) this.search = new SearchAPI(this.client) this.swaps = new SwapsAPI(this.client) + this.health = new HealthAPI(this.client) } } @@ -384,3 +387,34 @@ class SwapsAPI { }) } } + +class HealthAPI { + constructor(private client: OpenSeaClient) {} + + async check(): Promise { + const keyPrefix = this.client.getApiKeyPrefix() + try { + await this.client.get("/api/v2/collections", { limit: 1 }) + return { + status: "ok", + key_prefix: keyPrefix, + message: "API key is valid and connectivity is working", + } + } catch (error) { + let message: string + if (error instanceof OpenSeaAPIError) { + message = + error.statusCode === 401 || error.statusCode === 403 + ? `Authentication failed (${error.statusCode}): ${error.responseBody}` + : `API error (${error.statusCode}): ${error.responseBody}` + } else { + message = `Network error: ${(error as Error).message}` + } + return { + status: "error", + key_prefix: keyPrefix, + message, + } + } + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 08ae815..04bb9f5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -14,3 +14,9 @@ export interface CommandOptions { format?: "json" | "table" raw?: boolean } + +export type HealthResult = { + status: "ok" | "error" + key_prefix: string + message: string +} diff --git a/test/client.test.ts b/test/client.test.ts index d068dde..18f65b9 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -215,6 +215,17 @@ describe("OpenSeaClient", () => { expect(client.getDefaultChain()).toBe("ethereum") }) }) + + describe("getApiKeyPrefix", () => { + it("returns first 4 characters followed by ellipsis", () => { + expect(client.getApiKeyPrefix()).toBe("test...") + }) + + it("handles short API keys", () => { + const shortKeyClient = new OpenSeaClient({ apiKey: "ab" }) + expect(shortKeyClient.getApiKeyPrefix()).toBe("ab...") + }) + }) }) describe("OpenSeaAPIError", () => { diff --git a/test/commands/health.test.ts b/test/commands/health.test.ts new file mode 100644 index 0000000..93d70e9 --- /dev/null +++ b/test/commands/health.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { OpenSeaAPIError } from "../../src/client.js" +import { healthCommand } from "../../src/commands/health.js" +import { type CommandTestContext, createCommandTestContext } from "../mocks.js" + +describe("healthCommand", () => { + let ctx: CommandTestContext + let mockGetApiKeyPrefix: ReturnType + + beforeEach(() => { + ctx = createCommandTestContext() + mockGetApiKeyPrefix = vi.fn().mockReturnValue("test...") + ;(ctx.mockClient as Record).getApiKeyPrefix = + mockGetApiKeyPrefix + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("creates command with correct name", () => { + const cmd = healthCommand(ctx.getClient, ctx.getFormat) + expect(cmd.name()).toBe("health") + }) + + it("outputs ok status when API call succeeds", async () => { + ctx.mockClient.get.mockResolvedValue({ collections: [] }) + + const cmd = healthCommand(ctx.getClient, ctx.getFormat) + await cmd.parseAsync([], { from: "user" }) + + expect(ctx.mockClient.get).toHaveBeenCalledWith("/api/v2/collections", { + limit: 1, + }) + const output = JSON.parse(ctx.consoleSpy.mock.calls[0][0] as string) + expect(output.status).toBe("ok") + expect(output.key_prefix).toBe("test...") + expect(output.message).toBe("API key is valid and connectivity is working") + }) + + it("outputs error status on authentication failure", async () => { + ctx.mockClient.get.mockRejectedValue( + new OpenSeaAPIError(401, "Unauthorized", "/api/v2/collections"), + ) + + const mockExit = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never) + + const cmd = healthCommand(ctx.getClient, ctx.getFormat) + await cmd.parseAsync([], { from: "user" }) + + const output = JSON.parse(ctx.consoleSpy.mock.calls[0][0] as string) + expect(output.status).toBe("error") + expect(output.key_prefix).toBe("test...") + expect(output.message).toContain("Authentication failed (401)") + expect(mockExit).toHaveBeenCalledWith(1) + }) + + it("outputs error status on other API errors", async () => { + ctx.mockClient.get.mockRejectedValue( + new OpenSeaAPIError(500, "Internal Server Error", "/api/v2/collections"), + ) + + const mockExit = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never) + + const cmd = healthCommand(ctx.getClient, ctx.getFormat) + await cmd.parseAsync([], { from: "user" }) + + const output = JSON.parse(ctx.consoleSpy.mock.calls[0][0] as string) + expect(output.status).toBe("error") + expect(output.message).toContain("API error (500)") + expect(mockExit).toHaveBeenCalledWith(1) + }) + + it("outputs error status on network errors", async () => { + ctx.mockClient.get.mockRejectedValue(new TypeError("fetch failed")) + + const mockExit = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never) + + const cmd = healthCommand(ctx.getClient, ctx.getFormat) + await cmd.parseAsync([], { from: "user" }) + + const output = JSON.parse(ctx.consoleSpy.mock.calls[0][0] as string) + expect(output.status).toBe("error") + expect(output.message).toContain("Network error: fetch failed") + expect(mockExit).toHaveBeenCalledWith(1) + }) +}) diff --git a/test/sdk.test.ts b/test/sdk.test.ts index bafb50b..d6dca3a 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -7,6 +7,7 @@ vi.mock("../src/client.js", () => { MockOpenSeaClient.prototype.get = vi.fn() MockOpenSeaClient.prototype.post = vi.fn() MockOpenSeaClient.prototype.getDefaultChain = vi.fn(() => "ethereum") + MockOpenSeaClient.prototype.getApiKeyPrefix = vi.fn(() => "test...") return { OpenSeaClient: MockOpenSeaClient, OpenSeaAPIError: vi.fn() } }) @@ -36,6 +37,7 @@ describe("OpenSeaCLI", () => { expect(sdk.tokens).toBeDefined() expect(sdk.search).toBeDefined() expect(sdk.swaps).toBeDefined() + expect(sdk.health).toBeDefined() }) }) @@ -407,4 +409,27 @@ describe("OpenSeaCLI", () => { }) }) }) + + describe("health", () => { + it("check returns ok when API call succeeds", async () => { + mockGet.mockResolvedValue({ collections: [] }) + const result = await sdk.health.check() + expect(mockGet).toHaveBeenCalledWith("/api/v2/collections", { + limit: 1, + }) + expect(result.status).toBe("ok") + expect(result.key_prefix).toBe("test...") + expect(result.message).toBe( + "API key is valid and connectivity is working", + ) + }) + + it("check returns error when API call fails", async () => { + mockGet.mockRejectedValue(new Error("fetch failed")) + const result = await sdk.health.check() + expect(result.status).toBe("error") + expect(result.key_prefix).toBe("test...") + expect(result.message).toContain("Network error: fetch failed") + }) + }) }) From 5a7d6c232e4f836fdbb72a5811a201a3d640edae Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:25:48 +0000 Subject: [PATCH 2/5] fix: address code review feedback on health command (DIS-144) - Fix misleading success message: says 'Connectivity is working' instead of claiming API key validation (endpoint returns 200 for any key) - Extract shared checkHealth() into src/health.ts to DRY up duplicated logic between CLI command and SDK HealthAPI - Change 'type HealthResult' to 'interface HealthResult' for consistency with other types in src/types/ - Add getApiKeyPrefix to MockClient type instead of using Record cast - Fix SDK test: use vi.importActual for OpenSeaAPIError so instanceof works correctly, enabling auth/API error branch coverage - Add SDK tests for auth error (401) and API error (500) branches Co-Authored-By: Chris K --- src/commands/health.ts | 36 ++++++---------------------------- src/health.ts | 31 +++++++++++++++++++++++++++++ src/index.ts | 1 + src/sdk.ts | 28 +++----------------------- src/types/index.ts | 2 +- test/commands/health.test.ts | 6 +----- test/mocks.ts | 2 ++ test/sdk.test.ts | 38 ++++++++++++++++++++++++++++++------ 8 files changed, 77 insertions(+), 67 deletions(-) create mode 100644 src/health.ts diff --git a/src/commands/health.ts b/src/commands/health.ts index 52253e3..7626799 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -1,44 +1,20 @@ import { Command } from "commander" -import { OpenSeaAPIError, type OpenSeaClient } from "../client.js" +import type { OpenSeaClient } from "../client.js" +import { checkHealth } from "../health.js" import type { OutputFormat } from "../output.js" import { formatOutput } from "../output.js" -import type { HealthResult } from "../types/index.js" export function healthCommand( getClient: () => OpenSeaClient, getFormat: () => OutputFormat, ): Command { const cmd = new Command("health") - .description("Check API key validity and connectivity") + .description("Check API connectivity") .action(async () => { const client = getClient() - const keyPrefix = client.getApiKeyPrefix() - - try { - await client.get("/api/v2/collections", { limit: 1 }) - const result: HealthResult = { - status: "ok", - key_prefix: keyPrefix, - message: "API key is valid and connectivity is working", - } - console.log(formatOutput(result, getFormat())) - } catch (error) { - let message: string - if (error instanceof OpenSeaAPIError) { - message = - error.statusCode === 401 || error.statusCode === 403 - ? `Authentication failed (${error.statusCode}): ${error.responseBody}` - : `API error (${error.statusCode}): ${error.responseBody}` - } else { - message = `Network error: ${(error as Error).message}` - } - - const result: HealthResult = { - status: "error", - key_prefix: keyPrefix, - message, - } - console.log(formatOutput(result, getFormat())) + const result = await checkHealth(client) + console.log(formatOutput(result, getFormat())) + if (result.status === "error") { process.exit(1) } }) diff --git a/src/health.ts b/src/health.ts new file mode 100644 index 0000000..0c04e65 --- /dev/null +++ b/src/health.ts @@ -0,0 +1,31 @@ +import { OpenSeaAPIError, type OpenSeaClient } from "./client.js" +import type { HealthResult } from "./types/index.js" + +export async function checkHealth( + client: OpenSeaClient, +): Promise { + const keyPrefix = client.getApiKeyPrefix() + try { + await client.get("/api/v2/collections", { limit: 1 }) + return { + status: "ok", + key_prefix: keyPrefix, + message: "Connectivity is working", + } + } catch (error) { + let message: string + if (error instanceof OpenSeaAPIError) { + message = + error.statusCode === 401 || error.statusCode === 403 + ? `Authentication failed (${error.statusCode}): ${error.responseBody}` + : `API error (${error.statusCode}): ${error.responseBody}` + } else { + message = `Network error: ${(error as Error).message}` + } + return { + status: "error", + key_prefix: keyPrefix, + message, + } + } +} diff --git a/src/index.ts b/src/index.ts index 8e563d8..23a5cc5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export { OpenSeaAPIError, OpenSeaClient } from "./client.js" +export { checkHealth } from "./health.js" export type { OutputFormat } from "./output.js" export { formatOutput } from "./output.js" export { OpenSeaCLI } from "./sdk.js" diff --git a/src/sdk.ts b/src/sdk.ts index 52dfd1c..b9d5fa5 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -1,4 +1,5 @@ -import { OpenSeaAPIError, OpenSeaClient } from "./client.js" +import { OpenSeaClient } from "./client.js" +import { checkHealth } from "./health.js" import type { Account, AssetEvent, @@ -392,29 +393,6 @@ class HealthAPI { constructor(private client: OpenSeaClient) {} async check(): Promise { - const keyPrefix = this.client.getApiKeyPrefix() - try { - await this.client.get("/api/v2/collections", { limit: 1 }) - return { - status: "ok", - key_prefix: keyPrefix, - message: "API key is valid and connectivity is working", - } - } catch (error) { - let message: string - if (error instanceof OpenSeaAPIError) { - message = - error.statusCode === 401 || error.statusCode === 403 - ? `Authentication failed (${error.statusCode}): ${error.responseBody}` - : `API error (${error.statusCode}): ${error.responseBody}` - } else { - message = `Network error: ${(error as Error).message}` - } - return { - status: "error", - key_prefix: keyPrefix, - message, - } - } + return checkHealth(this.client) } } diff --git a/src/types/index.ts b/src/types/index.ts index 04bb9f5..ba8562c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -15,7 +15,7 @@ export interface CommandOptions { raw?: boolean } -export type HealthResult = { +export interface HealthResult { status: "ok" | "error" key_prefix: string message: string diff --git a/test/commands/health.test.ts b/test/commands/health.test.ts index 93d70e9..00b55e9 100644 --- a/test/commands/health.test.ts +++ b/test/commands/health.test.ts @@ -5,13 +5,9 @@ import { type CommandTestContext, createCommandTestContext } from "../mocks.js" describe("healthCommand", () => { let ctx: CommandTestContext - let mockGetApiKeyPrefix: ReturnType beforeEach(() => { ctx = createCommandTestContext() - mockGetApiKeyPrefix = vi.fn().mockReturnValue("test...") - ;(ctx.mockClient as Record).getApiKeyPrefix = - mockGetApiKeyPrefix }) afterEach(() => { @@ -35,7 +31,7 @@ describe("healthCommand", () => { const output = JSON.parse(ctx.consoleSpy.mock.calls[0][0] as string) expect(output.status).toBe("ok") expect(output.key_prefix).toBe("test...") - expect(output.message).toBe("API key is valid and connectivity is working") + expect(output.message).toBe("Connectivity is working") }) it("outputs error status on authentication failure", async () => { diff --git a/test/mocks.ts b/test/mocks.ts index 2167fc8..1f07050 100644 --- a/test/mocks.ts +++ b/test/mocks.ts @@ -5,6 +5,7 @@ import type { OutputFormat } from "../src/output.js" export type MockClient = { get: Mock post: Mock + getApiKeyPrefix: Mock } export type CommandTestContext = { @@ -18,6 +19,7 @@ export function createCommandTestContext(): CommandTestContext { const mockClient: MockClient = { get: vi.fn(), post: vi.fn(), + getApiKeyPrefix: vi.fn().mockReturnValue("test..."), } const getClient = () => mockClient as unknown as OpenSeaClient const getFormat = () => "json" as OutputFormat diff --git a/test/sdk.test.ts b/test/sdk.test.ts index d6dca3a..bfc04da 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -1,14 +1,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { OpenSeaClient } from "../src/client.js" +import { OpenSeaAPIError, OpenSeaClient } from "../src/client.js" import { OpenSeaCLI } from "../src/sdk.js" -vi.mock("../src/client.js", () => { +vi.mock("../src/client.js", async importOriginal => { + const actual = await importOriginal() const MockOpenSeaClient = vi.fn() MockOpenSeaClient.prototype.get = vi.fn() MockOpenSeaClient.prototype.post = vi.fn() MockOpenSeaClient.prototype.getDefaultChain = vi.fn(() => "ethereum") MockOpenSeaClient.prototype.getApiKeyPrefix = vi.fn(() => "test...") - return { OpenSeaClient: MockOpenSeaClient, OpenSeaAPIError: vi.fn() } + return { + OpenSeaClient: MockOpenSeaClient, + OpenSeaAPIError: actual.OpenSeaAPIError, + } }) describe("OpenSeaCLI", () => { @@ -419,12 +423,34 @@ describe("OpenSeaCLI", () => { }) expect(result.status).toBe("ok") expect(result.key_prefix).toBe("test...") - expect(result.message).toBe( - "API key is valid and connectivity is working", + expect(result.message).toBe("Connectivity is working") + }) + + it("check returns error on authentication failure", async () => { + mockGet.mockRejectedValue( + new OpenSeaAPIError(401, "Unauthorized", "/api/v2/collections"), ) + const result = await sdk.health.check() + expect(result.status).toBe("error") + expect(result.key_prefix).toBe("test...") + expect(result.message).toContain("Authentication failed (401)") + }) + + it("check returns error on API error", async () => { + mockGet.mockRejectedValue( + new OpenSeaAPIError( + 500, + "Internal Server Error", + "/api/v2/collections", + ), + ) + const result = await sdk.health.check() + expect(result.status).toBe("error") + expect(result.key_prefix).toBe("test...") + expect(result.message).toContain("API error (500)") }) - it("check returns error when API call fails", async () => { + it("check returns error when network fails", async () => { mockGet.mockRejectedValue(new Error("fetch failed")) const result = await sdk.health.check() expect(result.status).toBe("error") From b3c03005378e34d062ba0e60382bbffa746352bf Mon Sep 17 00:00:00 2001 From: Chris Korhonen Date: Wed, 4 Mar 2026 11:35:13 -0500 Subject: [PATCH 3/5] fix: validate API key authentication in health check The health check now performs two steps: 1. Connectivity check via /api/v2/collections (public endpoint) 2. Auth validation via /api/v2/listings (requires valid API key) Previously the health check only hit a public endpoint that returned 200 regardless of API key validity, giving false "ok" results for invalid keys. Co-Authored-By: Claude Opus 4.6 --- src/commands/health.ts | 2 +- src/health.ts | 45 +++++++++++++++++++++------ src/types/index.ts | 1 + test/commands/health.test.ts | 49 +++++++++++++++++++++-------- test/sdk.test.ts | 60 ++++++++++++++++++++++++++++-------- 5 files changed, 122 insertions(+), 35 deletions(-) diff --git a/src/commands/health.ts b/src/commands/health.ts index 7626799..e1e7d94 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -9,7 +9,7 @@ export function healthCommand( getFormat: () => OutputFormat, ): Command { const cmd = new Command("health") - .description("Check API connectivity") + .description("Check API connectivity and authentication") .action(async () => { const client = getClient() const result = await checkHealth(client) diff --git a/src/health.ts b/src/health.ts index 0c04e65..a379984 100644 --- a/src/health.ts +++ b/src/health.ts @@ -5,27 +5,54 @@ export async function checkHealth( client: OpenSeaClient, ): Promise { const keyPrefix = client.getApiKeyPrefix() + + // Step 1: Check basic connectivity with a public endpoint try { await client.get("/api/v2/collections", { limit: 1 }) - return { - status: "ok", - key_prefix: keyPrefix, - message: "Connectivity is working", - } } catch (error) { let message: string if (error instanceof OpenSeaAPIError) { - message = - error.statusCode === 401 || error.statusCode === 403 - ? `Authentication failed (${error.statusCode}): ${error.responseBody}` - : `API error (${error.statusCode}): ${error.responseBody}` + message = `API error (${error.statusCode}): ${error.responseBody}` } else { message = `Network error: ${(error as Error).message}` } return { status: "error", key_prefix: keyPrefix, + authenticated: false, message, } } + + // Step 2: Validate authentication with an endpoint that requires a valid API key + try { + await client.get("/api/v2/listings/collection/boredapeyachtclub/all", { + limit: 1, + }) + return { + status: "ok", + key_prefix: keyPrefix, + authenticated: true, + message: "Connectivity and authentication are working", + } + } catch (error) { + if ( + error instanceof OpenSeaAPIError && + (error.statusCode === 401 || error.statusCode === 403) + ) { + return { + status: "error", + key_prefix: keyPrefix, + authenticated: false, + message: `Authentication failed (${error.statusCode}): invalid API key`, + } + } + // Non-auth error on events endpoint — connectivity works but auth is unverified + return { + status: "ok", + key_prefix: keyPrefix, + authenticated: false, + message: "Connectivity is working but authentication could not be verified", + } + } } diff --git a/src/types/index.ts b/src/types/index.ts index ba8562c..56522a7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -18,5 +18,6 @@ export interface CommandOptions { export interface HealthResult { status: "ok" | "error" key_prefix: string + authenticated: boolean message: string } diff --git a/test/commands/health.test.ts b/test/commands/health.test.ts index 00b55e9..7583e11 100644 --- a/test/commands/health.test.ts +++ b/test/commands/health.test.ts @@ -19,8 +19,8 @@ describe("healthCommand", () => { expect(cmd.name()).toBe("health") }) - it("outputs ok status when API call succeeds", async () => { - ctx.mockClient.get.mockResolvedValue({ collections: [] }) + it("outputs ok status when both connectivity and auth succeed", async () => { + ctx.mockClient.get.mockResolvedValue({}) const cmd = healthCommand(ctx.getClient, ctx.getFormat) await cmd.parseAsync([], { from: "user" }) @@ -28,15 +28,21 @@ describe("healthCommand", () => { expect(ctx.mockClient.get).toHaveBeenCalledWith("/api/v2/collections", { limit: 1, }) + expect(ctx.mockClient.get).toHaveBeenCalledWith("/api/v2/listings/collection/boredapeyachtclub/all", { + limit: 1, + }) const output = JSON.parse(ctx.consoleSpy.mock.calls[0][0] as string) expect(output.status).toBe("ok") expect(output.key_prefix).toBe("test...") - expect(output.message).toBe("Connectivity is working") + expect(output.authenticated).toBe(true) + expect(output.message).toBe( + "Connectivity and authentication are working", + ) }) - it("outputs error status on authentication failure", async () => { + it("outputs error status when connectivity fails", async () => { ctx.mockClient.get.mockRejectedValue( - new OpenSeaAPIError(401, "Unauthorized", "/api/v2/collections"), + new OpenSeaAPIError(500, "Internal Server Error", "/api/v2/collections"), ) const mockExit = vi @@ -48,15 +54,17 @@ describe("healthCommand", () => { const output = JSON.parse(ctx.consoleSpy.mock.calls[0][0] as string) expect(output.status).toBe("error") - expect(output.key_prefix).toBe("test...") - expect(output.message).toContain("Authentication failed (401)") + expect(output.authenticated).toBe(false) + expect(output.message).toContain("API error (500)") expect(mockExit).toHaveBeenCalledWith(1) }) - it("outputs error status on other API errors", async () => { - ctx.mockClient.get.mockRejectedValue( - new OpenSeaAPIError(500, "Internal Server Error", "/api/v2/collections"), - ) + it("outputs error status when auth fails (401)", async () => { + ctx.mockClient.get + .mockResolvedValueOnce({}) // connectivity ok + .mockRejectedValueOnce( + new OpenSeaAPIError(401, "Unauthorized", "/api/v2/listings/collection/boredapeyachtclub/all"), + ) const mockExit = vi .spyOn(process, "exit") @@ -67,7 +75,8 @@ describe("healthCommand", () => { const output = JSON.parse(ctx.consoleSpy.mock.calls[0][0] as string) expect(output.status).toBe("error") - expect(output.message).toContain("API error (500)") + expect(output.authenticated).toBe(false) + expect(output.message).toContain("Authentication failed (401)") expect(mockExit).toHaveBeenCalledWith(1) }) @@ -86,4 +95,20 @@ describe("healthCommand", () => { expect(output.message).toContain("Network error: fetch failed") expect(mockExit).toHaveBeenCalledWith(1) }) + + it("reports ok with unverified auth when events endpoint has non-auth error", async () => { + ctx.mockClient.get + .mockResolvedValueOnce({}) // connectivity ok + .mockRejectedValueOnce( + new OpenSeaAPIError(500, "Server Error", "/api/v2/listings/collection/boredapeyachtclub/all"), + ) + + const cmd = healthCommand(ctx.getClient, ctx.getFormat) + await cmd.parseAsync([], { from: "user" }) + + const output = JSON.parse(ctx.consoleSpy.mock.calls[0][0] as string) + expect(output.status).toBe("ok") + expect(output.authenticated).toBe(false) + expect(output.message).toContain("could not be verified") + }) }) diff --git a/test/sdk.test.ts b/test/sdk.test.ts index bfc04da..5129101 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -415,28 +415,24 @@ describe("OpenSeaCLI", () => { }) describe("health", () => { - it("check returns ok when API call succeeds", async () => { - mockGet.mockResolvedValue({ collections: [] }) + it("check returns ok with auth when both calls succeed", async () => { + mockGet.mockResolvedValue({}) const result = await sdk.health.check() expect(mockGet).toHaveBeenCalledWith("/api/v2/collections", { limit: 1, }) + expect(mockGet).toHaveBeenCalledWith("/api/v2/listings/collection/boredapeyachtclub/all", { + limit: 1, + }) expect(result.status).toBe("ok") + expect(result.authenticated).toBe(true) expect(result.key_prefix).toBe("test...") - expect(result.message).toBe("Connectivity is working") - }) - - it("check returns error on authentication failure", async () => { - mockGet.mockRejectedValue( - new OpenSeaAPIError(401, "Unauthorized", "/api/v2/collections"), + expect(result.message).toBe( + "Connectivity and authentication are working", ) - const result = await sdk.health.check() - expect(result.status).toBe("error") - expect(result.key_prefix).toBe("test...") - expect(result.message).toContain("Authentication failed (401)") }) - it("check returns error on API error", async () => { + it("check returns error when connectivity fails", async () => { mockGet.mockRejectedValue( new OpenSeaAPIError( 500, @@ -446,10 +442,48 @@ describe("OpenSeaCLI", () => { ) const result = await sdk.health.check() expect(result.status).toBe("error") + expect(result.authenticated).toBe(false) expect(result.key_prefix).toBe("test...") expect(result.message).toContain("API error (500)") }) + it("check returns error on authentication failure (401)", async () => { + mockGet + .mockResolvedValueOnce({}) // connectivity ok + .mockRejectedValueOnce( + new OpenSeaAPIError(401, "Unauthorized", "/api/v2/events"), + ) + const result = await sdk.health.check() + expect(result.status).toBe("error") + expect(result.authenticated).toBe(false) + expect(result.key_prefix).toBe("test...") + expect(result.message).toContain("Authentication failed (401)") + }) + + it("check returns error on authentication failure (403)", async () => { + mockGet + .mockResolvedValueOnce({}) // connectivity ok + .mockRejectedValueOnce( + new OpenSeaAPIError(403, "Forbidden", "/api/v2/events"), + ) + const result = await sdk.health.check() + expect(result.status).toBe("error") + expect(result.authenticated).toBe(false) + expect(result.message).toContain("Authentication failed (403)") + }) + + it("check returns ok with unverified auth on non-auth events error", async () => { + mockGet + .mockResolvedValueOnce({}) // connectivity ok + .mockRejectedValueOnce( + new OpenSeaAPIError(500, "Server Error", "/api/v2/events"), + ) + const result = await sdk.health.check() + expect(result.status).toBe("ok") + expect(result.authenticated).toBe(false) + expect(result.message).toContain("could not be verified") + }) + it("check returns error when network fails", async () => { mockGet.mockRejectedValue(new Error("fetch failed")) const result = await sdk.health.check() From 0a4908d92ee2b7a2c87e7b46a329283cc9b0d0b3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:05:48 +0000 Subject: [PATCH 4/5] fix: biome formatting and stale comment in health check Co-Authored-By: Chris K --- src/health.ts | 5 +++-- test/commands/health.test.ts | 25 +++++++++++++++++-------- test/sdk.test.ts | 13 +++++++------ 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/health.ts b/src/health.ts index a379984..7d48dd9 100644 --- a/src/health.ts +++ b/src/health.ts @@ -47,12 +47,13 @@ export async function checkHealth( message: `Authentication failed (${error.statusCode}): invalid API key`, } } - // Non-auth error on events endpoint — connectivity works but auth is unverified + // Non-auth error on listings endpoint — connectivity works but auth is unverified return { status: "ok", key_prefix: keyPrefix, authenticated: false, - message: "Connectivity is working but authentication could not be verified", + message: + "Connectivity is working but authentication could not be verified", } } } diff --git a/test/commands/health.test.ts b/test/commands/health.test.ts index 7583e11..f209434 100644 --- a/test/commands/health.test.ts +++ b/test/commands/health.test.ts @@ -28,16 +28,17 @@ describe("healthCommand", () => { expect(ctx.mockClient.get).toHaveBeenCalledWith("/api/v2/collections", { limit: 1, }) - expect(ctx.mockClient.get).toHaveBeenCalledWith("/api/v2/listings/collection/boredapeyachtclub/all", { - limit: 1, - }) + expect(ctx.mockClient.get).toHaveBeenCalledWith( + "/api/v2/listings/collection/boredapeyachtclub/all", + { + limit: 1, + }, + ) const output = JSON.parse(ctx.consoleSpy.mock.calls[0][0] as string) expect(output.status).toBe("ok") expect(output.key_prefix).toBe("test...") expect(output.authenticated).toBe(true) - expect(output.message).toBe( - "Connectivity and authentication are working", - ) + expect(output.message).toBe("Connectivity and authentication are working") }) it("outputs error status when connectivity fails", async () => { @@ -63,7 +64,11 @@ describe("healthCommand", () => { ctx.mockClient.get .mockResolvedValueOnce({}) // connectivity ok .mockRejectedValueOnce( - new OpenSeaAPIError(401, "Unauthorized", "/api/v2/listings/collection/boredapeyachtclub/all"), + new OpenSeaAPIError( + 401, + "Unauthorized", + "/api/v2/listings/collection/boredapeyachtclub/all", + ), ) const mockExit = vi @@ -100,7 +105,11 @@ describe("healthCommand", () => { ctx.mockClient.get .mockResolvedValueOnce({}) // connectivity ok .mockRejectedValueOnce( - new OpenSeaAPIError(500, "Server Error", "/api/v2/listings/collection/boredapeyachtclub/all"), + new OpenSeaAPIError( + 500, + "Server Error", + "/api/v2/listings/collection/boredapeyachtclub/all", + ), ) const cmd = healthCommand(ctx.getClient, ctx.getFormat) diff --git a/test/sdk.test.ts b/test/sdk.test.ts index 5129101..c6e2632 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -421,15 +421,16 @@ describe("OpenSeaCLI", () => { expect(mockGet).toHaveBeenCalledWith("/api/v2/collections", { limit: 1, }) - expect(mockGet).toHaveBeenCalledWith("/api/v2/listings/collection/boredapeyachtclub/all", { - limit: 1, - }) + expect(mockGet).toHaveBeenCalledWith( + "/api/v2/listings/collection/boredapeyachtclub/all", + { + limit: 1, + }, + ) expect(result.status).toBe("ok") expect(result.authenticated).toBe(true) expect(result.key_prefix).toBe("test...") - expect(result.message).toBe( - "Connectivity and authentication are working", - ) + expect(result.message).toBe("Connectivity and authentication are working") }) it("check returns error when connectivity fails", async () => { From 03ee4ae50af2a075d9c33f22377041a46b861ed4 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:09:31 +0000 Subject: [PATCH 5/5] fix: guard short API keys, add 429 rate-limit handling with exit code 3 - getApiKeyPrefix() returns '***' for keys shorter than 8 chars - checkHealth() detects 429 responses and sets rate_limited flag - CLI exits with code 3 on rate limiting (vs 1 for other errors) - HealthResult interface gains rate_limited boolean field - Added tests for 429 handling in both CLI and SDK Co-Authored-By: Chris K --- src/client.ts | 1 + src/commands/health.ts | 2 +- src/health.ts | 36 ++++++++++++++++++++++++++---------- src/types/index.ts | 1 + test/client.test.ts | 4 ++-- test/commands/health.test.ts | 21 ++++++++++++++++++++- test/sdk.test.ts | 10 ++++++++++ 7 files changed, 61 insertions(+), 14 deletions(-) diff --git a/src/client.ts b/src/client.ts index de0bc44..45cb100 100644 --- a/src/client.ts +++ b/src/client.ts @@ -106,6 +106,7 @@ export class OpenSeaClient { } getApiKeyPrefix(): string { + if (this.apiKey.length < 8) return "***" return `${this.apiKey.slice(0, 4)}...` } } diff --git a/src/commands/health.ts b/src/commands/health.ts index e1e7d94..4267cc5 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -15,7 +15,7 @@ export function healthCommand( const result = await checkHealth(client) console.log(formatOutput(result, getFormat())) if (result.status === "error") { - process.exit(1) + process.exit(result.rate_limited ? 3 : 1) } }) diff --git a/src/health.ts b/src/health.ts index 7d48dd9..53224a9 100644 --- a/src/health.ts +++ b/src/health.ts @@ -12,7 +12,10 @@ export async function checkHealth( } catch (error) { let message: string if (error instanceof OpenSeaAPIError) { - message = `API error (${error.statusCode}): ${error.responseBody}` + message = + error.statusCode === 429 + ? "Rate limited: too many requests" + : `API error (${error.statusCode}): ${error.responseBody}` } else { message = `Network error: ${(error as Error).message}` } @@ -20,6 +23,8 @@ export async function checkHealth( status: "error", key_prefix: keyPrefix, authenticated: false, + rate_limited: + error instanceof OpenSeaAPIError && error.statusCode === 429, message, } } @@ -33,18 +38,28 @@ export async function checkHealth( status: "ok", key_prefix: keyPrefix, authenticated: true, + rate_limited: false, message: "Connectivity and authentication are working", } } catch (error) { - if ( - error instanceof OpenSeaAPIError && - (error.statusCode === 401 || error.statusCode === 403) - ) { - return { - status: "error", - key_prefix: keyPrefix, - authenticated: false, - message: `Authentication failed (${error.statusCode}): invalid API key`, + if (error instanceof OpenSeaAPIError) { + if (error.statusCode === 429) { + return { + status: "error", + key_prefix: keyPrefix, + authenticated: false, + rate_limited: true, + message: "Rate limited: too many requests", + } + } + if (error.statusCode === 401 || error.statusCode === 403) { + return { + status: "error", + key_prefix: keyPrefix, + authenticated: false, + rate_limited: false, + message: `Authentication failed (${error.statusCode}): invalid API key`, + } } } // Non-auth error on listings endpoint — connectivity works but auth is unverified @@ -52,6 +67,7 @@ export async function checkHealth( status: "ok", key_prefix: keyPrefix, authenticated: false, + rate_limited: false, message: "Connectivity is working but authentication could not be verified", } diff --git a/src/types/index.ts b/src/types/index.ts index 56522a7..ad55e6e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -19,5 +19,6 @@ export interface HealthResult { status: "ok" | "error" key_prefix: string authenticated: boolean + rate_limited: boolean message: string } diff --git a/test/client.test.ts b/test/client.test.ts index 18f65b9..bd578da 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -221,9 +221,9 @@ describe("OpenSeaClient", () => { expect(client.getApiKeyPrefix()).toBe("test...") }) - it("handles short API keys", () => { + it("masks short API keys", () => { const shortKeyClient = new OpenSeaClient({ apiKey: "ab" }) - expect(shortKeyClient.getApiKeyPrefix()).toBe("ab...") + expect(shortKeyClient.getApiKeyPrefix()).toBe("***") }) }) }) diff --git a/test/commands/health.test.ts b/test/commands/health.test.ts index f209434..c221dd9 100644 --- a/test/commands/health.test.ts +++ b/test/commands/health.test.ts @@ -101,7 +101,7 @@ describe("healthCommand", () => { expect(mockExit).toHaveBeenCalledWith(1) }) - it("reports ok with unverified auth when events endpoint has non-auth error", async () => { + it("reports ok with unverified auth when listings endpoint has non-auth error", async () => { ctx.mockClient.get .mockResolvedValueOnce({}) // connectivity ok .mockRejectedValueOnce( @@ -120,4 +120,23 @@ describe("healthCommand", () => { expect(output.authenticated).toBe(false) expect(output.message).toContain("could not be verified") }) + + it("exits with code 3 on rate limit (429)", async () => { + ctx.mockClient.get.mockRejectedValue( + new OpenSeaAPIError(429, "Too Many Requests", "/api/v2/collections"), + ) + + const mockExit = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never) + + const cmd = healthCommand(ctx.getClient, ctx.getFormat) + await cmd.parseAsync([], { from: "user" }) + + const output = JSON.parse(ctx.consoleSpy.mock.calls[0][0] as string) + expect(output.status).toBe("error") + expect(output.rate_limited).toBe(true) + expect(output.message).toContain("Rate limited") + expect(mockExit).toHaveBeenCalledWith(3) + }) }) diff --git a/test/sdk.test.ts b/test/sdk.test.ts index c6e2632..e07a9b5 100644 --- a/test/sdk.test.ts +++ b/test/sdk.test.ts @@ -485,6 +485,16 @@ describe("OpenSeaCLI", () => { expect(result.message).toContain("could not be verified") }) + it("check returns error with rate_limited on 429", async () => { + mockGet.mockRejectedValue( + new OpenSeaAPIError(429, "Too Many Requests", "/api/v2/collections"), + ) + const result = await sdk.health.check() + expect(result.status).toBe("error") + expect(result.rate_limited).toBe(true) + expect(result.message).toContain("Rate limited") + }) + it("check returns error when network fails", async () => { mockGet.mockRejectedValue(new Error("fetch failed")) const result = await sdk.health.check()