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
2 changes: 2 additions & 0 deletions api/migrations/sr159_api_agents.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- SR-159: Rollback — drop the api_agents table added by sr159_api_agents.sql
DROP TABLE IF EXISTS api_agents;
19 changes: 19 additions & 0 deletions api/migrations/sr159_api_agents.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- SR-159: Postgres-backed agent store for the REST API layer.
--
-- The GraphQL layer already queries an `agents` table (graphql/resolvers.ts).
-- This creates `api_agents` for the REST API to avoid a schema conflict while
-- both coexist. A future consolidation can UNION or rename as appropriate.

CREATE TABLE IF NOT EXISTS api_agents (
id VARCHAR(56) PRIMARY KEY,
stellar_address VARCHAR(56) NOT NULL UNIQUE,
payout_address TEXT NOT NULL,
name VARCHAR(255) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'active', 'suspended')),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_api_agents_stellar_address ON api_agents(stellar_address);
CREATE INDEX IF NOT EXISTS idx_api_agents_status ON api_agents(status);
71 changes: 71 additions & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,69 @@ paths:
schema:
$ref: '#/components/schemas/ErrorResponse'

/api/remittances/{id}/status:
patch:
tags:
- Remittances
summary: Transition a remittance status
description: Updates a remittance through the server-side status state machine. Requires an agent or admin JWT.
operationId: updateRemittanceStatus
security:
- BearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [status, webhook_endpoint]
properties:
status:
type: string
enum: [Pending, Processing, Completed, Cancelled, Failed, Disputed]
webhook_endpoint:
type: string
format: uri
responses:
'200':
description: Status transitioned successfully
'400':
description: Invalid request
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: Authentication required
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: Insufficient privileges
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: Remittance not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: Invalid status transition
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'

