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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ This is a **Bun monorepo** containing multiple sub-projects organized into apps,
### Apps

- **[apps/api](apps/api)** - Backend API service providing signature services, on/off-ramping flows, quote generation, and transaction state management
- **[apps/dashboard](apps/dashboard)** - Internal React dashboard for protected API client observability data
- **[apps/frontend](apps/frontend)** - React-based web application built with Vite for the Vortex user interface
- **[apps/rebalancer](apps/rebalancer)** - Service for automated liquidity rebalancing across chains
### Contracts
Expand Down Expand Up @@ -67,6 +68,7 @@ bun dev

This will start:
- **Frontend**: [http://127.0.0.1:5173/](http://127.0.0.1:5173)
- **Dashboard**: [http://127.0.0.1:5175/](http://127.0.0.1:5175)
- **Backend API**: [http://localhost:3000](http://localhost:3000)

#### Run Individual Projects
Expand All @@ -76,6 +78,11 @@ This will start:
bun dev:frontend
```

**Internal dashboard only:**
```bash
bun dev:dashboard
```

**Backend API only:**
```bash
bun dev:backend
Expand Down Expand Up @@ -103,6 +110,9 @@ bun build
# Build frontend
bun build:frontend

# Build internal dashboard
bun build:dashboard

# Build backend API
bun build:backend

Expand Down Expand Up @@ -178,6 +188,24 @@ bun start

See [apps/api/README.md](apps/api/README.md) for detailed API documentation.

### Internal Dashboard (apps/dashboard)

The internal dashboard displays sanitized API client observability events from `GET /v1/admin/api-client-events`. The backend endpoint requires `Authorization: Bearer <METRICS_DASHBOARD_SECRET>`, and the dashboard stores the entered token in browser session storage only. Use a different value than `ADMIN_SECRET` so a dashboard token compromise cannot access broader admin operations.

**Development:**
```bash
cd apps/dashboard
bun dev
```

**Build:**
```bash
cd apps/dashboard
bun build
```

For Netlify, set the build command to `bun build` from `apps/dashboard` or use the root script `bun build:dashboard`. `VITE_DASHBOARD_API_BASE` can override the API base URL; otherwise the app uses `http://localhost:3000` in development and the `/api/<environment>` Netlify redirects in deployed environments.

### Rebalancer (apps/rebalancer)

Service for automated liquidity rebalancing across chains.
Expand Down
4 changes: 4 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ SANDBOX_ENABLED=false
# Example: openssl rand -base64 32
ADMIN_SECRET=your-secure-admin-secret-here

# Internal metrics dashboard authentication
# Use a different secret than ADMIN_SECRET to reduce blast radius.
METRICS_DASHBOARD_SECRET=your-secure-metrics-dashboard-secret-here

# Supabase Configuration
SUPABASE_URL=https://your-project-id.supabase.co
SUPABASE_ANON_KEY=your-anon-key-here
Expand Down
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@
"name": "vortex-backend",
"scripts": {
"build": "bun run swc src -d dist --strip-leading-paths",
"dev": "concurrently -n 'shared,backend' -c '#ffa500,007755' 'cd ../../packages/shared && bun run dev' 'bun --watch src/index.ts'",
"dev": "concurrently -n 'shared,backend' -c '#ffa500,007755' 'cd ../../packages/shared && bun run dev' 'NODE_ENV=development bun --watch src/index.ts'",
"migrate": "bun -r @swc-node/register src/database/migrator.ts",
"migrate:revert": "bun -r @swc-node/register src/database/migrator.ts revert-all",
"migrate:revert-last": "bun -r @swc-node/register src/database/migrator.ts revert",
Expand Down
187 changes: 187 additions & 0 deletions apps/api/src/api/controllers/admin/apiClientEvents.controller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { afterEach, describe, expect, it, mock } from "bun:test";
import express from "express";
import httpStatus from "http-status";
import { config } from "../../../config/vars";
import ApiClientEvent from "../../../models/apiClientEvent.model";
import apiClientEventsRoutes from "../../routes/v1/admin/api-client-events.route";
import { listApiClientEvents } from "./apiClientEvents.controller";

type ResponseRecorder = {
body: unknown;
statusCode: number;
json: ReturnType<typeof mock>;
status: ReturnType<typeof mock>;
};

function createResponse() {
const res: ResponseRecorder = {
body: undefined,
json: mock((body: unknown) => {
res.body = body;
return res;
}),
status: mock((statusCode: number) => {
res.statusCode = statusCode;
return res;
}),
statusCode: Number(httpStatus.OK)
};

return res;
}

describe("api client events admin route", () => {
const originalAdminSecret = config.adminSecret;
const originalMetricsDashboardSecret = config.metricsDashboardSecret;

afterEach(() => {
config.adminSecret = originalAdminSecret;
config.metricsDashboardSecret = originalMetricsDashboardSecret;
});

async function fetchFromTestRoute(authorization?: string) {
const app = express();
app.use("/v1/admin/api-client-events", apiClientEventsRoutes);

const server = app.listen(0);
const address = server.address();

if (!address || typeof address === "string") {
server.close();
throw new Error("Could not bind test server");
}

try {
return await fetch(`http://127.0.0.1:${address.port}/v1/admin/api-client-events`, {
...(authorization ? { headers: { Authorization: authorization } } : {})
});
} finally {
server.close();
}
}

it("rejects requests without metrics dashboard authentication before querying events", async () => {
const response = await fetchFromTestRoute();
const body = await response.json();

expect(response.status).toBe(httpStatus.UNAUTHORIZED);
expect(body.error.code).toBe("METRICS_DASHBOARD_AUTH_REQUIRED");
});

it("rejects the admin secret because metrics access has a dedicated secret", async () => {
config.adminSecret = "admin-secret";
config.metricsDashboardSecret = "metrics-dashboard-secret";

const response = await fetchFromTestRoute("Bearer admin-secret");
const body = await response.json();

expect(response.status).toBe(httpStatus.FORBIDDEN);
expect(body.error.code).toBe("INVALID_METRICS_DASHBOARD_TOKEN");
});
});

describe("listApiClientEvents", () => {
const originalFindAndCountAll = ApiClientEvent.findAndCountAll;
const originalFindAll = ApiClientEvent.findAll;

afterEach(() => {
ApiClientEvent.findAndCountAll = originalFindAndCountAll;
ApiClientEvent.findAll = originalFindAll;
});

it("returns safe event fields, pagination, filters, and summary counts", async () => {
const findAndCountAllMock = mock(async (_options: unknown) => ({
count: 2,
rows: [
{
apiKeyPrefix: "sk_live_1234",
createdAt: new Date("2026-01-02T00:00:00.000Z"),
durationMs: 25,
errorMessage: null,
errorType: "none",
httpStatus: 200,
id: "event-1",
metadata: { endpoint: "/v1/quotes" },
operation: "quote_create",
partnerName: "Partner",
quoteId: "quote-1",
rampId: null,
requestId: "request-1",
status: "success"
}
]
}));
const findAllMock = mock(async (_options: unknown) => [
{ errorType: "none", operation: "quote_create", status: "success" },
{ errorType: "internal_error", operation: "ramp_start", status: "failure" }
]);

ApiClientEvent.findAndCountAll = findAndCountAllMock as unknown as typeof ApiClientEvent.findAndCountAll;
ApiClientEvent.findAll = findAllMock as unknown as typeof ApiClientEvent.findAll;

const res = createResponse();
await listApiClientEvents(
{
query: {
endDate: "2026-01-03T00:00:00.000Z",
limit: "500",
offset: "5",
operation: "quote_create",
partnerName: "Partner",
startDate: "2026-01-01T00:00:00.000Z",
status: "success"
}
} as Parameters<typeof listApiClientEvents>[0],
res as unknown as Parameters<typeof listApiClientEvents>[1]
);

const firstFindCall = findAndCountAllMock.mock.calls[0];
expect(firstFindCall).toBeDefined();

const findOptions = firstFindCall?.[0] as {
attributes: string[];
limit: number;
offset: number;
where: Record<string, unknown>;
};

expect(res.statusCode).toBe(httpStatus.OK);
expect(findOptions.limit).toBe(200);
expect(findOptions.offset).toBe(5);
expect(findOptions.attributes).not.toContain("partnerId");
expect(findOptions.attributes).not.toContain("userId");
expect(findOptions.where.operation).toBe("quote_create");
expect(findOptions.where.partnerName).toBe("Partner");
expect(findOptions.where.status).toBe("success");
expect(res.body).toEqual({
events: [
{
apiKeyPrefix: "sk_live_1234",
createdAt: new Date("2026-01-02T00:00:00.000Z"),
durationMs: 25,
errorMessage: null,
errorType: "none",
httpStatus: 200,
id: "event-1",
metadata: { endpoint: "/v1/quotes" },
operation: "quote_create",
partnerName: "Partner",
quoteId: "quote-1",
rampId: null,
requestId: "request-1",
status: "success"
}
],
limit: 200,
offset: 5,
summary: {
byErrorType: { internal_error: 1, none: 1 },
byOperation: { quote_create: 1, ramp_start: 1 },
byStatus: { failure: 1, success: 1 },
sampleSize: 2,
total: 2
},
total: 2
});
});
});
Loading
Loading