Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
315bd01
chore: add MCP SDK and OAuth device auth dependencies
ulrikandersen Jan 14, 2026
a1b09bd
chore: add MCP feature folder structure
ulrikandersen Jan 14, 2026
f6f0089
feat(mcp): add MCP session types and interface
ulrikandersen Jan 14, 2026
40b3c43
feat(mcp): implement Redis-backed MCP session store
ulrikandersen Jan 14, 2026
48df033
feat(mcp): add OpenAPI service and spec cache
ulrikandersen Jan 14, 2026
de85445
feat(mcp): add MCP tool handlers for OpenAPI exploration
ulrikandersen Jan 14, 2026
8fb0d3f
feat(mcp): implement device flow authentication service
ulrikandersen Jan 14, 2026
37c4ce1
feat(mcp): create MCP server with tool registration
ulrikandersen Jan 14, 2026
02e7afe
feat(mcp): add Streamable HTTP API route for MCP protocol
ulrikandersen Jan 14, 2026
c2dde9f
feat(mcp): add device flow auth status polling endpoint
ulrikandersen Jan 14, 2026
6427331
fix: lint and build fixes for MCP feature
ulrikandersen Jan 14, 2026
c3c3889
feat(cli): add device flow auth endpoint
ulrikandersen Jan 14, 2026
817605c
fix(mcp): add error handling to device flow endpoint
ulrikandersen Jan 14, 2026
27f72ce
feat(cli): add auth status polling endpoint
ulrikandersen Jan 14, 2026
ba45353
test: add error scenario test for auth status endpoint
ulrikandersen Jan 14, 2026
663afe7
feat(cli): add auth logout endpoint
ulrikandersen Jan 14, 2026
bc70023
feat(cli): add auth middleware helper
ulrikandersen Jan 14, 2026
edfca66
feat(cli): add projects endpoint for CLI authentication
ulrikandersen Jan 14, 2026
d295a34
fix(cli): improve error handling and performance in projects endpoint
ulrikandersen Jan 14, 2026
8aa9171
feat(cli): add remaining API endpoints (projects, endpoints, schemas)
ulrikandersen Jan 14, 2026
25917cc
feat(cli): initialize CLI package with config and API modules
ulrikandersen Jan 14, 2026
458206c
feat(cli): add CLI commands for auth, projects, endpoints, and schemas
ulrikandersen Jan 14, 2026
830db32
feat(cli): implement MCP server with tools and serve command
ulrikandersen Jan 14, 2026
4184a85
refactor(cli): extract shared types and remove duplicated auth logic
ulrikandersen Jan 14, 2026
8ec13e8
feat(cli): add npm workspaces for CLI package
ulrikandersen Jan 14, 2026
b58fe62
fix(lint): ignore CLI dist folder and suppress polling loop warnings
ulrikandersen Jan 14, 2026
133f6f7
docs(cli): add CLI README with usage instructions
ulrikandersen Jan 14, 2026
c78254c
fix: security and error handling improvements from code review
ulrikandersen Jan 14, 2026
f3577f1
refactor: use two-phase project loading for CLI and MCP
ulrikandersen Jan 15, 2026
bc35a24
feat(cli): add OpenAPI parsing dependencies
ulrikandersen Jan 15, 2026
b235c07
feat(cli): add Version and OpenApiSpecification types
ulrikandersen Jan 15, 2026
da85877
feat(cli): add hybrid cache module
ulrikandersen Jan 15, 2026
e620930
feat(cli): add getRaw method for fetching spec content
ulrikandersen Jan 15, 2026
c41a2ca
feat(cli): add local OpenAPIService for parsing
ulrikandersen Jan 15, 2026
6dbd4a2
feat(cli): add OpenAPIService helpers to shared
ulrikandersen Jan 15, 2026
d4beca5
refactor(cli): use local OpenAPIService in endpoints commands
ulrikandersen Jan 15, 2026
461aa05
refactor(cli): use local OpenAPIService in schemas commands
ulrikandersen Jan 15, 2026
f6a68d0
feat(cli): add cache management command
ulrikandersen Jan 15, 2026
164b649
refactor(cli): use local OpenAPIService in MCP server
ulrikandersen Jan 15, 2026
fc9820a
refactor(server): remove CLI parsing endpoints (moved to CLI)
ulrikandersen Jan 15, 2026
c4d95d5
fix(cli): resolve lint errors in cache module
ulrikandersen Jan 15, 2026
5c3c3ec
test: remove orphaned tests for deleted CLI endpoints
ulrikandersen Jan 15, 2026
b19fcf0
refactor(cli): use owner/name format consistently for project IDs
ulrikandersen Jan 15, 2026
9f84ef4
feat(cli): add 30s cache for project details
ulrikandersen Jan 15, 2026
cd6804b
feat(cli): add endpoint slice with schemas
ulrikandersen Jan 15, 2026
8f69499
fix(cli): disable YAML anchor generation for cleaner output
ulrikandersen Jan 15, 2026
aebd3f9
chore: remove accidentally committed local files
ulrikandersen Jan 15, 2026
630141e
fix(cli): use original schema names from $ref paths in endpoint slice
ulrikandersen Jan 15, 2026
61af9e4
refactor(cli): use bundle() instead of dereference() for cleaner output
ulrikandersen Jan 15, 2026
662a5b0
feat(cli): return valid OpenAPI 3.0 document from getEndpointSlice
ulrikandersen Jan 15, 2026
1465a8a
feat(cli): add support for remote specs
ulrikandersen Jan 15, 2026
6ffb0bd
fix(cli): handle specs with broken external refs
ulrikandersen Jan 15, 2026
dc2259f
feat(cli): add spec command and MCP tool for full OpenAPI spec
ulrikandersen Jan 15, 2026
703e8e0
refactor(cli): use named required params for version and spec
ulrikandersen Jan 15, 2026
415c704
feat(cli): add --json/--yaml output to all commands
ulrikandersen Jan 15, 2026
29b6de9
feat(cli): use cli-table3 for prettier table output
ulrikandersen Jan 15, 2026
6dbee89
feat(cli): use tables for endpoint details
ulrikandersen Jan 15, 2026
ebd79df
refactor(cli): remove MCP, add token refresh, restructure commands
ulrikandersen Jan 16, 2026
4b9a232
fix(cli): security hardening for device flow auth
ulrikandersen Jan 16, 2026
0705cd1
test(cli): add tests for config, api client, and shared utilities
ulrikandersen Jan 16, 2026
ac69d8b
feat(cli): add "did you mean" hint for wrong HTTP method
ulrikandersen Jan 16, 2026
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
58 changes: 58 additions & 0 deletions __test__/api/cli/auth/device.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { jest, describe, it, expect, beforeAll } from "@jest/globals"

