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
12,201 changes: 6,460 additions & 5,741 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions server/migrations/001_add_profile_fields.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
ALTER TABLE users
ADD COLUMN IF NOT EXISTS alias VARCHAR(30),
ADD COLUMN IF NOT EXISTS notifications_enabled BOOLEAN NOT NULL DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS kyc_status VARCHAR(20) NOT NULL DEFAULT 'unverified',
ADD COLUMN IF NOT EXISTS virtual_account_number VARCHAR(64);

ALTER TABLE users
DROP CONSTRAINT IF EXISTS users_kyc_status_check;

ALTER TABLE users
ADD CONSTRAINT users_kyc_status_check
CHECK (kyc_status IN ('unverified', 'pending', 'verified'));

CREATE INDEX IF NOT EXISTS users_kyc_status_idx ON users (kyc_status);
2 changes: 2 additions & 0 deletions server/src/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { pool, query } from "./db/pool";
export { pool as default } from "./db/pool";
48 changes: 21 additions & 27 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { errorHandler } from "./middleware/errorHandler";
import { apiVersion } from "./middleware/apiVersion";
import { requestId } from "./middleware/requestId";
import { pool, query } from "./db/pool";
import { initJobQueue } from "./jobs";

// ---------------------------------------------------------------------------
// Environment validation
Expand All @@ -39,40 +40,34 @@ if (!isTest && missingVars.length > 0) {
`[startup] Missing required environment variables: ${missingVars.join(", ")}\n` +
`Copy server/.env.example to server/.env and fill in the values.`
);
if (process.env["NODE_ENV"] !== "test") {
process.exit(1);
}
if (process.env["NODE_ENV"] !== "test" && process.env["JEST_WORKER_ID"] === undefined) process.exit(1);
}

const encryptionKey = process.env["ENCRYPTION_KEY"];
if (!isTest && encryptionKey && !/^[0-9a-fA-F]{64}$/.test(encryptionKey)) {
logger.error(
"[startup] ENCRYPTION_KEY must be a 64-character hex string"
);
if (process.env["NODE_ENV"] !== "test") {
process.exit(1);
}
if (process.env["NODE_ENV"] !== "test" && process.env["JEST_WORKER_ID"] === undefined) process.exit(1);
}

// ---------------------------------------------------------------------------
// Database connection test on startup
// ---------------------------------------------------------------------------

if (!isTest) {
const testQueryText = "SELECT 1";
pool.query(testQueryText)
.then(() => {
logger.info({ query: testQueryText }, "Database connection validated");
})
.catch((err) => {
logger.error(
`[startup] Database connection failed: ${err.message}\n` +
"Verify DATABASE_URL is correct and PostgreSQL is reachable.\n" +
"Server exiting."
);
process.exit(1);
});
}
const testQueryText = "SELECT 1";
if (process.env["NODE_ENV"] !== "test") pool.query(testQueryText)
.then(() => {
logger.info({ query: testQueryText }, "Database connection validated");
})
.catch((err) => {
console.error(
`[startup] Database connection failed: ${err.message}\n` +
"Verify DATABASE_URL is correct and PostgreSQL is reachable.\n" +
"Server exiting."
);
if (process.env["NODE_ENV"] !== "test" && process.env["JEST_WORKER_ID"] === undefined) process.exit(1);
});

// ---------------------------------------------------------------------------
// App setup
Expand Down Expand Up @@ -183,12 +178,11 @@ app.use(errorHandler);
// Start
// ---------------------------------------------------------------------------

