Skip to content

Commit 4af8282

Browse files
waydelyleclaude
andcommitted
fix: address all medium and low code review findings
Medium fixes: - M01: JWT secret uses lazy getter (no hardcoded fallback at import time) - M02: Task list query uses ORDER BY createdAt DESC - M03: Database indexes on tasks(status, requesterId, assigneeId), escrow_transactions(taskId, status), challenges(publicKey, used) - M04: Webhook delivery has 5s fetch timeout via AbortController - M05: verifyAuditChain defaults to limit 1000 - M06: Task PATCH explicitly picks allowed fields - M07: Agent update response strips webhookSecret - M08: MCP endpoint validates JSON-RPC body structure - M09: agents/match endpoint uses rateLimitStrict - M10: TaskListQuerySchema validates status against TASK_STATUS enum - M11: Worker cleans up expired/used challenges every 10 min - M12: BigInt.toJSON monkey-patch documented with comment Low fixes: - L01: Removed duplicate agent card route from agents.ts - L02: Rate limit headers set after next() in Redis path - L03: Matching weights extracted to named constants - L04: Dockerfile comment explains why TS source is in prod image - L05: Global 50MB request body size limit middleware - L06: Worker cleans up read messages older than 7 days hourly Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7a5993c commit 4af8282

13 files changed

Lines changed: 116 additions & 44 deletions

File tree

packages/api/Dockerfile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,11 @@ COPY --from=build /app/packages/shared/dist/ ./packages/shared/dist/
4747
COPY --from=build /app/packages/api/package.json ./packages/api/package.json
4848
COPY --from=build /app/packages/api/dist/ ./packages/api/dist/
4949

50-
# Copy drizzle config and startup script for db:push
50+
# Copy drizzle config and startup script for db:push.
51+
# NOTE: schema.ts and drizzle.config.ts are TypeScript source files intentionally
52+
# included in the production image. start.sh runs `drizzle-kit push` on container
53+
# startup to apply any pending schema changes, and drizzle-kit requires the raw
54+
# schema definition and its config to resolve the database schema.
5155
COPY --from=build /app/packages/api/drizzle.config.ts ./packages/api/drizzle.config.ts
5256
COPY --from=build /app/packages/api/src/db/schema.ts ./packages/api/src/db/schema.ts
5357
COPY packages/api/start.sh ./packages/api/start.sh

packages/api/src/db/schema.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,11 @@ export const tasks = pgTable('tasks', {
125125
revealIdentity: boolean('reveal_identity').default(true).notNull(),
126126
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
127127
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
128-
});
128+
}, (table) => [
129+
index('idx_tasks_status').on(table.status),
130+
index('idx_tasks_requester_id').on(table.requesterId),
131+
index('idx_tasks_assignee_id').on(table.assigneeId),
132+
]);
129133

130134
// ============================================
131135
// TASK INVITATIONS
@@ -182,7 +186,9 @@ export const escrowTransactions = pgTable('escrow_transactions', {
182186
network: text('network').default('base-sepolia').notNull(),
183187
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
184188
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
185-
});
189+
}, (table) => [
190+
index('idx_escrow_task_status').on(table.taskId, table.status),
191+
]);
186192

187193
// ============================================
188194
// RATINGS (float 0-1 scale, weighted)
@@ -393,4 +399,6 @@ export const challenges = pgTable('challenges', {
393399
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
394400
used: boolean('used').default(false).notNull(),
395401
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
396-
});
402+
}, (table) => [
403+
index('idx_challenges_pubkey_used').on(table.publicKey, table.used),
404+
]);

packages/api/src/index.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,12 @@ import { rateLimitDefault } from './middleware/rateLimit.js';
2424
import { otelMiddleware } from './middleware/otel.js';
2525
import { validateChainConfig } from './services/escrow.js';
2626

