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
2 changes: 1 addition & 1 deletion .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
strategy:
fail-fast: false
matrix:
package: [codev-cli, codev-backend]
package: [codev-cli, codev-proxy]
defaults:
run:
working-directory: ${{ matrix.package }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
strategy:
fail-fast: false
matrix:
package: [codev-cli, codev-backend]
package: [codev-cli, codev-proxy]
defaults:
run:
working-directory: ${{ matrix.package }}
Expand Down
20 changes: 10 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Two independent Bun packages in a single git repo:

- `codev-cli/` — interactive Ink + React CLI. Owns the full OIDC/PKCE login flow with Viettel SSO. Has its own `CLAUDE.md` with CLI-specific conventions (Bun APIs, absolute imports via `@/*`, validation commands) — **read it when working in `codev-cli/`.**
- `codev-backend/` — Bun HTTP server. Verifies an SSO access token and exchanges it for a LiteLLM API key via a gateway endpoint.
- `codev-proxy/` — Bun HTTP server. Verifies an SSO access token and exchanges it for a LiteLLM API key via a gateway endpoint.

Each package has its own `package.json`, `bun.lock`, `biome.json`, `tsconfig.json`, and `Dockerfile`. There is no root `package.json` and no workspaces configured — run package scripts from inside each subdir.

Expand All @@ -37,7 +37,7 @@ CLI-only: `bun run build` (bundles via `build.ts`), `bun run start` (runs the bu
The login flow crosses both packages — understanding it requires reading files in both.

```
codev-cli (src/auth.ts) codev-backend (src/index.ts) Gateway
codev-cli (src/auth.ts) codev-proxy (src/index.ts) Gateway
───────────────────────── ───────────────────────── ───────
login()
Expand All @@ -49,7 +49,7 @@ codev-cli (src/auth.ts) codev-backend (src/index.ts) Gateway
│ → caches tokens in ~/.codev/auth.json
└─ fetchApiKey(access_token)
POST BACKEND_URL/auth/exchange
POST http://localhost:8787/auth/exchange
Authorization: Bearer <access_token>
Expand All @@ -72,22 +72,22 @@ codev-cli (src/auth.ts) codev-backend (src/index.ts) Gateway

Key invariants across the boundary:

- **The CLI is the SSO client, not the backend.** All OIDC/PKCE state, tokens, and the loopback callback server live in `codev-cli/src/auth.ts`. The backend only verifies the access token via `/userinfo`.
- **The "gateway" is a single endpoint, not the LiteLLM admin API.** `API_URL` is a full URL (e.g. `https://netmind.viettel.vn/gateway/add_user_and_generate_key`), not a base. The backend posts `{ username }` and reads `key_token` from the response (with fallback to `api_key`/`key`/`token`).
- **Key reuse is the gateway's responsibility.** The backend makes one call per exchange and trusts the gateway to return the existing key for an existing user. Don't add local caching without confirming behavior.
- **`BACKEND_URL` in the CLI is required, no default.** `codev-cli/src/backend.ts` throws if missing. Users copy `.env.example` to `.env`.
- **`NODE_ENV` gates key logging on the backend.** In `development`/`staging` (or unset), `/auth/exchange` logs the API key in plaintext for debugging. In `production`, only the email is logged. The backend Dockerfile sets `NODE_ENV=production`.
- **The CLI is the SSO client, not the proxy.** All OIDC/PKCE state, tokens, and the loopback callback server live in `codev-cli/src/auth.ts`. The proxy only verifies the access token via `/userinfo`.
- **The "gateway" is a single endpoint, not the LiteLLM admin API.** `API_URL` is a full URL (e.g. `https://netmind.viettel.vn/gateway/add_user_and_generate_key`), not a base. The proxy posts `{ username }` and reads `key_token` from the response (with fallback to `api_key`/`key`/`token`).
- **Key reuse is the gateway's responsibility.** The proxy makes one call per exchange and trusts the gateway to return the existing key for an existing user. Don't add local caching without confirming behavior.
- **The CLI's proxy URL is hardcoded.** `codev-cli/src/proxy.ts` targets `http://localhost:8787` as a module constant — the CLI has no runtime env vars and ships no `.env.example`.
- **`NODE_ENV` gates key logging in the proxy.** In `development`/`staging` (or unset), `/auth/exchange` logs the API key in plaintext for debugging. In `production`, only the email is logged. The proxy's Dockerfile sets `NODE_ENV=production`.

## Tech conventions shared by both packages

- Bun is the runtime, test runner, bundler, and package manager — never Node/npm/jest/webpack. See `codev-cli/CLAUDE.md` for the full list of Bun APIs to prefer (`Bun.serve`, `bun:sqlite`, `Bun.sql`, etc.) and not to reach for Express/ws/dotenv/pg.
- TypeScript config is strict with `noUncheckedIndexedAccess` and `verbatimModuleSyntax`.
- Biome is the only formatter/linter. Tab indentation, double quotes. `bun run fix` before committing.
- Absolute imports via `@/*` alias resolving to `src/*`. No relative imports across more than one level.
- `.env` is never committed or baked into Docker images. Each package ships an `.env.example`.
- `.env` is never committed or baked into Docker images. Only `codev-proxy` uses runtime env vars (and ships an `.env.example`).

## Deployment

`codev-backend` is designed to be built and pushed to Docker Hub for DevOps to deploy. See `codev-backend/README.md` for the deployment contract: required env vars (with secret flags), tagging strategy, and env var injection patterns for K8s / systemd / Swarm / secret managers.
`codev-proxy` is designed to be built and pushed to Docker Hub for DevOps to deploy. See `codev-proxy/README.md` for the deployment contract: required env vars (with secret flags), tagging strategy, and env var injection patterns for K8s / systemd / Swarm / secret managers.

`codev-cli` is distributed as an npm package (`bun run build` produces `dist/index.js`, referenced from `package.json`'s `bin` field).
11 changes: 0 additions & 11 deletions codev-backend/.env.example

This file was deleted.

2 changes: 0 additions & 2 deletions codev-cli/.env.example

This file was deleted.

4 changes: 1 addition & 3 deletions codev-cli/src/components/Login.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Box, Text, useInput } from "ink";
import { useCallback, useEffect, useRef, useState } from "react";
import { login } from "@/auth.js";
import { fetchApiKey } from "@/backend.js";
import { fetchApiKey } from "@/proxy.js";

interface LoginProps {
onDone: () => void;
Expand All @@ -24,10 +24,8 @@ export function Login({ onDone }: LoginProps) {
setWaitingForEnter(true);
})
.then(async (auth) => {
addLog("Fetching API key from backend...");
const key = await fetchApiKey(auth.access_token);
setApiKey(key);
addLog("API key ready.");
setTimeout(onDone, 1000);
})
.catch((err: Error) => setError(err.message));
Expand Down
14 changes: 4 additions & 10 deletions codev-cli/src/backend.ts → codev-cli/src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,23 @@ interface ErrorResponse {
error?: string;
}

function backendUrl(): string {
const url = process.env.BACKEND_URL;
if (!url) {
throw new Error("Missing required env var: BACKEND_URL");
}
return url.replace(/\/$/, "");
}
const PROXY_URL = "http://localhost:8787";

export async function fetchApiKey(accessToken: string): Promise<string> {
const res = await fetch(`${backendUrl()}/auth/exchange`, {
const res = await fetch(`${PROXY_URL}/auth/exchange`, {
method: "POST",
headers: { Authorization: `Bearer ${accessToken}` },
});

if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as ErrorResponse;
const reason = body.error || res.statusText;
throw new Error(`Backend /auth/exchange failed (${res.status}): ${reason}`);
throw new Error(`Proxy /auth/exchange failed (${res.status}): ${reason}`);
}

const data = (await res.json()) as ExchangeResponse;
if (!data.api_key) {
throw new Error("Backend returned no api_key");
throw new Error("Proxy returned no api_key");
}
return data.api_key;
}
14 changes: 7 additions & 7 deletions codev-cli/tests/components/Login.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
import { cleanup, render } from "ink-testing-library";
import * as auth from "@/auth.js";
import * as backend from "@/backend.js";
import { Login } from "@/components/Login.js";
import * as proxy from "@/proxy.js";

afterEach(() => {
cleanup();
Expand Down Expand Up @@ -112,7 +112,7 @@ describe("Login", () => {
user: { sub: "u", email: "test@viettel.com.vn", displayName: "Test" },
}),
);
spyOn(backend, "fetchApiKey").mockResolvedValue("sk-test-key-123");
spyOn(proxy, "fetchApiKey").mockResolvedValue("sk-test-key-123");

const onDone = mock();
const { lastFrame } = render(<Login onDone={onDone} />);
Expand All @@ -133,7 +133,7 @@ describe("Login", () => {
user: { sub: "u", email: "test@viettel.com.vn", displayName: "Test" },
}),
);
spyOn(backend, "fetchApiKey").mockResolvedValue("sk-test-key-123");
spyOn(proxy, "fetchApiKey").mockResolvedValue("sk-test-key-123");

const onDone = mock();
render(<Login onDone={onDone} />);
Expand All @@ -145,7 +145,7 @@ describe("Login", () => {
expect(onDone).toHaveBeenCalledTimes(1);
});

test("shows error if backend key exchange fails", async () => {
test("shows error if proxy key exchange fails", async () => {
spyOn(auth, "login").mockImplementation(() =>
Promise.resolve({
access_token: "access-xyz",
Expand All @@ -154,8 +154,8 @@ describe("Login", () => {
user: { sub: "u", email: "test@viettel.com.vn", displayName: "Test" },
}),
);
spyOn(backend, "fetchApiKey").mockRejectedValue(
new Error("Backend /auth/exchange failed (502): boom"),
spyOn(proxy, "fetchApiKey").mockRejectedValue(
new Error("Proxy /auth/exchange failed (502): boom"),
);

const onDone = mock();
Expand All @@ -164,7 +164,7 @@ describe("Login", () => {
await new Promise((r) => setTimeout(r, 100));

const output = lastFrame() ?? "";
expect(output).toContain("Login failed: Backend /auth/exchange failed");
expect(output).toContain("Login failed: Proxy /auth/exchange failed");
expect(onDone).not.toHaveBeenCalled();
});
});
File renamed without changes.
12 changes: 12 additions & 0 deletions codev-proxy/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Full gateway endpoint that provisions/returns a LiteLLM key per user.
# This is a complete URL, not a base — the backend POSTs directly to it.
API_URL=https://netmind.viettel.vn/gateway/add_user_and_generate_key

# Bearer token for the gateway above.
AUTH_TOKEN=sk-gateway-token

# OIDC userinfo endpoint used to verify the CLI's SSO access token
SSO_USERINFO_URL=https://netmind.viettel.vn/sso-wrapper/userinfo

# HTTP port the backend listens on (default: 8787)
PORT=8787
File renamed without changes.
File renamed without changes.
File renamed without changes.
25 changes: 14 additions & 11 deletions codev-backend/README.md → codev-proxy/README.md
Original file line number Diff line number Diff line change
@@ -1,27 +1,29 @@
# codev-backend
# codev-proxy

Thin proxy between the `codev-cli` and the LiteLLM proxy.
Token-exchange service between `codev-cli` and the LiteLLM gateway.

The CLI owns the full OIDC / PKCE flow with Viettel SSO. Once it has an access
token, it calls this backend to exchange that token for a LiteLLM API key.
token, it calls this proxy to exchange that token for a LiteLLM API key.

## Flow

```
codev-cli ──(1) POST /auth/exchange Authorization: Bearer <sso_access_token>
codev-backend ──(2) GET {SSO_USERINFO_URL} (verify token, read sub + email)
codev-proxy ──(2) GET {SSO_USERINFO_URL} (verify token, read sub + email)
──(3) GET {API_URL}/user/info?user_id=<sub>
└─ if user+key exists, reuse it
└─ otherwise POST /user/new (or /key/generate)
──(3) POST {API_URL} Authorization: Bearer {AUTH_TOKEN}
Body: { "username": "<email>" }
Response: { "key_token": "sk-..." }
codev-cli ◀── { api_key, user }
```

The backend never generates a new LiteLLM key for a user who already has one.
`API_URL` is a single gateway endpoint (not a base URL). Key reuse for
existing users is the gateway's responsibility — the proxy makes one call
per exchange and trusts the gateway to return the existing key.

## Endpoints

Expand All @@ -43,6 +45,7 @@ Responses:
```
- `401 Unauthorized` — missing or invalid SSO token.
- `502 Bad Gateway` — LiteLLM or SSO provider failure.
- `504 Gateway Timeout` — upstream SSO or gateway call timed out.

### `GET /health`

Expand Down Expand Up @@ -88,7 +91,7 @@ Tag every image with a version **and** the git SHA. Don't push `:latest` for pro
```bash
VERSION=0.1.0
SHA=$(git rev-parse --short HEAD)
REPO=your-dockerhub-org/codev-backend
REPO=your-dockerhub-org/codev-proxy

docker build -t $REPO:$VERSION -t $REPO:$SHA .
```
Expand All @@ -103,7 +106,7 @@ docker push $REPO:$SHA

### 3. What to hand DevOps

- The image reference: `your-dockerhub-org/codev-backend:0.1.0`
- The image reference: `your-dockerhub-org/codev-proxy:0.1.0`
- The env var table above (they need to populate **required** vars; route **secret** ones through their secret store)
- The health check endpoint: `GET /health`
- The listening port: `8787` (or whatever `PORT` is set to)
Expand All @@ -113,7 +116,7 @@ docker push $REPO:$SHA
**Never** bake `.env` into the image — `.dockerignore` already excludes it. DevOps should inject at runtime using whichever pattern fits their platform:

- **Kubernetes** — non-secrets in a `ConfigMap`, secrets in a `Secret`, both mounted via `envFrom:` on the Deployment. Wire the `/health` endpoint to `livenessProbe` and `readinessProbe`.
- **Plain docker / systemd** — `docker run --env-file /etc/codev-backend/env ...` with the file owned `root:root` and `chmod 600`, or a systemd unit with `EnvironmentFile=`.
- **Plain docker / systemd** — `docker run --env-file /etc/codev-proxy/env ...` with the file owned `root:root` and `chmod 600`, or a systemd unit with `EnvironmentFile=`.
- **Docker Swarm** — `docker secret` for `AUTH_TOKEN`, standard env for the rest.
- **HashiCorp Vault / AWS Secrets Manager / Azure Key Vault** — inject via agent or init container at container start.

Expand Down
File renamed without changes.
2 changes: 1 addition & 1 deletion codev-backend/bun.lock → codev-proxy/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion codev-backend/package.json → codev-proxy/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "codev-backend",
"name": "codev-proxy",
"version": "0.1.0",
"type": "module",
"scripts": {
Expand Down
12 changes: 11 additions & 1 deletion codev-backend/src/config.ts → codev-proxy/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,20 @@ function required(name: string): string {
return value;
}

function port(): number {
const raw = process.env.PORT;
if (!raw) return 8787;
const n = Number(raw);
if (!Number.isInteger(n) || n <= 0 || n > 65535) {
throw new Error(`Invalid PORT: ${raw}`);
}
return n;
}

const nodeEnv = process.env.NODE_ENV ?? "development";

export const config = {
port: Number(process.env.PORT ?? 8787),
port: port(),
apiUrl: required("API_URL"),
authToken: required("AUTH_TOKEN"),
ssoUserinfoUrl: required("SSO_USERINFO_URL"),
Expand Down
2 changes: 1 addition & 1 deletion codev-backend/src/index.ts → codev-proxy/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,6 @@ if (import.meta.main) {
});

console.log(
`codev-backend listening on http://${server.hostname}:${server.port}`,
`codev-proxy listening on http://${server.hostname}:${server.port}`,
);
}
25 changes: 17 additions & 8 deletions codev-backend/src/litellm.ts → codev-proxy/src/litellm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,23 @@ interface KeyResponse {

export async function getOrProvisionKey(user: SsoUser): Promise<string> {
const username = user.email;
const res = await fetch(config.apiUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${config.authToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ username }),
});
let res: Response;
try {
res = await fetch(config.apiUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${config.authToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ username }),
signal: AbortSignal.timeout(10_000),
});
} catch (err) {
if (err instanceof DOMException && err.name === "TimeoutError") {
throw new LiteLlmError("Gateway request timed out", 504);
}
throw err;
}
console.log(`[gateway] POST ${config.apiUrl} → ${res.status}`);

if (!res.ok) {
Expand Down
15 changes: 12 additions & 3 deletions codev-backend/src/sso.ts → codev-proxy/src/sso.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,18 @@ export class SsoError extends Error {
}

export async function verifySsoToken(accessToken: string): Promise<SsoUser> {
const res = await fetch(config.ssoUserinfoUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
});
let res: Response;
try {
res = await fetch(config.ssoUserinfoUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
signal: AbortSignal.timeout(10_000),
});
} catch (err) {
if (err instanceof DOMException && err.name === "TimeoutError") {
throw new SsoError("SSO userinfo request timed out", 504);
}
throw err;
}

if (res.status === 401 || res.status === 403) {
throw new SsoError("Invalid or expired SSO token", 401);
Expand Down
Loading
Loading