// Set environment variables before any module loading
process.env.REDIS_URL = "redis://localhost:6379"
process.env.GITHUB_CLIENT_ID = "test-client-id"
process.env.GITHUB_CLIENT_SECRET = "test-client-secret"

const mockInitiateDeviceFlow = jest.fn().mockResolvedValue({
userCode: "ABCD-1234",
verificationUri: "https://github.com/login/device",
deviceCode: "device123",
sessionId: "session-uuid",
expiresIn: 899,
interval: 5,
})

// Use unstable_mockModule for ESM
jest.unstable_mockModule("@/common/key-value-store/RedisKeyValueStore", () => ({
default: jest.fn().mockImplementation(() => ({})),
}))

jest.unstable_mockModule("@/features/cli/data", () => ({
RedisCLISessionStore: jest.fn().mockImplementation(() => ({})),
}))

jest.unstable_mockModule("@/features/cli/domain", () => ({
CLIDeviceFlowService: jest.fn().mockImplementation(() => ({
initiateDeviceFlow: mockInitiateDeviceFlow,
})),
}))

describe("POST /api/cli/auth/device", () => {
let POST: (req: Request) => Promise<Response>

beforeAll(async () => {
const routeModule = await import("@/app/api/cli/auth/device/route")
POST = routeModule.POST
})

it("returns device flow details for authentication", async () => {
const request = new Request("http://localhost/api/cli/auth/device", {
method: "POST",
})

const response = await POST(request)
const data = await response.json()

expect(response.status).toBe(200)
expect(data).toEqual({
userCode: "ABCD-1234",
verificationUri: "https://github.com/login/device",
deviceCode: "device123",
sessionId: "session-uuid",
expiresIn: 899,
interval: 5,
})
})
})
65 changes: 65 additions & 0 deletions __test__/api/cli/auth/logout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { jest, describe, it, expect, beforeAll, beforeEach } from "@jest/globals"

// Set environment variables before any module loading
process.env.REDIS_URL = "redis://localhost:6379"

const mockDelete = jest.fn()

// Use unstable_mockModule for ESM
jest.unstable_mockModule("@/common/key-value-store/RedisKeyValueStore", () => ({
default: jest.fn().mockImplementation(() => ({})),
}))

jest.unstable_mockModule("@/features/cli/data", () => ({
RedisCLISessionStore: jest.fn().mockImplementation(() => ({
delete: mockDelete,
})),
}))