27-
// Fix BigInt JSON serialization (Drizzle returns bigint columns as JS BigInt)
27+
// INTENTIONAL: BigInt.prototype.toJSON monkey-patch.
28+
// Drizzle ORM returns PostgreSQL bigint columns as native JS BigInt values,
29+
// but JSON.stringify() throws "TypeError: Do not know how to serialize a BigInt"
30+
// by default. This global patch converts BigInt to string during serialization
31+
// so Hono's c.json() works transparently with USDC amounts (stored as bigint).
32+
// This must run before any route handler is invoked.
2833
(BigInt.prototype as unknown as { toJSON: () => string }).toJSON = function () {
2934
return this.toString();
3035
};
@@ -45,6 +50,16 @@ app.use('*', cors({
4550
app.use('*', logger());
4651
app.use('*', otelMiddleware);
4752

53+
// Reject request bodies larger than 50 MB
54+
const MAX_BODY_BYTES = 50 * 1024 * 1024;
55+
app.use('*', async (c, next) => {
56+
const contentLength = c.req.header('content-length');
57+
if (contentLength && parseInt(contentLength, 10) > MAX_BODY_BYTES) {
58+
return c.json({ error: 'Request body too large' }, 413);
59+
}
60+
await next();
61+
});
62+
4863
// Rate limiting
4964
app.use('/api/*', rateLimitDefault);
5065

packages/api/src/middleware/rateLimit.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,9 @@ export function rateLimit(options: RateLimitOptions) {
102102
}
103103

104104
const remaining = Math.max(0, Math.floor(maxRequests - weightedCount));
105+
await next();
105106
c.res.headers.set('X-RateLimit-Limit', String(maxRequests));
106107
c.res.headers.set('X-RateLimit-Remaining', String(remaining));
107-
await next();
108108
return;
109109
}
110110

packages/api/src/routes/agents.ts

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,10 @@ import {
1818
TASK_STATUS,
1919
} from '@swarmdock/shared';
2020
import { authMiddleware, requireScope, type AuthContext } from '../middleware/auth.js';
21-
import { rateLimitAuth } from '../middleware/rateLimit.js';
21+
import { rateLimitAuth, rateLimitStrict } from '../middleware/rateLimit.js';
2222
import { eventBus } from '../lib/events.js';
2323
import { getRatingsSummary } from '../services/ratings.js';
2424
import { provisionAgentWallet } from '../services/wallet.js';
25-
import { getAgentCardById } from '../services/agent-card.js';
2625
import { updateTrustLevel } from '../services/reputation.js';
2726
import { getAgentPortfolio, createPortfolioItem, updatePortfolioItem, deletePortfolioItem } from '../services/portfolio.js';
2827
import { fetchOrderedRowsByIds, searchAgentsIndex } from '../services/search.js';
@@ -519,7 +518,8 @@ app.patch('/:id', authMiddleware, requireScope('profile.write'), async (c) => {
519518
data: { agentId: id },
520519
});
521520

522-
return c.json(updated);
521+
const { webhookSecret: _ws, publicKey: _pk, ...safeUpdated } = updated;
522+
return c.json(safeUpdated);
523523
});
524524

525525
// POST /api/v1/agents/:id/heartbeat — Refresh AAT
@@ -636,18 +636,10 @@ app.delete('/:id/portfolio/:itemId', authMiddleware, requireScope('portfolio.wri
636636
}
637637
});
638638

639-
// GET /agents/:id/.well-known/agent.json — A2A Agent Card
640-
app.get('/:id/.well-known/agent.json', async (c) => {
641-
const agentCard = await getAgentCardById(c.req.param('id'));
642-
if (!agentCard) {
643-
return c.json({ error: 'Agent not found' }, 404);
644-
}
645-
646-
return c.json(agentCard);
647-
});
639+
// Agent card served from index.ts at /agents/:id/.well-known/agent.json
648640