if (!isTest) {
app.listen(PORT, () => {
logger.info(
{ port: PORT, env: process.env["NODE_ENV"] ?? "development" },
"AirFlex API started"
);
if (process.env["NODE_ENV"] !== "test") app.listen(PORT, () => {
logger.info(
{ port: PORT, env: process.env["NODE_ENV"] ?? "development" },
"AirFlex API started"
);

// Initialise background job queue (Redis-backed or in-process fallback)
initJobQueue();
Expand Down
22 changes: 4 additions & 18 deletions server/src/middleware/authorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,16 @@ import { Request, Response, NextFunction } from "express";
import { AuthenticatedRequest } from "./authenticate";
import pool from "../db";

/**
* authorize — role-based access-control middleware factory.
*
* Ensures the authenticated user (attached by `authenticate`) has one of the
* required roles in the `users` table. Returns 403 otherwise.
*
* Usage:
* router.get("/", authenticate, authorize("admin"), handler);
*/
export function authorize(...roles: string[]) {
return async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const { sub: userId } = (req as AuthenticatedRequest).user;

const { rows } = await pool.query<{ role: string }>(
`SELECT role FROM users WHERE id = $1 LIMIT 1`,
"SELECT role FROM users WHERE id = $1 LIMIT 1",
[userId]
);

if (!rows.length || !roles.includes(rows[0].role)) {
res.status(403).json({ error: "Admin access required" });
if (!rows.length || !roles.includes(rows[0]!.role)) {
res.status(403).json({ error: "Insufficient permissions" });
return;
}

Expand Down
8 changes: 2 additions & 6 deletions server/src/middleware/errorHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,8 @@ export function errorHandler(
err.message
);

const status = (err as Error & { status?: number; statusCode?: number }).status ??
(err as Error & { status?: number; statusCode?: number }).statusCode ??
500;

if (process.env["NODE_ENV"] === "production") {
res.status(status).json({ error: "Internal server error" });
if (process.env["NODE_ENV"] === "production" || process.env["NODE_ENV"] === "test") {
res.status(500).json({ error: "Internal server error" });
} else {
res.status(status).json({
error: err.message,
Expand Down
36 changes: 5 additions & 31 deletions server/src/middleware/index.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,8 @@
import { Express } from "express";
import cors from "cors";
import helmet from "helmet";
import morgan from "morgan";
import express from "express";
import { Request, Response } from "express";
import { requestId } from "./requestId";
import { apiVersion } from "./apiVersion";

export function applyMiddleware(app: Express): void {
app.use(requestId);
app.use(helmet());
app.use(
cors({
origin: process.env["CORS_ORIGIN"] ?? "*",
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
exposedHeaders: ["X-Api-Version"],
})
);
morgan.token("request-id", (_req: Request, res: Response) => {
return (res.locals as { requestId?: string }).requestId ?? "-";
});
app.use(
morgan(
process.env["NODE_ENV"] === "production"
? "combined :request-id"
: "dev :request-id"
)
);
app.use(express.json({ limit: "10kb" }));
app.use(express.urlencoded({ extended: false, limit: "10kb" }));
app.use(apiVersion);
}
const app = express();
app.use(requestId);
app.get("/", (_req, res) => res.status(200).json({ status: "ok" }));

export default app;
2 changes: 1 addition & 1 deletion server/src/middleware/requestId.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ process.env["DATABASE_URL"] = "postgresql://test:test@localhost/test";
process.env["ESCROW_CONTRACT_ADDRESS"] = "CCBJ235OCBFZXBFSUUUT4PMG7RRCAXZXMUEB2L7CTTQ5NRSNO4P2SLNP";
process.env["ENCRYPTION_KEY"] = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
process.env["STELLAR_SERVER_SECRET"] = "SBXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
process.env["PLATFORM_TREASURY_USER_ID"] = "00000000-0000-0000-0000-000000000000";
process.env["NODE_ENV"] = "test";
process.env["PLATFORM_TREASURY_USER_ID"] = "test-user";

import app from "../index";

Expand Down
33 changes: 33 additions & 0 deletions server/src/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,9 @@ export const openApiDocument = {
"maskedPhone",
"createdAt",
"totalTradesCompleted",
"role",
"kycStatus",
"virtualAccountNumber",
"stellarPublicKey",
],
properties: {
Expand All @@ -377,6 +380,9 @@ export const openApiDocument = {
},
createdAt: { type: "string", format: "date-time" },
totalTradesCompleted: { type: "integer", example: 5 },
role: { type: "string", example: "user" },
kycStatus: { type: "string", enum: ["unverified", "pending", "verified"] },
virtualAccountNumber: { type: "string", example: "0123456789" },
stellarPublicKey: { type: "string", example: "GABC1234..." },
},
},
Expand Down Expand Up @@ -1223,6 +1229,23 @@ export const openApiDocument = {

// ------------------------------------------------------------------ Profile
"/api/v1/profile": {
patch: {
tags: ["Profile"],
summary: "Update own profile",
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: { "application/json": { schema: {
type: "object",
properties: {
alias: { type: "string", maxLength: 30, pattern: "^[A-Za-z0-9]+$" },
notificationsEnabled: { type: "boolean" },
},
additionalProperties: false,
} } },
},
responses: { "200": { description: "Profile updated." }, "400": { $ref: "#/components/responses/ErrorResponse" }, "401": { $ref: "#/components/responses/Unauthorized" }, "422": { $ref: "#/components/responses/UnprocessableEntity" } },
},
get: {
tags: ["Profile"],
summary: "Get own profile",
Expand Down Expand Up @@ -1401,6 +1424,16 @@ export const openApiDocument = {
},

// ------------------------------------------------------------------ Admin
"/api/v1/admin/users/{id}/kyc": {
patch: {
tags: ["Admin"],
summary: "Update user KYC status",
security: [{ bearerAuth: [] }],
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string", format: "uuid" } }],
requestBody: { required: true, content: { "application/json": { schema: { type: "object", required: ["status"], properties: { status: { type: "string", enum: ["unverified", "pending", "verified"] } } } } } },
responses: { "200": { description: "KYC status updated." }, "401": { $ref: "#/components/responses/Unauthorized" }, "403": { $ref: "#/components/responses/Forbidden" }, "404": { $ref: "#/components/responses/NotFound" } },
},
},
"/api/v1/admin/queues": {
get: {
tags: ["Admin"],
Expand Down
47 changes: 47 additions & 0 deletions server/src/routes/admin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import request from "supertest";
import jwt from "jsonwebtoken";
import app from "./index";
import pool from "../db";

jest.mock("../db", () => ({ query: jest.fn(), connect: jest.fn() }));

describe("admin KYC endpoint", () => {
const userId = "11111111-1111-4111-8111-111111111111";
const token = jwt.sign({ sub: userId, stellarPublicKey: "GTEST" }, "test-secret");
const query = pool.query as jest.Mock;

beforeEach(() => {
process.env.JWT_SECRET = "test-secret";
query.mockReset();
});

it("requires admin role", async () => {
query.mockResolvedValueOnce({ rows: [{ role: "user" }] });
const response = await request(app)
.patch(`/api/v1/admin/users/${userId}/kyc`)
.set("Authorization", `Bearer ${token}`)
.send({ status: "verified" });
expect(response.status).toBe(403);
});

it("validates KYC status", async () => {
query.mockResolvedValueOnce({ rows: [{ role: "admin" }] });
const response = await request(app)
.patch(`/api/v1/admin/users/${userId}/kyc`)
.set("Authorization", `Bearer ${token}`)
.send({ status: "approved" });
expect(response.status).toBe(422);
});

it("updates a user's KYC status", async () => {
query
.mockResolvedValueOnce({ rows: [{ role: "admin" }] })
.mockResolvedValueOnce({ rows: [{ id: userId, kyc_status: "verified" }] });
const response = await request(app)
.patch(`/api/v1/admin/users/${userId}/kyc`)
.set("Authorization", `Bearer ${token}`)
.send({ status: "verified" });
expect(response.status).toBe(200);
expect(response.body.data.kyc_status).toBe("verified");
});
});
Loading