describe("POST /api/cli/auth/logout", () => {
let POST: (req: Request) => Promise<Response>

beforeAll(async () => {
const routeModule = await import("@/app/api/cli/auth/logout/route")
POST = routeModule.POST
})

beforeEach(() => {
jest.clearAllMocks()
})

it("deletes session and returns success", async () => {
mockDelete.mockResolvedValue(undefined)

const request = new Request("http://localhost/api/cli/auth/logout", {
method: "POST",
headers: { Authorization: "Bearer session-uuid" },
})

const response = await POST(request)
const data = await response.json()

expect(response.status).toBe(200)
expect(data).toEqual({ success: true })
expect(mockDelete).toHaveBeenCalledWith("session-uuid")
})

it("returns 401 when no authorization header", async () => {
const request = new Request("http://localhost/api/cli/auth/logout", {
method: "POST",
})

const response = await POST(request)
expect(response.status).toBe(401)
})

it("returns 401 when authorization header is not Bearer token", async () => {
const request = new Request("http://localhost/api/cli/auth/logout", {
method: "POST",
headers: { Authorization: "Basic some-credentials" },
})

const response = await POST(request)
expect(response.status).toBe(401)
})
})
153 changes: 153 additions & 0 deletions __test__/api/cli/auth/status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { jest, describe, it, expect, beforeAll, beforeEach } from "@jest/globals"

// Set environment variables before any module loading
process.env.REDIS_URL = "redis://localhost:6379"
process.env.GITHUB_CLIENT_ID = "test-client-id"
process.env.GITHUB_CLIENT_SECRET = "test-client-secret"

const mockPollForToken = jest.fn()

// Use unstable_mockModule for ESM
jest.unstable_mockModule("@/common/key-value-store/RedisKeyValueStore", () => ({
default: jest.fn().mockImplementation(() => ({})),
}))

jest.unstable_mockModule("@/features/cli/data", () => ({
RedisCLISessionStore: jest.fn().mockImplementation(() => ({})),
}))

jest.unstable_mockModule("@/features/cli/domain", () => ({
CLIDeviceFlowService: jest.fn().mockImplementation(() => ({
pollForToken: mockPollForToken,
})),
}))

function createPostRequest(body: unknown, ip?: string): Request {
const headers: Record<string, string> = { "Content-Type": "application/json" }
if (ip) {
headers["x-forwarded-for"] = ip
}
return new Request("http://localhost/api/cli/auth/status", {
method: "POST",
headers,
body: JSON.stringify(body),
})
}

describe("POST /api/cli/auth/status", () => {
let POST: (req: Request) => Promise<Response>
let rateLimitMap: Map<string, number>

beforeAll(async () => {
const routeModule = await import("@/app/api/cli/auth/status/route")
POST = routeModule.POST
rateLimitMap = routeModule.rateLimitMap
})

beforeEach(() => {
jest.clearAllMocks()
rateLimitMap.clear()
})

it("returns pending when authorization not complete", async () => {
mockPollForToken.mockResolvedValue(null)

const request = createPostRequest({ device_code: "abc123" }, "192.168.1.1")
const response = await POST(request)
const data = await response.json()

expect(response.status).toBe(200)
expect(data).toEqual({ status: "pending" })
})

it("returns complete with sessionId on success", async () => {
mockPollForToken.mockResolvedValue({ sessionId: "session-uuid" })

const request = createPostRequest({ device_code: "abc123" }, "192.168.1.2")
const response = await POST(request)
const data = await response.json()

expect(response.status).toBe(200)
expect(data).toEqual({ status: "complete", sessionId: "session-uuid" })
})

it("returns 400 when device_code missing", async () => {
const request = createPostRequest({}, "192.168.1.3")
const response = await POST(request)

expect(response.status).toBe(400)
})

it("returns 400 when body is invalid JSON", async () => {
const request = new Request("http://localhost/api/cli/auth/status", {
method: "POST",
headers: { "Content-Type": "application/json", "x-forwarded-for": "192.168.1.4" },
body: "invalid json",
})
const response = await POST(request)
const data = await response.json()

expect(response.status).toBe(400)
expect(data).toEqual({ error: "Invalid JSON body" })
})

it("returns error status when pollForToken fails", async () => {
mockPollForToken.mockRejectedValue(new Error("Token expired"))

const request = createPostRequest({ device_code: "abc123" }, "192.168.1.5")
const response = await POST(request)
const data = await response.json()

expect(response.status).toBe(500)
expect(data).toEqual({ status: "error", error: "Token expired" })
})

describe("rate limiting", () => {
it("returns 429 when same IP makes requests too quickly", async () => {
mockPollForToken.mockResolvedValue(null)
const testIP = "10.0.0.1"

// First request should succeed
const request1 = createPostRequest({ device_code: "abc123" }, testIP)
const response1 = await POST(request1)
expect(response1.status).toBe(200)

// Second request from same IP should be rate limited
const request2 = createPostRequest({ device_code: "abc123" }, testIP)
const response2 = await POST(request2)
const data = await response2.json()

expect(response2.status).toBe(429)
expect(data.error).toContain("Too many requests")
})

it("allows requests from different IPs", async () => {
mockPollForToken.mockResolvedValue(null)

const request1 = createPostRequest({ device_code: "abc123" }, "10.0.0.2")
const response1 = await POST(request1)
expect(response1.status).toBe(200)

const request2 = createPostRequest({ device_code: "abc123" }, "10.0.0.3")
const response2 = await POST(request2)
expect(response2.status).toBe(200)
})

it("allows request after rate limit window expires", async () => {
mockPollForToken.mockResolvedValue(null)
const testIP = "10.0.0.4"

// First request
const request1 = createPostRequest({ device_code: "abc123" }, testIP)
await POST(request1)

// Simulate time passing by manually updating the rate limit map
rateLimitMap.set(testIP, Date.now() - 6000) // 6 seconds ago

// Request should now succeed
const request2 = createPostRequest({ device_code: "abc123" }, testIP)
const response2 = await POST(request2)
expect(response2.status).toBe(200)
})
})
})
105 changes: 105 additions & 0 deletions __test__/api/cli/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { jest, describe, it, expect, beforeAll, beforeEach } from "@jest/globals"
import { NextRequest, NextResponse } from "next/server"