/api/agents:
post:
tags:
Expand Down Expand Up @@ -405,6 +468,8 @@ paths:
- Agents
summary: Get agent profile (Issue #880)
operationId: getAgent
security:
- BearerAuth: []
parameters:
- name: id
in: path
Expand All @@ -425,6 +490,12 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: Authentication required
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'

/api/agents/{id}/payout-address:
put:
Expand Down
20 changes: 17 additions & 3 deletions api/src/__tests__/agents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import { createApp } from '../app';
import { agentStore } from '../routes/agents';
import { bearer, useTestJwtSecret } from './helpers/authTestUtils';

const ADMIN_KEY = 'test-admin-key';
const VALID_ADDRESS = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN';
Expand Down Expand Up @@ -88,11 +89,22 @@ describe('POST /api/agents', () => {
});

describe('GET /api/agents/:id', () => {
beforeEach(() => agentStore.clear());
beforeEach(() => {
agentStore.clear();
useTestJwtSecret();
});

it('returns 404 for unknown agent', async () => {
it('returns 401 without authentication', async () => {
const app = createApp();
const res = await request(app).get(`/api/agents/${VALID_ADDRESS}`);
expect(res.status).toBe(401);
});

it('returns 404 for unknown agent', async () => {
const app = createApp();
const res = await request(app)
.get(`/api/agents/${VALID_ADDRESS}`)
.set('Authorization', bearer('user-1'));
expect(res.status).toBe(404);
expect(res.body.error.code).toBe('AGENT_NOT_FOUND');
});
Expand All @@ -105,7 +117,9 @@ describe('GET /api/agents/:id', () => {
.set('x-api-key', ADMIN_KEY)
.send({ stellar_address: VALID_ADDRESS, payout_address: 'iban456', name: 'Bob' });

const res = await request(app).get(`/api/agents/${VALID_ADDRESS}`);
const res = await request(app)
.get(`/api/agents/${VALID_ADDRESS}`)
.set('Authorization', bearer(VALID_ADDRESS, { role: 'agent' }));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.name).toBe('Bob');
Expand Down
51 changes: 27 additions & 24 deletions api/src/__tests__/governance-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import request from 'supertest';
import { bearer, useTestJwtSecret } from './helpers/authTestUtils';

// ─── Shared in-memory state (vi.hoisted so vi.mock factory can close over it) ─

Expand All @@ -9,7 +10,7 @@ const { pendingActions } = vi.hoisted(() => ({

// ─── Mock AdminConfirmationService with a faithful in-memory implementation ───

vi.mock('../../../backend/src/admin-confirmation', () => {
vi.mock('../admin-confirmation', () => {
let seq = 0;

class MockAdminConfirmationService {
Expand Down Expand Up @@ -78,15 +79,17 @@ vi.mock('../../../backend/src/admin-confirmation', () => {
// ─── Env vars must be set before app is imported ──────────────────────────────

process.env.DATABASE_URL = 'postgres://test:test@localhost/test';
process.env.ADMIN_API_KEY = 'governance-test-key';
useTestJwtSecret();

import { createApp } from '../app';
import { InMemoryAgentStore } from '../routes/agents';

const ADMIN_KEY = 'governance-test-key';
const ADMIN_1 = 'GADMIN1XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';
const ADMIN_2 = 'GADMIN2XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';
const ADMIN_1_AUTH = bearer(ADMIN_1, { role: 'admin' });
const ADMIN_2_AUTH = bearer(ADMIN_2, { role: 'admin' });

const app = createApp();
const app = createApp({ agentStore: new InMemoryAgentStore() });

// ─────────────────────────────────────────────────────────────────────────────

Expand All @@ -101,7 +104,7 @@ describe('Governance proposal lifecycle — integration', () => {
it('admin can initiate a high-risk governance proposal', async () => {
const res = await request(app)
.post('/api/admin/actions')
.set('x-api-key', ADMIN_KEY)
.set('Authorization', ADMIN_1_AUTH)
.send({ operation: 'update_fee', initiated_by: ADMIN_1, params: { fee_bps: 300 } });

expect(res.status).toBe(201);
Expand All @@ -123,21 +126,21 @@ describe('Governance proposal lifecycle — integration', () => {
it('rejects proposal with an invalid operation type', async () => {
const res = await request(app)
.post('/api/admin/actions')
.set('x-api-key', ADMIN_KEY)
.set('Authorization', ADMIN_1_AUTH)
.send({ operation: 'delete_everything', initiated_by: ADMIN_1 });

expect(res.status).toBe(400);
expect(res.body.error.code).toBe('INVALID_OPERATION');
});

it('rejects proposal when initiated_by is missing', async () => {
it('derives the initiator from the verified token', async () => {
const res = await request(app)
.post('/api/admin/actions')
.set('x-api-key', ADMIN_KEY)
.set('Authorization', ADMIN_1_AUTH)
.send({ operation: 'update_fee' });

expect(res.status).toBe(400);
expect(res.body.error.code).toBe('MISSING_FIELD');
expect(res.status).toBe(201);
expect(res.body.data.initiated_by).toBe(ADMIN_1);
});
});

Expand All @@ -147,15 +150,15 @@ describe('Governance proposal lifecycle — integration', () => {
it('second admin can vote to confirm a pending proposal', async () => {
const proposeRes = await request(app)
.post('/api/admin/actions')
.set('x-api-key', ADMIN_KEY)
.set('Authorization', ADMIN_1_AUTH)
.send({ operation: 'withdraw_fees', initiated_by: ADMIN_1, params: {} });

expect(proposeRes.status).toBe(201);
const actionId = proposeRes.body.data.id;

const confirmRes = await request(app)
.post(`/api/admin/actions/${actionId}/confirm`)
.set('x-api-key', ADMIN_KEY)
.set('Authorization', ADMIN_2_AUTH)
.send({ confirmed_by: ADMIN_2 });

expect(confirmRes.status).toBe(200);
Expand All @@ -167,15 +170,15 @@ describe('Governance proposal lifecycle — integration', () => {
it('the initiating admin cannot vote on their own proposal (self-confirm blocked)', async () => {
const proposeRes = await request(app)
.post('/api/admin/actions')
.set('x-api-key', ADMIN_KEY)
.set('Authorization', ADMIN_1_AUTH)
.send({ operation: 'remove_agent', initiated_by: ADMIN_1, params: { agent: 'GXXX' } });

expect(proposeRes.status).toBe(201);
const actionId = proposeRes.body.data.id;

const confirmRes = await request(app)
.post(`/api/admin/actions/${actionId}/confirm`)
.set('x-api-key', ADMIN_KEY)
.set('Authorization', ADMIN_1_AUTH)
.send({ confirmed_by: ADMIN_1 });

expect(confirmRes.status).toBe(409);
Expand All @@ -184,17 +187,17 @@ describe('Governance proposal lifecycle — integration', () => {
it('lists all proposals pending a second admin vote', async () => {
await request(app)
.post('/api/admin/actions')
.set('x-api-key', ADMIN_KEY)
.set('Authorization', ADMIN_1_AUTH)
.send({ operation: 'update_fee', initiated_by: ADMIN_1, params: { fee_bps: 200 } });

await request(app)
.post('/api/admin/actions')
.set('x-api-key', ADMIN_KEY)
.set('Authorization', ADMIN_1_AUTH)
.send({ operation: 'withdraw_fees', initiated_by: ADMIN_2, params: {} });

const listRes = await request(app)
.get('/api/admin/actions')
.set('x-api-key', ADMIN_KEY);
.set('Authorization', ADMIN_1_AUTH);

expect(listRes.status).toBe(200);
expect(listRes.body.success).toBe(true);
Expand All @@ -204,7 +207,7 @@ describe('Governance proposal lifecycle — integration', () => {
it('rejects confirm without admin authentication', async () => {
const proposeRes = await request(app)
.post('/api/admin/actions')
.set('x-api-key', ADMIN_KEY)
.set('Authorization', ADMIN_1_AUTH)
.send({ operation: 'update_fee', initiated_by: ADMIN_1, params: {} });

const actionId = proposeRes.body.data.id;
Expand All @@ -223,19 +226,19 @@ describe('Governance proposal lifecycle — integration', () => {
it('confirmed proposal is removed from the pending list', async () => {
const proposeRes = await request(app)
.post('/api/admin/actions')
.set('x-api-key', ADMIN_KEY)
.set('Authorization', ADMIN_1_AUTH)
.send({ operation: 'update_fee', initiated_by: ADMIN_1, params: { fee_bps: 100 } });

const actionId = proposeRes.body.data.id;

await request(app)
.post(`/api/admin/actions/${actionId}/confirm`)
.set('x-api-key', ADMIN_KEY)
.set('Authorization', ADMIN_2_AUTH)
.send({ confirmed_by: ADMIN_2 });

const listRes = await request(app)
.get('/api/admin/actions')
.set('x-api-key', ADMIN_KEY);
.set('Authorization', ADMIN_1_AUTH);

const stillPending = listRes.body.data.filter((a: { id: string }) => a.id === actionId);
expect(stillPending).toHaveLength(0);
Expand All @@ -257,7 +260,7 @@ describe('Governance proposal lifecycle — integration', () => {

const confirmRes = await request(app)
.post(`/api/admin/actions/${expiredId}/confirm`)
.set('x-api-key', ADMIN_KEY)
.set('Authorization', ADMIN_2_AUTH)
.send({ confirmed_by: ADMIN_2 });

expect(confirmRes.status).toBe(409);
Expand All @@ -278,7 +281,7 @@ describe('Governance proposal lifecycle — integration', () => {

const listRes = await request(app)
.get('/api/admin/actions')
.set('x-api-key', ADMIN_KEY);
.set('Authorization', ADMIN_1_AUTH);

const expired = listRes.body.data.filter((a: { id: string }) => a.id === expiredId);
expect(expired).toHaveLength(0);
Expand All @@ -287,7 +290,7 @@ describe('Governance proposal lifecycle — integration', () => {
it('returns 404 for an unknown proposal ID', async () => {
const confirmRes = await request(app)
.post('/api/admin/actions/nonexistent-id/confirm')
.set('x-api-key', ADMIN_KEY)
.set('Authorization', ADMIN_2_AUTH)
.send({ confirmed_by: ADMIN_2 });

expect(confirmRes.status).toBe(404);
Expand Down
12 changes: 9 additions & 3 deletions api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import settlementsRouter from './routes/settlements';
import { createRemittancesRouter, RemittancesRouterOptions } from './routes/remittances';
import { createAdminRouter } from './routes/admin';
import { createAnalyticsRouter } from './routes/analytics';
import { createAgentsRouter } from './routes/agents';
import { createAgentsRouter, AgentStore, PostgresAgentStore } from './routes/agents';
import { createAgentAnalyticsRouter } from './routes/agent-analytics';
import { createAuthRouter } from './routes/auth';
import { createAccountsRouter } from './routes/accounts';
Expand All @@ -39,6 +39,7 @@ type AppOptions = {
io?: SocketIOServer;
/** Instrumented database pool — when provided, mounts readiness checks */
pool?: Pool;
agentStore?: AgentStore;
} & RemittancesRouterOptions;

async function probeUrl(urlString: string, timeoutMs = 2000): Promise<{ status: number; ok: boolean; message?: string }> {
Expand Down Expand Up @@ -225,9 +226,14 @@ export function createApp(options: AppOptions = {}): Application {
})()
: Promise.resolve();

const agentsStore = options.agentStore ?? (pool ? new PostgresAgentStore(pool) : undefined);
const agentBootstrapPromise = agentsStore instanceof PostgresAgentStore
? agentsStore.initializeSchema()
: Promise.resolve();

// Expose the bootstrap promise so index.ts can await it before accepting
// traffic. Tests (no DATABASE_URL, injected store) get a no-op Promise.
(app as any).__anchorBootstrap = anchorBootstrapPromise;
(app as any).__anchorBootstrap = Promise.all([anchorBootstrapPromise, agentBootstrapPromise]);

// SR-056: Build a dedicated sub-router that holds all /api/* routes, then
// mount it with createVersionedRouter so:
Expand Down Expand Up @@ -283,7 +289,7 @@ export function createApp(options: AppOptions = {}): Application {
apiRouter.use('/auth', createAuthRouter());

// Agents — registration and management (Issue #880)
apiRouter.use('/agents', createAgentsRouter());
apiRouter.use('/agents', createAgentsRouter({ store: agentsStore }));

// Agent analytics — earnings and performance metrics (Issue #947)
if (analyticsPool) {
Expand Down
Loading