From b90311f888a6c11daa24653ec55938af009f5c02 Mon Sep 17 00:00:00 2001 From: GideonBature Date: Thu, 26 Feb 2026 05:27:05 +0100 Subject: [PATCH] feat: Implement developer create API --- .gitignore | 1 + README.md | 30 +--- jest.config.cjs | 45 +---- package.json | 11 +- src/app.test.ts | 196 +++++++++++++++++++++- src/app.ts | 137 +++++++++++++-- src/controllers/depositController.ts | 1 - src/index.test.ts | 68 -------- src/index.ts | 63 +------ src/middleware/requireAuth.ts | 41 ----- src/repositories/apiRepository.drizzle.ts | 13 -- src/repositories/apiRepository.ts | 79 +++++++-- src/repositories/userRepository.ts | 21 --- src/routes/admin.ts | 5 - src/routes/developerRoutes.ts | 11 +- src/webhooks/webhook.routes.ts | 1 - tsconfig.jest.json | 10 ++ 17 files changed, 411 insertions(+), 322 deletions(-) create mode 100644 tsconfig.jest.json diff --git a/.gitignore b/.gitignore index 30c5f0d..7793e99 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ dist .env .env.* *.log +package-lock.json /src/generated/prisma /.idea diff --git a/README.md b/README.md index 03304d4..7ea2025 100644 --- a/README.md +++ b/README.md @@ -20,27 +20,9 @@ API gateway, usage metering, and billing services for the Callora API marketplac ## Vault repository behavior -<<<<<<< HEAD -Endpoint: - -`GET /api/developers/analytics` - -Authentication: - -- Requires `x-user-id` header (developer identity for now). - -Query params: - -- `from` (required): ISO date/time -- `to` (required): ISO date/time -- `groupBy` (optional): `day | week | month` (default: `day`) -- `apiId` (optional): filters to one API (must belong to authenticated developer) -- `includeTop` (optional): set to `true` to include `topEndpoints` and anonymized `topUsers` -======= - Enforces one vault per user per network. - `balanceSnapshot` is stored in smallest units using non-negative integer `bigint` values. - `findByUserId` is network-aware and returns the vault for a specific user/network pair. ->>>>>>> main ## Local setup @@ -52,9 +34,9 @@ Query params: npm install npm run dev ``` -<<<<<<< HEAD 3. API base: `http://localhost:3000` + ### Docker Setup You can run the entire stack (API and PostgreSQL) locally using Docker Compose: @@ -63,10 +45,6 @@ You can run the entire stack (API and PostgreSQL) locally using Docker Compose: docker compose up --build ``` The API will be available at http://localhost:3000, and the PostgreSQL database will be mapped to local port 5432. -======= - -3. API base: [http://localhost:3000](http://localhost:3000). Example: [http://localhost:3000/api/health](http://localhost:3000/api/health). ->>>>>>> main ## Scripts @@ -102,12 +80,6 @@ callora-backend/ ## Environment -<<<<<<< HEAD - `PORT` — HTTP port (default: 3000). Optional for local dev. This repo is part of [Callora](https://github.com/your-org/callora). Frontend: `callora-frontend`. Contracts: `callora-contracts`. -======= -- `PORT` - HTTP port (default: 3000). Optional for local dev. - -This repo is part of [Callora](https://github.com/your-org/callora). Frontend: `callora-frontend`. Contracts: `callora-contracts`. ->>>>>>> main diff --git a/jest.config.cjs b/jest.config.cjs index a9b9167..aea241e 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -1,55 +1,20 @@ /** @type {import('ts-jest').JestConfigWithTsJest} */ module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', - testMatch: ['**/?(*.)+(spec|test).ts'], - preset: 'ts-jest/presets/default-esm', - testEnvironment: 'node', - testMatch: ['**/?(*.)+(spec|test).ts'], - extensionsToTreatAsEsm: ['.ts'], - transform: { - '^.+\\.ts$': ['ts-jest', { useESM: true, tsconfig: 'tsconfig.json' }] - } -export default { preset: 'ts-jest/presets/default-esm', testEnvironment: 'node', extensionsToTreatAsEsm: ['.ts'], - testMatch: ['**/?(*.)+(spec|test).ts'], moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1', }, transform: { - '^.+\\.ts$': [ + '^.+\\.tsx?$': [ 'ts-jest', { - useESM: false, - tsconfig: { - module: 'commonjs', - moduleResolution: 'node', - isolatedModules: true, - }, + useESM: true, + tsconfig: 'tsconfig.jest.json', + diagnostics: { ignoreCodes: [151002] }, }, ], }, -}; - '^.+\\.tsx?$': ['ts-jest', { - useESM: true, - tsconfig: { - module: 'ESNext', - moduleResolution: 'Bundler', - }, - }], - }, - collectCoverageFrom: [ - 'src/**/*.ts', - '!src/index.ts', - ], - coverageThreshold: { - global: { - lines: 95, - functions: 95, - branches: 95, - statements: 95, - }, - }, + testMatch: ['**/tests/integration/**/*.test.ts'], }; diff --git a/package.json b/package.json index 6df3f25..a2ca8ab 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,11 @@ "db:migrate": "drizzle-kit migrate", "db:studio": "drizzle-kit studio", "typecheck": "tsc --noEmit", - "test": "node --import tsx --test \"src/**/*.test.ts\"", - "test:unit": "node --import tsx --test \"src/**/*.test.ts\"", - "test:integration": "node --import tsx --test \"tests/integration/**/*.test.ts\"", - "test:coverage": "node --import tsx --test --experimental-test-coverage \"src/**/*.test.ts\" \"tests/integration/**/*.test.ts\"", + "test": "node --import tsx --experimental-test-module-mocks --test \"src/**/*.test.ts\"", + "test:unit": "node --import tsx --experimental-test-module-mocks --test \"src/**/*.test.ts\"", + "test:integration": "NODE_OPTIONS='--experimental-vm-modules' jest", + "test:coverage": "node --import tsx --experimental-test-module-mocks --experimental-test-coverage --test \"src/**/*.test.ts\"", "test:all": "npm run test:unit && npm run test:integration", - "test": "node --import tsx --test $(find src -name '*.test.ts')", - "test:coverage": "node --import tsx --test --experimental-test-coverage $(find src -name '*.test.ts')", "validate:issue-9": "node scripts/validate-issue-9.mjs" }, "dependencies": { @@ -62,6 +60,7 @@ "pg-mem": "^3.0.13", "prisma": "^7.4.1", "supertest": "^7.2.2", + "ts-jest": "^29.4.6", "tsx": "^4.7.0", "typescript": "^5.9.3", "typescript-eslint": "^8.56.1" diff --git a/src/app.test.ts b/src/app.test.ts index 75fe0ec..6c53f01 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import test from 'node:test'; +import test, { mock } from 'node:test'; import request from 'supertest'; import { createApp } from './app.js'; @@ -9,6 +9,15 @@ import type { ApiRepository, ApiListFilters } from './repositories/apiRepository import type { Developer } from './db/schema.js'; import type { DeveloperRepository } from './repositories/developerRepository.js'; import { InMemoryApiRepository } from './repositories/apiRepository.js'; +// Mock better-sqlite3 before any module that transitively imports it is loaded. +// This allows unit tests for app.ts to run without a compiled native binding. +await mock.module('better-sqlite3', { + defaultExport: class MockDatabase { + prepare() { return { get: () => null }; } + exec() {} + close() {} + }, +}); const seedRepository = () => new InMemoryUsageEventsRepository([ @@ -441,3 +450,188 @@ test('GET /api/apis/:id returns api with empty endpoints list', async () => { assert.equal(res.body.name, 'Empty API'); assert.deepEqual(res.body.endpoints, []); }); + +// --------------------------------------------------------------------------- +// POST /api/developers/apis — publish a new API +// --------------------------------------------------------------------------- + +const mockDeveloper = { id: 42, user_id: 'dev-1', name: 'Alice', website: null, description: null, category: null, created_at: new Date(), updated_at: new Date() }; + +const validApiBody = { + name: 'My Weather API', + description: 'Real-time weather data', + base_url: 'https://api.weather.example.com', + category: 'weather', + status: 'draft', + endpoints: [ + { + path: '/forecast', + method: 'GET', + price_per_call_usdc: '0.01', + description: 'Get forecast', + }, + ], +}; + +const makeApp = (hasDeveloper = true) => + createApp({ + usageEventsRepository: seedRepository(), + findDeveloperByUserId: async () => (hasDeveloper ? mockDeveloper : undefined), + createApiWithEndpoints: async (input) => ({ + id: 1, + developer_id: input.developer_id, + name: input.name, + description: input.description ?? null, + base_url: input.base_url, + logo_url: null, + category: input.category ?? null, + status: input.status ?? 'draft', + created_at: new Date(), + updated_at: new Date(), + endpoints: input.endpoints.map((ep, idx) => ({ + id: idx + 1, + api_id: 1, + path: ep.path, + method: ep.method, + price_per_call_usdc: ep.price_per_call_usdc, + description: ep.description ?? null, + created_at: new Date(), + updated_at: new Date(), + })), + }), + }); + +test('POST /api/developers/apis returns 401 when unauthenticated', async () => { + const app = makeApp(); + const res = await request(app).post('/api/developers/apis').send(validApiBody); + assert.equal(res.status, 401); + assert.equal(res.body.code, 'UNAUTHORIZED'); +}); + +test('POST /api/developers/apis returns 400 when name is missing', async () => { + const app = makeApp(); + const { name: _n, ...body } = validApiBody; + const res = await request(app) + .post('/api/developers/apis') + .set('x-user-id', 'dev-1') + .send(body); + assert.equal(res.status, 400); + assert.match(res.body.error, /name/i); +}); + +test('POST /api/developers/apis returns 400 when base_url is missing', async () => { + const app = makeApp(); + const { base_url: _b, ...body } = validApiBody; + const res = await request(app) + .post('/api/developers/apis') + .set('x-user-id', 'dev-1') + .send(body); + assert.equal(res.status, 400); + assert.match(res.body.error, /base_url/i); +}); + +test('POST /api/developers/apis returns 400 when base_url is not a valid URL', async () => { + const app = makeApp(); + const res = await request(app) + .post('/api/developers/apis') + .set('x-user-id', 'dev-1') + .send({ ...validApiBody, base_url: 'not-a-url' }); + assert.equal(res.status, 400); + assert.match(res.body.error, /base_url/i); +}); + +test('POST /api/developers/apis returns 400 when status is invalid', async () => { + const app = makeApp(); + const res = await request(app) + .post('/api/developers/apis') + .set('x-user-id', 'dev-1') + .send({ ...validApiBody, status: 'published' }); + assert.equal(res.status, 400); + assert.match(res.body.error, /status/i); +}); + +test('POST /api/developers/apis returns 400 when endpoints is not an array', async () => { + const app = makeApp(); + const res = await request(app) + .post('/api/developers/apis') + .set('x-user-id', 'dev-1') + .send({ ...validApiBody, endpoints: 'bad' }); + assert.equal(res.status, 400); + assert.match(res.body.error, /endpoints/i); +}); + +test('POST /api/developers/apis returns 400 when an endpoint path does not start with /', async () => { + const app = makeApp(); + const res = await request(app) + .post('/api/developers/apis') + .set('x-user-id', 'dev-1') + .send({ + ...validApiBody, + endpoints: [{ path: 'no-slash', method: 'GET', price_per_call_usdc: '0.01' }], + }); + assert.equal(res.status, 400); + assert.match(res.body.error, /path/i); +}); + +test('POST /api/developers/apis returns 400 when an endpoint method is invalid', async () => { + const app = makeApp(); + const res = await request(app) + .post('/api/developers/apis') + .set('x-user-id', 'dev-1') + .send({ + ...validApiBody, + endpoints: [{ path: '/data', method: 'FETCH', price_per_call_usdc: '0.01' }], + }); + assert.equal(res.status, 400); + assert.match(res.body.error, /method/i); +}); + +test('POST /api/developers/apis returns 400 when price_per_call_usdc is invalid', async () => { + const app = makeApp(); + const res = await request(app) + .post('/api/developers/apis') + .set('x-user-id', 'dev-1') + .send({ + ...validApiBody, + endpoints: [{ path: '/data', method: 'GET', price_per_call_usdc: 'free' }], + }); + assert.equal(res.status, 400); + assert.match(res.body.error, /price_per_call_usdc/i); +}); + +test('POST /api/developers/apis returns 400 with DEVELOPER_NOT_FOUND when no developer profile', async () => { + const app = makeApp(false); + const res = await request(app) + .post('/api/developers/apis') + .set('x-user-id', 'dev-1') + .send(validApiBody); + assert.equal(res.status, 400); + assert.equal(res.body.code, 'DEVELOPER_NOT_FOUND'); +}); + +test('POST /api/developers/apis returns 201 with created API and endpoints', async () => { + const app = makeApp(); + const res = await request(app) + .post('/api/developers/apis') + .set('x-user-id', 'dev-1') + .send(validApiBody); + assert.equal(res.status, 201); + assert.equal(res.body.name, validApiBody.name); + assert.equal(res.body.base_url, validApiBody.base_url); + assert.equal(res.body.developer_id, mockDeveloper.id); + assert.ok(Array.isArray(res.body.endpoints)); + assert.equal(res.body.endpoints.length, 1); + assert.equal(res.body.endpoints[0].path, '/forecast'); + assert.equal(res.body.endpoints[0].method, 'GET'); +}); + +test('POST /api/developers/apis returns 201 when endpoints array is empty', async () => { + const app = makeApp(); + const res = await request(app) + .post('/api/developers/apis') + .set('x-user-id', 'dev-1') + .send({ ...validApiBody, endpoints: [] }); + assert.equal(res.status, 201); + assert.ok(Array.isArray(res.body.endpoints)); + assert.equal(res.body.endpoints.length, 0); +}); diff --git a/src/app.ts b/src/app.ts index 8a7e5f6..5961047 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,37 +1,45 @@ import express from 'express'; -import type { Pool } from 'pg'; import cors from 'cors'; import adminRouter from './routes/admin.js'; - import { InMemoryUsageEventsRepository, type GroupBy, type UsageEventsRepository, } from './repositories/usageEventsRepository.js'; -import { defaultApiRepository, type ApiRepository } from './repositories/apiRepository.js'; -import { defaultDeveloperRepository, type DeveloperRepository } from './repositories/developerRepository.js'; -import { apiStatusEnum, type ApiStatus } from './db/schema.js'; +import { + defaultApiRepository, + type ApiRepository, + type CreateApiInput, + type ApiWithEndpoints, + createApi, +} from './repositories/apiRepository.js'; +import { + defaultDeveloperRepository, + type DeveloperRepository, + findByUserId, +} from './repositories/developerRepository.js'; +import { apiStatusEnum, type ApiStatus, httpMethodEnum } from './db/schema.js'; +import type { Developer } from './db/schema.js'; import { requireAuth, type AuthenticatedLocals } from './middleware/requireAuth.js'; import { buildDeveloperAnalytics } from './services/developerAnalytics.js'; import { errorHandler } from './middleware/errorHandler.js'; import { performHealthCheck, type HealthCheckConfig } from './services/healthCheck.js'; - -interface AppDependencies { - usageEventsRepository: UsageEventsRepository; - healthCheckConfig?: HealthCheckConfig; -import adminRouter from './routes/admin.js'; import { parsePagination, paginatedResponse } from './lib/pagination.js'; import { InMemoryVaultRepository, type VaultRepository } from './repositories/vaultRepository.js'; import { DepositController } from './controllers/depositController.js'; import { TransactionBuilderService } from './services/transactionBuilder.js'; import { requestIdMiddleware } from './middleware/requestId.js'; import { requestLogger } from './middleware/logging.js'; +import { BadRequestError } from './errors/index.js'; interface AppDependencies { - usageEventsRepository: UsageEventsRepository; - vaultRepository: VaultRepository; - apiRepository: ApiRepository; - developerRepository: DeveloperRepository; + usageEventsRepository?: UsageEventsRepository; + healthCheckConfig?: HealthCheckConfig; + vaultRepository?: VaultRepository; + apiRepository?: ApiRepository; + developerRepository?: DeveloperRepository; + findDeveloperByUserId?: (userId: string) => Promise; + createApiWithEndpoints?: (input: CreateApiInput) => Promise; } const isValidGroupBy = (value: string): value is GroupBy => @@ -69,6 +77,8 @@ export const createApp = (dependencies?: Partial) => { dependencies?.usageEventsRepository ?? new InMemoryUsageEventsRepository(); const vaultRepository = dependencies?.vaultRepository ?? new InMemoryVaultRepository(); + const lookupDeveloper = dependencies?.findDeveloperByUserId ?? findByUserId; + const persistApi = dependencies?.createApiWithEndpoints ?? createApi; // Initialize deposit controller const transactionBuilder = new TransactionBuilderService(); @@ -134,11 +144,10 @@ export const createApp = (dependencies?: Partial) => { }, }); } + }); + app.use('/api/admin', adminRouter); - app.get('/api/health', (_req, res) => { - res.json({ status: 'ok', service: 'callora-backend' }); - }); app.get('/api/apis', (req, res) => { const { limit, offset } = parsePagination(req.query as { limit?: string; offset?: string }); @@ -299,6 +308,100 @@ export const createApp = (dependencies?: Partial) => { depositController.prepareDeposit(req, res); }); + // POST /api/developers/apis — publish a new API (authenticated) + app.post('/api/developers/apis', requireAuth, async (req, res: express.Response, next) => { + try { + const user = res.locals.authenticatedUser; + if (!user) { + next(new BadRequestError('Unauthorized')); + return; + } + + const { name, description, base_url, category, status, endpoints } = req.body as Record; + + // Validate required string fields + if (!name || typeof name !== 'string' || name.trim() === '') { + next(new BadRequestError('name is required')); + return; + } + + if (!base_url || typeof base_url !== 'string' || base_url.trim() === '') { + next(new BadRequestError('base_url is required')); + return; + } + + // Validate base_url is a proper URL + try { + new URL(base_url); + } catch { + next(new BadRequestError('base_url must be a valid URL (e.g. https://api.example.com)')); + return; + } + + // Validate optional status + if (status !== undefined && !apiStatusEnum.includes(status as typeof apiStatusEnum[number])) { + next(new BadRequestError(`status must be one of: ${apiStatusEnum.join(', ')}`)); + return; + } + + // Validate endpoints array + if (!Array.isArray(endpoints)) { + next(new BadRequestError('endpoints must be an array')); + return; + } + + for (let i = 0; i < endpoints.length; i++) { + const ep = endpoints[i] as Record; + + if (!ep.path || typeof ep.path !== 'string' || !ep.path.startsWith('/')) { + next(new BadRequestError(`endpoints[${i}].path must be a string starting with /`)); + return; + } + + if (!ep.method || !httpMethodEnum.includes(ep.method as typeof httpMethodEnum[number])) { + next(new BadRequestError(`endpoints[${i}].method must be one of: ${httpMethodEnum.join(', ')}`)); + return; + } + + if ( + !ep.price_per_call_usdc || + typeof ep.price_per_call_usdc !== 'string' || + isNaN(parseFloat(ep.price_per_call_usdc)) || + parseFloat(ep.price_per_call_usdc) < 0 + ) { + next(new BadRequestError(`endpoints[${i}].price_per_call_usdc must be a non-negative numeric string`)); + return; + } + } + + // Ensure the caller has a developer profile + const developer = await lookupDeveloper(user.id); + if (!developer) { + next(new BadRequestError('Developer profile not found. Create a developer profile first.', 'DEVELOPER_NOT_FOUND')); + return; + } + + const api = await persistApi({ + developer_id: developer.id, + name: name.trim(), + description: typeof description === 'string' ? description : null, + base_url: base_url.trim(), + category: typeof category === 'string' ? category : null, + status: (status as typeof apiStatusEnum[number]) ?? 'draft', + endpoints: (endpoints as Array>).map((ep) => ({ + path: ep.path as string, + method: ep.method as typeof httpMethodEnum[number], + price_per_call_usdc: ep.price_per_call_usdc as string, + description: typeof ep.description === 'string' ? ep.description : null, + })), + }); + + res.status(201).json(api); + } catch (err) { + next(err); + } + }); + app.use(errorHandler); return app; }; diff --git a/src/controllers/depositController.ts b/src/controllers/depositController.ts index a23e304..2a4cb70 100644 --- a/src/controllers/depositController.ts +++ b/src/controllers/depositController.ts @@ -145,7 +145,6 @@ export class DepositController { function: unsignedTx.operation.function as 'deposit', args: unsignedTx.operation.args, }, - operation: unsignedTx.operation as DepositPrepareResponse['operation'], metadata: { fee: unsignedTx.fee, timeout: unsignedTx.timeout, diff --git a/src/index.test.ts b/src/index.test.ts index 4371fd6..8b653c3 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,4 +1,3 @@ -/// import assert from 'node:assert/strict'; import test from 'node:test'; import request from 'supertest'; @@ -9,70 +8,3 @@ test('Health API returns ok status', async () => { assert.equal(response.status, 200); assert.equal(response.body.status, 'ok'); }); - -describe('POST /api/apis/:apiId/keys', () => { - it('creates an API key for an authenticated user and returns key + prefix once', async () => { - const response = await request(app) - .post('/api/apis/weather-api/keys') - .set('authorization', 'Bearer user-123') - .send({ - scopes: ['read:usage'], - rate_limit_per_minute: 120 - }); - - assert.equal(response.status, 201); - assert.ok(response.body.key); - assert.ok(response.body.prefix); - assert.ok(response.body.key.startsWith('ck_live_')); - assert.ok(response.body.key.startsWith(response.body.prefix)); - - const stored = apiKeyRepository.listForTesting().at(-1); - assert.equal(stored?.prefix, response.body.prefix); - assert.ok(stored?.keyHash); - assert.equal((stored as unknown as { key?: string })?.key, undefined); - }); - - it('returns 401 when unauthenticated', async () => { - const response = await request(app).post('/api/apis/weather-api/keys').send({}); - assert.equal(response.status, 401); - }); - - it('returns 400 when scopes are invalid', async () => { - const response = await request(app) - .post('/api/apis/weather-api/keys') - .set('authorization', 'Bearer user-123') - .send({ scopes: [123] }); - - assert.equal(response.status, 400); - }); - - it('returns 400 when rate_limit_per_minute is invalid', async () => { - const response = await request(app) - .post('/api/apis/weather-api/keys') - .set('authorization', 'Bearer user-123') - .send({ rate_limit_per_minute: 0 }); - - assert.equal(response.status, 400); - }); - - it('returns 404 when API is not published and active', async () => { - const draftApiResponse = await request(app) - .post('/api/apis/draft-api/keys') - .set('authorization', 'Bearer user-123') - .send({}); - - const inactiveApiResponse = await request(app) - .post('/api/apis/inactive-api/keys') - .set('authorization', 'Bearer user-123') - .send({}); - - const missingApiResponse = await request(app) - .post('/api/apis/missing-api/keys') - .set('authorization', 'Bearer user-123') - .send({}); - - assert.equal(draftApiResponse.status, 404); - assert.equal(inactiveApiResponse.status, 404); - assert.equal(missingApiResponse.status, 404); - }); -}); diff --git a/src/index.ts b/src/index.ts index 4bd0589..c716751 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,77 +1,21 @@ import { fileURLToPath } from 'node:url'; -import dotenv from 'dotenv'; -import express from "express"; -import { config } from "./config/index.js"; -import routes from "./routes/index.js"; - -const app = express(); -import express from 'express'; -import developerRoutes from './routes/developerRoutes.js'; -import { createGatewayRouter } from './routes/gatewayRoutes.js'; -import { createProxyRouter } from './routes/proxyRoutes.js'; -import { createBillingService } from './services/billingService.js'; -import { createRateLimiter } from './services/rateLimiter.js'; -import { createUsageStore } from './services/usageStore.js'; -import { createApiRegistry } from './data/apiRegistry.js'; -import { ApiKey } from './types/gateway.js'; import 'dotenv/config'; -import { fileURLToPath } from 'node:url'; import { createApp } from './app.js'; import { buildHealthCheckConfig, closeDbPool } from './config/health.js'; - -// Load environment variables -dotenv.config(); - -const healthCheckConfig = buildHealthCheckConfig(); -const app = createApp({ healthCheckConfig }); -const PORT = process.env.PORT ?? 3000; import { logger } from './logger.js'; import { metricsMiddleware, metricsEndpoint } from './metrics.js'; -const app = createApp(); +const healthCheckConfig = buildHealthCheckConfig(); +const app = createApp({ healthCheckConfig }); const PORT = process.env.PORT ?? 3000; -app.use(express.json()); -app.use('/api/developers', developerRoutes); - -// Shared services -const billing = createBillingService({ dev_001: 1000 }); -const rateLimiter = createRateLimiter(100, 60_000); -const usageStore = createUsageStore(); - -const apiKeys = new Map([ - ['test-key-1', { key: 'test-key-1', developerId: 'dev_001', apiId: 'api_001' }], -]); - -// Legacy gateway route (existing) -const gatewayRouter = createGatewayRouter({ - billing, - rateLimiter, - usageStore, - upstreamUrl: process.env.UPSTREAM_URL ?? 'http://localhost:4000', - apiKeys, -}); -app.use('/api/gateway', gatewayRouter); - -// New proxy route: /v1/call/:apiSlugOrId/* -const proxyRouter = createProxyRouter({ - billing, - rateLimiter, - usageStore, - registry: createApiRegistry(), - apiKeys, - proxyConfig: { - timeoutMs: parseInt(process.env.PROXY_TIMEOUT_MS ?? '30000', 10), - }, -}); -app.use('/v1/call', proxyRouter); // Inject the metrics middleware globally to track all incoming requests app.use(metricsMiddleware); app.get('/api/metrics', metricsEndpoint); if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { app.listen(PORT, () => { - console.log(`Callora backend listening on http://localhost:${PORT}`); + logger.info(`Callora backend listening on http://localhost:${PORT}`); if (healthCheckConfig) { console.log('✅ Health check endpoint enabled at /api/health'); } @@ -88,7 +32,6 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { console.log('SIGINT received, closing connections...'); await closeDbPool(); process.exit(0); - logger.info(`Callora backend listening on http://localhost:${PORT}`); }); } diff --git a/src/middleware/requireAuth.ts b/src/middleware/requireAuth.ts index 9a1e418..01dde0d 100644 --- a/src/middleware/requireAuth.ts +++ b/src/middleware/requireAuth.ts @@ -1,44 +1,3 @@ -import { Request, Response, NextFunction } from 'express'; - -/** - * Mock token → developerId map. - * Replace with real JWT / session validation in production. - */ -const MOCK_TOKENS: Record = { - 'dev-token-1': 'dev_001', - 'dev-token-2': 'dev_002', -}; - -// Extend Express Request to carry the authenticated developer id -declare module 'express-serve-static-core' { - interface Request { - developerId?: string; - } -} - -/** - * Middleware that requires a valid Bearer token. - * On success it sets `req.developerId`; on failure it returns 401. - */ -export function requireAuth(req: Request, res: Response, next: NextFunction): void { - const authHeader = req.headers.authorization; - - if (!authHeader || !authHeader.startsWith('Bearer ')) { - res.status(401).json({ error: 'Unauthorized: missing or invalid token' }); - return; - } - - const token = authHeader.slice(7); // strip "Bearer " - const developerId = MOCK_TOKENS[token]; - - if (!developerId) { - res.status(401).json({ error: 'Unauthorized: invalid token' }); - return; - } - - req.developerId = developerId; - next(); -} import type { NextFunction, Request, Response } from 'express'; import type { AuthenticatedUser } from '../types/auth.js'; diff --git a/src/repositories/apiRepository.drizzle.ts b/src/repositories/apiRepository.drizzle.ts index daf35ab..2905f16 100644 --- a/src/repositories/apiRepository.drizzle.ts +++ b/src/repositories/apiRepository.drizzle.ts @@ -9,19 +9,6 @@ export class DrizzleApiRepository implements ApiRepository { if (filters.status) { conditions.push(eq(schema.apis.status, filters.status)); } - const results = await db.select().from(schema.apis).where(and(...conditions)); - let rows = results as Api[]; - if (typeof filters.offset === 'number') { - rows = rows.slice(filters.offset); - } - if (typeof filters.limit === 'number') { - rows = rows.slice(0, filters.limit); - } - return rows; - const conditions = [eq(schema.apis.developer_id, developerId)]; - if (filters.status) { - conditions.push(eq(schema.apis.status, filters.status)); - } let query = db.select().from(schema.apis).where(and(...conditions)); diff --git a/src/repositories/apiRepository.ts b/src/repositories/apiRepository.ts index e31250f..b644464 100644 --- a/src/repositories/apiRepository.ts +++ b/src/repositories/apiRepository.ts @@ -1,7 +1,6 @@ import { eq, and, type SQL } from 'drizzle-orm'; -import { eq, and } from 'drizzle-orm'; import { db, schema } from '../db/index.js'; -import type { Api, ApiStatus } from '../db/schema.js'; +import type { Api, ApiEndpoint, NewApi, NewApiEndpoint, ApiStatus, HttpMethod } from '../db/schema.js'; export interface ApiListFilters { status?: ApiStatus; @@ -42,24 +41,10 @@ export interface ApiRepository { export const defaultApiRepository: ApiRepository = { async listByDeveloper(developerId, filters = {}) { const conditions: SQL[] = [eq(schema.apis.developer_id, developerId)]; - const conditions = [eq(schema.apis.developer_id, developerId)]; if (filters.status) { conditions.push(eq(schema.apis.status, filters.status)); } - const results = await db - .select() - .from(schema.apis) - .where(and(...conditions)); - - let rows = results as Api[]; - if (typeof filters.offset === 'number') { - rows = rows.slice(filters.offset); - } - if (typeof filters.limit === 'number') { - rows = rows.slice(0, filters.limit); - } - return rows; let query = db.select().from(schema.apis).where(and(...conditions)); if (typeof filters.limit === 'number') { @@ -108,3 +93,65 @@ export class InMemoryApiRepository implements ApiRepository { return this.endpointsByApiId.get(apiId) ?? []; } } + +// --- Create API (production) --- + +export interface CreateEndpointInput { + path: string; + method: HttpMethod; + price_per_call_usdc: string; + description?: string | null; +} + +export interface CreateApiInput { + developer_id: number; + name: string; + description?: string | null; + base_url: string; + category?: string | null; + status?: ApiStatus; + endpoints: CreateEndpointInput[]; +} + +export interface ApiWithEndpoints extends Api { + endpoints: ApiEndpoint[]; +} + +export async function createApi(input: CreateApiInput): Promise { + const { endpoints, ...apiData } = input; + + const [api] = await db + .insert(schema.apis) + .values({ + developer_id: apiData.developer_id, + name: apiData.name, + description: apiData.description ?? null, + base_url: apiData.base_url, + category: apiData.category ?? null, + status: apiData.status ?? 'draft', + } as NewApi) + .returning(); + + if (!api) throw new Error('API insert failed'); + + let endpointRows: ApiEndpoint[] = []; + if (endpoints.length > 0) { + endpointRows = await db + .insert(schema.apiEndpoints) + .values( + endpoints.map( + (e) => + ({ + api_id: api.id, + path: e.path, + method: e.method, + price_per_call_usdc: e.price_per_call_usdc, + description: e.description ?? null, + }) as NewApiEndpoint, + ), + ) + .returning(); + } + + return { ...api, endpoints: endpointRows }; +} diff --git a/src/repositories/userRepository.ts b/src/repositories/userRepository.ts index 0830773..2b2888f 100644 --- a/src/repositories/userRepository.ts +++ b/src/repositories/userRepository.ts @@ -10,18 +10,6 @@ interface FindUsersResult { } export async function findUsers(params: PaginationParams): Promise { - -interface PaginatedUsers { - users: Pick[]; - total: number; - page: number; - limit: number; - totalPages: number; -} - -export async function findUsers(page: number, limit: number): Promise { - const skip = (page - 1) * limit; - const [users, total] = await prisma.$transaction([ prisma.user.findMany({ select: { @@ -32,18 +20,9 @@ export async function findUsers(page: number, limit: number): Promise { const { limit, offset } = parsePagination(req.query as { limit?: string; offset?: string }); const { users, total } = await findUsers({ limit, offset }); res.json(paginatedResponse(users, { total, limit, offset })); - const page = Math.max(1, parseInt(req.query.page as string, 10) || 1); - const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string, 10) || 20)); - - const result = await findUsers(page, limit); - res.json(result); } catch (error) { console.error('Failed to list users:', error); res.status(500).json({ error: 'Internal server error' }); diff --git a/src/routes/developerRoutes.ts b/src/routes/developerRoutes.ts index 2289844..c3cfe7a 100644 --- a/src/routes/developerRoutes.ts +++ b/src/routes/developerRoutes.ts @@ -1,5 +1,5 @@ import { Router, Request, Response } from 'express'; -import { requireAuth } from '../middleware/requireAuth.js'; +import { requireAuth, type AuthenticatedLocals } from '../middleware/requireAuth.js'; import { getSettlements, getRevenueSummary } from '../data/developerData.js'; import { DeveloperRevenueResponse } from '../types/developer.js'; @@ -15,8 +15,13 @@ const router = Router(); * limit – number of settlements to return (default 20, max 100) * offset – pagination offset (default 0) */ -router.get('/revenue', requireAuth, (req: Request, res: Response) => { - const developerId = req.developerId!; +router.get('/revenue', requireAuth, (req: Request, res: Response) => { + const user = res.locals.authenticatedUser; + if (!user) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + const developerId = user.id; // Parse & clamp query params let limit = parseInt(req.query.limit as string, 10); diff --git a/src/webhooks/webhook.routes.ts b/src/webhooks/webhook.routes.ts index 938bc52..803680f 100644 --- a/src/webhooks/webhook.routes.ts +++ b/src/webhooks/webhook.routes.ts @@ -62,7 +62,6 @@ router.get('/:developerId', (req: Request, res: Response) => { // Never expose the secret // eslint-disable-next-line @typescript-eslint/no-unused-vars const { secret: _s, ...safeConfig } = config; - const { secret: _s, ...safeConfig } = config; // eslint-disable-line @typescript-eslint/no-unused-vars return res.json(safeConfig); }); diff --git a/tsconfig.jest.json b/tsconfig.jest.json new file mode 100644 index 0000000..7816bb5 --- /dev/null +++ b/tsconfig.jest.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "isolatedModules": true, + "rootDir": "." + }, + "include": ["src", "tests"] +}