// Set environment variables before any module loading
process.env.REDIS_URL = "redis://localhost:6379"

const mockGet = jest.fn()

// Use unstable_mockModule for ESM
jest.unstable_mockModule("@/common/key-value-store/RedisKeyValueStore", () => ({
default: jest.fn().mockImplementation(() => ({})),
}))

jest.unstable_mockModule("@/features/cli/data/RedisCLISessionStore", () => ({
RedisCLISessionStore: jest.fn().mockImplementation(() => ({
get: mockGet,
})),
}))

describe("CLI middleware", () => {
let getSessionFromRequest: (request: NextRequest) => string | null
let withAuth: <T>(
handler: (request: NextRequest, auth: { session: { sessionId: string; accessToken: string }; sessionStore: unknown }) => Promise<NextResponse<T>>
) => (request: NextRequest) => Promise<NextResponse<T | { error: string }>>

beforeAll(async () => {
const middlewareModule = await import("@/app/api/cli/middleware")
getSessionFromRequest = middlewareModule.getSessionFromRequest
withAuth = middlewareModule.withAuth
})

beforeEach(() => {
jest.clearAllMocks()
})

describe("getSessionFromRequest", () => {
it("extracts session ID from Bearer token", () => {
const request = new Request("http://localhost/api/cli/test", {
headers: { Authorization: "Bearer abc123" },
}) as NextRequest
expect(getSessionFromRequest(request as NextRequest)).toBe("abc123")
})

it("returns null when no auth header", () => {
const request = new Request("http://localhost/api/cli/test") as NextRequest
expect(getSessionFromRequest(request as NextRequest)).toBeNull()
})
})

describe("withAuth", () => {
it("returns 401 when no session ID", async () => {
const handler = jest.fn()
const wrappedHandler = withAuth(handler)

const request = new Request("http://localhost/api/cli/test") as NextRequest
const response = await wrappedHandler(request as NextRequest)

expect(response.status).toBe(401)
expect(handler).not.toHaveBeenCalled()
})

it("returns 401 when session not found in Redis", async () => {
mockGet.mockResolvedValue(null)

const handler = jest.fn()
const wrappedHandler = withAuth(handler)

const request = new Request("http://localhost/api/cli/test", {
headers: { Authorization: "Bearer invalid-session" },
}) as NextRequest
const response = await wrappedHandler(request as NextRequest)

expect(response.status).toBe(401)
expect(handler).not.toHaveBeenCalled()
})

it("calls handler with auth context when session valid", async () => {
const mockSession = {
sessionId: "valid-session",
accessToken: "github-token",
createdAt: new Date().toISOString(),
}
mockGet.mockResolvedValue(mockSession)

const handler = jest
.fn()
.mockResolvedValue(NextResponse.json({ ok: true }))
const wrappedHandler = withAuth(handler)

const request = new Request("http://localhost/api/cli/test", {
headers: { Authorization: "Bearer valid-session" },
}) as NextRequest
const response = await wrappedHandler(request as NextRequest)

expect(response.status).toBe(200)
expect(handler).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
session: mockSession,
sessionStore: expect.any(Object),
})
)
})
})
})
Loading
Loading