Skip to content
Open
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
1 change: 0 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,5 @@ load-tests
eslint.config.mjs
jest.config.ts
tsconfig.test.json
bun.lock
tmp-*
docs
13 changes: 9 additions & 4 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Legacy unversioned `/api/*` routes are deprecated and maintained for backward co

Replace the `/api` prefix with `/v1` (e.g. `/api/projects` → `/v1/projects`). Notes:

- Auth is not identical between the two mounts (for example `/api/portfolio` requires an API key while `/v1/portfolio` does not; admin routes on `/v1` additionally require request signing). Follow the `/v1` requirements.
- Auth is the same on both mounts: `/api/portfolio` and `/v1/portfolio` both require an API key (`401` without one); admin routes on `/v1` additionally require request signing. Follow the `/v1` requirements.
- Legacy traffic is counted in the Prometheus metric `legacy_api_requests_total{path}`; check it to find remaining callers.
- On and after **2027-01-01T00:00:00Z** every `/api/*` request returns `410 Gone` with a `Link: </v1/...>; rel="successor-version"` header and a JSON body pointing at the `/v1` equivalent. The mounts are then deleted; see [docs/LEGACY_API_REMOVAL.md](docs/LEGACY_API_REMOVAL.md).

Expand Down Expand Up @@ -526,6 +526,9 @@ deterministic per `(address, clock hour)` — the same address returns the same
value within a clock hour, and `current_value` stays between `1.5x` and `2.0x`
of `current_shares`.

**Auth:** An API key is required on both the `/v1` and legacy `/api` mounts
(`401 unauthorized` without one).

**Response `200`**

```json
Expand Down Expand Up @@ -1331,9 +1334,11 @@ Computes and submits `update_impact_score` transactions to the Soroban smart con

**Headers**

| Header | Required | Value |
| :-------------- | :----------------------------- | :----------------------- |
| `Authorization` | Yes (when `ADMIN_API_KEY` set) | `Bearer <ADMIN_API_KEY>` |
| Header | Required | Value |
| :-------------- | :------- | :----------------------- |
| `Authorization` | Yes | `Bearer <ADMIN_API_KEY>` |

If `ADMIN_API_KEY` is unset the endpoint fails closed with `500 server_misconfigured`.

**Request Body (optional)**

Expand Down
30 changes: 24 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,15 +1,33 @@
# ── Build stage ──────────────────────────────────────────────────────────────
FROM node:20-alpine AS builder
# bun.lock is the canonical lockfile (CI installs with `bun install
# --frozen-lockfile`); there is no package-lock.json, so `npm ci` cannot work.
FROM oven/bun:1-alpine AS builder

WORKDIR /app

COPY package*.json ./
RUN npm ci --ignore-scripts
# Skip the husky prepare hook — it needs a .git checkout that images lack.
ENV HUSKY=0

COPY package.json bun.lock ./
RUN bun install --frozen-lockfile

COPY tsconfig.json ./
COPY src ./src

RUN npm run build
RUN bun run build

# ── Production dependency stage ─────────────────────────────────────────────
# Prod-only install for the Node runtime image below.
FROM oven/bun:1-alpine AS prod-deps

WORKDIR /app

ENV HUSKY=0

# --ignore-scripts: the `prepare` hook runs `husky`, which is a devDependency
# and therefore absent from a --production install.
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production --ignore-scripts && bun pm cache rm

# ── Production stage ──────────────────────────────────────────────────────────
FROM node:20-alpine AS production
Expand All @@ -25,8 +43,8 @@ ENV NODE_OPTIONS="--max-old-space-size=384"

WORKDIR /app

COPY package*.json ./
RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force
COPY --from=prod-deps /app/node_modules ./node_modules
COPY package.json ./