649641
// POST /api/v1/agents/match — Find best-matching agents for a task
650-
app.post('/match', async (c) => {
642+
app.post('/match', rateLimitStrict, async (c) => {
651643
const body = await c.req.json();
652644
const { description, skills, limit = 10 } = body as { description: string; skills?: string[]; limit?: number };
653645

packages/api/src/routes/mcp.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ app.post('/', async (c) => {
5050
}
5151

5252
const body = await c.req.json() as MCPRequest;
53-
if (body.jsonrpc !== '2.0' || !body.method) {
54-
return c.json(mcpError(body.id ?? null, -32600, 'Invalid JSON-RPC request'));
53+
if (body.jsonrpc !== '2.0' || typeof body.method !== 'string' || body.id == null) {
54+
return c.json(mcpError(body?.id ?? null, -32600, 'Invalid JSON-RPC request: must include jsonrpc "2.0", a string method, and an id'));
5555
}
5656

5757
const capabilities = (agent.mcpCapabilities ?? {}) as {

packages/api/src/routes/tasks.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ app.get('/', async (c) => {
118118

119119
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
120120
const [{ total }] = await db.select({ total: count() }).from(tasks).where(whereClause);
121-
const result = await db.select().from(tasks).where(whereClause).limit(limit).offset(offset);
121+
const result = await db.select().from(tasks).where(whereClause).orderBy(desc(tasks.createdAt)).limit(limit).offset(offset);
122122

123123
const taskIds = result.map((task) => task.id);
124124
const bidCountRows = taskIds.length > 0
@@ -489,10 +489,11 @@ app.patch('/:id', authMiddleware, requireScope('tasks.write'), async (c) => {
489489
throw new HTTPException(400, { message: 'Cannot update task in current status' });
490490
}
491491

492-
const updateData: Record<string, unknown> = { ...parsed.data, updatedAt: new Date() };
493-
if (parsed.data.deadline) {
494-
updateData.deadline = new Date(parsed.data.deadline);
495-
}
492+
const { title, description, deadline } = parsed.data;
493+
const updateData: Record<string, unknown> = { updatedAt: new Date() };
494+
if (title !== undefined) updateData.title = title;
495+
if (description !== undefined) updateData.description = description;
496+
if (deadline !== undefined) updateData.deadline = new Date(deadline);
496497

497498
const [result] = await tx
498499
.update(tasks)

packages/api/src/services/audit.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,8 @@ export async function verifyAuditChain(
9090
.from(auditLog)
9191
.orderBy(auditLog.id);
9292

93-
const entries = limit
94-
? await query.limit(limit)
95-
: await query;
93+
const effectiveLimit = limit ?? 1000;
94+
const entries = await query.limit(effectiveLimit);
9695

9796
if (entries.length === 0) {
9897
return { valid: true, entriesChecked: 0 };

packages/api/src/services/identity.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,16 @@ import * as jose from 'jose';
22
import { SCOPES, AAT_EXPIRY_HOURS } from '@swarmdock/shared';
33
import type { Scope, AATPayload } from '@swarmdock/shared';
44

5-
const JWT_SECRET = new TextEncoder().encode(
6-
process.env.JWT_SECRET ?? 'swarmdock-dev-secret-change-in-production'
7-
);
5+
let _jwtSecret: Uint8Array | undefined;
6+
7+
function getJwtSecret(): Uint8Array {
8+
if (!_jwtSecret) {
9+
_jwtSecret = new TextEncoder().encode(
10+
process.env.JWT_SECRET ?? 'swarmdock-dev-secret-change-in-production'
11+
);
12+
}
13+
return _jwtSecret;
14+
}
815

916
const DEFAULT_SCOPES: Scope[] = [
1017
'tasks.read',
@@ -33,13 +40,13 @@ export async function issueAAT(agent: {
3340
.setIssuedAt()
3441
.setExpirationTime(`${AAT_EXPIRY_HOURS}h`)
3542
.setIssuer('swarmdock.ai')
36-
.sign(JWT_SECRET);
43+
.sign(getJwtSecret());
3744

3845
return jwt;
3946
}
4047

4148
export async function verifyAAT(token: string): Promise<AATPayload> {
42-
const { payload } = await jose.jwtVerify(token, JWT_SECRET, {
49+
const { payload } = await jose.jwtVerify(token, getJwtSecret(), {
4350
issuer: 'swarmdock.ai',
4451
});
4552

packages/api/src/services/matching.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@ import { db } from '../db/client.js';
22
import { tasks, agents, agentRatings, agentReputation } from '../db/schema.js';
33
import { eq, and, sql } from 'drizzle-orm';
44

5+
/** Matching weight constants */
6+
const WEIGHT_TRUST = 0.20;
7+
const WEIGHT_QUALITY = 0.35;
8+
const WEIGHT_HISTORY = 0.25;
9+
const WEIGHT_COLLABORATIVE = 0.20;
10+
const PREMIUM_BOOST = 1.5;
11+
512
interface MatchScore {
613
agentId: string;
714
score: number;
@@ -102,14 +109,14 @@ export async function scoreMatchCandidates(
102109

103110
// Weighted blend
104111
let score =
105-
trustLevel * 0.20 +
106-
qualityScore * 0.35 +
107-
historicalSuccess * 0.25 +
108-
collaborativeScore * 0.20;
112+
trustLevel * WEIGHT_TRUST +
113+
qualityScore * WEIGHT_QUALITY +
114+
historicalSuccess * WEIGHT_HISTORY +
115+
collaborativeScore * WEIGHT_COLLABORATIVE;
109116

110-
// Premium agents get a 1.5x boost
117+
// Premium agents get a boost
111118
if (premiumMap.get(agentId)) {
112-
score *= 1.5;
119+
score *= PREMIUM_BOOST;
113120
}
114121

115122
return {

0 commit comments

Comments
 (0)