COPY --from=builder /app/dist ./dist

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ Create a `.env` file (see `.env.example`):
| `RPC_URL` | No | `https://soroban-testnet.stellar.org` | Stellar RPC endpoint |
| `PORT` | No | `3001` | HTTP port the server listens on |
| `FRONTEND_URL` | No | `http://localhost:3000` | Origin allowed by CORS |
| `ADMIN_API_KEY` | No | — | Bearer token for `/api/admin/*`. If unset, auth is skipped (dev only) |
| `ADMIN_API_KEY` | No | — | Bearer token for `/v1/admin/*` and other admin-gated routes. If unset, the server starts but admin endpoints fail closed with `500 server_misconfigured` |
| `RATE_LIMIT_WINDOW_MS` | No | `60000` | Public rate-limit window (ms) |
| `RATE_LIMIT_MAX` | No | `100` | Public max requests per IP per window |
| `RATE_LIMIT_ADMIN_WINDOW_MS` | No | `RATE_LIMIT_WINDOW_MS` | Admin rate-limit window (ms) |
Expand Down
56 changes: 55 additions & 1 deletion openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -904,16 +904,70 @@
}
}
},
"/portfolio": {
"/portfolio/{address}": {
"get": {
"summary": "Get portfolio summary",
"tags": [
"Portfolio"
],
"security": [
{
"ApiKeyAuth": []
}
],
"parameters": [
{
"name": "address",
"in": "path",
"required": true,
"schema": {
"type": "string"
},
"description": "Stellar account (G...) or contract (C...) strkey"
},
{
"name": "cursor",
"in": "query",
"schema": {
"type": "string"
},
"description": "Pagination cursor from a previous response's next_cursor"
},
{
"name": "limit",
"in": "query",
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 200
},
"description": "Maximum number of events to return (1-200)"
}
],
"responses": {
"200": {
"description": "Portfolio metrics"
},
"400": {
"description": "Invalid address or pagination parameters",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"429": {
"description": "Too many requests",
"content": {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"scripts": {
"dev": "node api.js && node --watch -r ts-node/register src/index.ts",
"dev:no-watch": "ts-node src/index.ts",
"build": "node api.js && tsc && mkdir -p dist/data dist/schemas && cp -R src/data/. dist/data/ && cp -R src/schemas/. dist/schemas/",
"build": "tsc && mkdir -p dist/data dist/schemas dist/proto && cp -R src/data/. dist/data/ && cp -R src/schemas/. dist/schemas/ && cp -R src/proto/. dist/proto/",
"build:dist-smoke": "npm run build && node -e \"require('./dist/lib/contractErrors'); console.log('dist contractErrors smoke test ok');\"",
"start": "node api.js && node dist/index.js",
"test": "jest",
Expand Down
4 changes: 3 additions & 1 deletion packages/api-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ const api = createApiClient({
// Fully typed API calls
const projects = await api.GET("/projects");
const project = await api.GET("/projects/{id}", { params: { path: { id: 1 } } });
const portfolio = await api.GET("/portfolio");
const portfolio = await api.GET("/portfolio/{address}", {
params: { path: { address: "GCFIRY65OQE7DFP5KLNS2PF2LVZMUZYJX4OZIEQ36N2IQANUB5XVYOJR" } },
});
```

## Regenerating
Expand Down
32 changes: 29 additions & 3 deletions packages/api-client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -538,7 +538,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/portfolio": {
"/portfolio/{address}": {
parameters: {
query?: never;
header?: never;
Expand All @@ -548,9 +548,17 @@ export interface paths {
/** Get portfolio summary */
get: {
parameters: {
query?: never;
query?: {
/** @description Pagination cursor from a previous response's next_cursor */
cursor?: string;
/** @description Maximum number of events to return (1-200) */
limit?: number;
};
header?: never;
path?: never;
path: {
/** @description Stellar account (G...) or contract (C...) strkey */
address: string;
};
cookie?: never;
};
requestBody?: never;
Expand All @@ -562,6 +570,24 @@ export interface paths {
};
content?: never;
};
/** @description Invalid address or pagination parameters */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Too many requests */
429: {
headers: {
Expand Down
4 changes: 3 additions & 1 deletion scripts/generate-api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,9 @@ const api = createApiClient({
// Fully typed API calls
const projects = await api.GET("/projects");
const project = await api.GET("/projects/{id}", { params: { path: { id: 1 } } });
const portfolio = await api.GET("/portfolio");
const portfolio = await api.GET("/portfolio/{address}", {
params: { path: { address: "GCFIRY65OQE7DFP5KLNS2PF2LVZMUZYJX4OZIEQ36N2IQANUB5XVYOJR" } },
});
\`\`\`

## Regenerating
Expand Down
12 changes: 5 additions & 7 deletions src/__tests__/benchmarking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,8 @@ describe("benchmarking routes", () => {
});

it("GET /api/benchmarking/benchmarks/:id — 404 for unknown benchmark", async () => {
const res = await request(app)
.get("/api/benchmarking/benchmarks/nonexistent")
.expect(404);
expect(res.body.error).toBe("not_found");
const res = await request(app).get("/api/benchmarking/benchmarks/nonexistent").expect(404);
expect(res.body.error.code).toBe("not_found");
});

it("POST /api/benchmarking/benchmarks — creates a custom benchmark with admin auth", async () => {
Expand All @@ -73,7 +71,7 @@ describe("benchmarking routes", () => {
thresholds: { poor: 10, fair: 30, good: 50, excellent: 80 },
})
.expect(401);
expect(res.body.error).toBe("unauthorized");
expect(res.body.error.code).toBe("unauthorized");
});

it("POST /api/benchmarking/benchmarks — 400 for missing fields", async () => {
Expand All @@ -95,7 +93,7 @@ describe("benchmarking routes", () => {
thresholds: { poor: 80, fair: 70, good: 90, excellent: 95 },
})
.expect(400);
expect(res.body.error).toBe("bad_request");
expect(res.body.error.code).toBe("bad_request");
});

it("POST /api/benchmarking/benchmarks — 400 when a built-in benchmark id is reused", async () => {
Expand All @@ -109,7 +107,7 @@ describe("benchmarking routes", () => {
thresholds: { poor: 20, fair: 40, good: 60, excellent: 80 },
})
.expect(400);
expect(res.body.error).toBe("bad_request");
expect(res.body.error.code).toBe("bad_request");
});

it("GET /api/benchmarking/:id — evaluates all benchmarks for a project", async () => {
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/chains.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ describe("chains routes", () => {
.post("/v1/chains/broadcast/1")
.send({ chains: ["ethereum"] })
.expect(501);
expect(res.body.error).toContain("only Stellar");
expect(res.body.error.message).toContain("only Stellar");
});

it("POST /v1/chains/broadcast/:projectId broadcasts to Stellar", async () => {
Expand Down
8 changes: 7 additions & 1 deletion src/__tests__/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ jest.mock("../lib/analytics", () => ({
summaryToCsv: jest.fn(),
}));

// Factory mock avoids loading the real registry module, which throws at import
// time when PROJECT_REGISTRY_CONTRACT_ID is unset (e.g. in CI).
jest.mock("../lib/registry", () => ({
projectExists: jest.fn().mockResolvedValue(true),
}));

import {
collectScores,
portfolioSummary,
Expand Down Expand Up @@ -123,6 +129,6 @@ describe("dashboard routes (#679)", () => {
mockedCollect.mockRejectedValueOnce(new Error("db down"));
const res = await request(app).get("/v1/dashboard/summary");
expect(res.status).toBe(500);
expect(res.body.error.code).toBe("INTERNAL_ERROR");
expect(res.body.error.code).toBe("internal_error");
});
});
6 changes: 3 additions & 3 deletions src/__tests__/error-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@ function createAppWithError(throwFn: () => void) {
}

describe("error handling middleware", () => {
it("unhandled error returns 500 with JSON body and INTERNAL_ERROR code", async () => {
it("unhandled error returns 500 with JSON body and internal_error code", async () => {
const app = createAppWithError(() => {
throw new Error("something broke");
});
const res = await request(app).get("/error").expect(500);
expect(res.headers["content-type"]).toMatch(/json/);
expect(res.body).toEqual({
error: {
code: "INTERNAL_ERROR",
code: "internal_error",
message: "An unexpected error occurred",
},
});
Expand Down Expand Up @@ -69,7 +69,7 @@ describe("error handling middleware", () => {
app.use(errorHandler);

const res = await request(app).get("/async-error").expect(500);
expect(res.body.error.code).toBe("INTERNAL_ERROR");
expect(res.body.error.code).toBe("internal_error");
expect(res.body.error.message).toBe("An unexpected error occurred");
});

Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/feature-flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ describe("feature flags", () => {
expect(res.status).toBe(500);
expect(res.body).toEqual({
error: {
code: "INTERNAL_ERROR",
code: "internal_error",
message: "An unexpected error occurred",
},
});
Expand Down Expand Up @@ -371,7 +371,7 @@ describe("feature flags", () => {
expect(res.status).toBe(500);
expect(res.body).toEqual({
error: {
code: "INTERNAL_ERROR",
code: "internal_error",
message: "An unexpected error occurred",
},
});
Expand Down
4 changes: 3 additions & 1 deletion src/__tests__/investorActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ const app = express();
app.use("/v1/investors", investorActivityRouter);
app.use(errorHandler);

const ADDRESS = `G${"A".repeat(55)}`;
// Checksum-valid G-address: the routes run StrKey.decodeEd25519PublicKey, which
// rejects fabricated addresses like "G" + "A".repeat(55).
const ADDRESS = "GAAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQDZ7H";

describe("GET /v1/investors/:address/activity", () => {
beforeEach(() => getActivity.mockResolvedValue({ events: [], next_cursor: null }));
Expand Down
Loading
Loading