Skip to content

Commit 5830681

Browse files
committed
Harden production readiness
1 parent 6555f57 commit 5830681

27 files changed

Lines changed: 2236 additions & 2503 deletions

.github/workflows/ci.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ jobs:
4343

4444
- name: Audit dependencies
4545
run: pnpm audit --audit-level moderate
46-
continue-on-error: true
4746

4847
- name: Type-check all packages
4948
run: pnpm type-check
@@ -77,6 +76,9 @@ jobs:
7776
run_pkg_tests @swarmdock/api 1
7877
run_pkg_tests @swarmdock/sdk 1
7978
run_pkg_tests @swarmdock/cli 1
79+
run_pkg_tests @swarmdock/web 1
80+
run_pkg_tests @swarmdock/installer 1
81+
run_pkg_tests create-swarmdock-agent 1
8082
8183
- name: Run escrow integration tests
8284
env:

package.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"prepare": "husky"
1717
},
1818
"devDependencies": {
19-
"@eslint/js": "^10.0.1",
19+
"@eslint/js": "^9.39.4",
2020
"eslint": "^9.39.4",
2121
"husky": "^9.1.7",
2222
"lint-staged": "^16.4.0",
@@ -33,7 +33,13 @@
3333
},
3434
"pnpm": {
3535
"overrides": {
36-
"@swarmdock/shared@*": "workspace:*"
36+
"@swarmdock/shared@*": "workspace:*",
37+
"@anthropic-ai/sdk": "^0.81.0",
38+
"basic-ftp": "^5.3.0",
39+
"esbuild": "^0.25.12",
40+
"fast-xml-parser": "^5.7.0",
41+
"protobufjs": "^7.5.5",
42+
"uuid": "^14.0.0"
3743
}
3844
}
3945
}

packages/api/package.json

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@
1919
"db:studio": "drizzle-kit studio"
2020
},
2121
"dependencies": {
22-
"@aws-sdk/client-s3": "^3.922.0",
23-
"@hono/node-server": "^1.14.0",
22+
"@aws-sdk/client-s3": "^3.1035.0",
23+
"@hono/node-server": "^1.19.14",
2424
"@hono/swagger-ui": "^0.6.1",
25-
"@huggingface/transformers": "^3.0.0",
25+
"@huggingface/transformers": "^4.2.0",
2626
"@modelcontextprotocol/sdk": "^1.27.1",
2727
"@opentelemetry/api": "^1.9.1",
2828
"@opentelemetry/auto-instrumentations-node": "^0.72.0",
@@ -37,13 +37,14 @@
3737
"@x402/evm": "^2.0.4",
3838
"@x402/hono": "^2.0.4",
3939
"ajv": "^8.18.0",
40-
"drizzle-orm": "^0.39.0",
41-
"hono": "^4.7.0",
42-
"isomorphic-dompurify": "^3.7.1",
40+
"drizzle-orm": "^0.45.2",
41+
"hono": "^4.12.14",
42+
"isomorphic-dompurify": "^3.10.0",
4343
"jose": "^6.0.0",
4444
"meilisearch": "^0.52.0",
4545
"nats": "^2.29.3",
4646
"pg": "^8.13.0",
47+
"redis": "^5.12.1",
4748
"swarmdock-mcp": "^0.2.0",
4849
"tweetnacl": "^1.0.3",
4950
"tweetnacl-util": "^0.15.1",
@@ -56,6 +57,6 @@
5657
"typescript": "^5.8.0"
5758
},
5859
"optionalDependencies": {
59-
"drizzle-kit": "^0.30.0"
60+
"drizzle-kit": "^0.31.10"
6061
}
6162
}

packages/api/src/lib/pagination.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
export type PaginationOptions = {
2+
defaultLimit?: number;
3+
maxLimit?: number;
4+
};
5+
6+
export type Pagination = {
7+
limit: number;
8+
offset: number;
9+
};
10+
11+
export function parseBoundedInteger(
12+
raw: string | null | undefined,
13+
fallback: number,
14+
min: number,
15+
max: number,
16+
): number {
17+
const parsed = raw?.trim() ? Number.parseInt(raw, 10) : fallback;
18+
if (!Number.isFinite(parsed)) {
19+
return fallback;
20+
}
21+
22+
return Math.min(Math.max(parsed, min), max);
23+
}
24+
25+
export function parsePagination(
26+
limitRaw: string | null | undefined,
27+
offsetRaw: string | null | undefined,
28+
options: PaginationOptions = {},
29+
): Pagination {
30+
const defaultLimit = options.defaultLimit ?? 20;
31+
const maxLimit = options.maxLimit ?? 100;
32+
33+
return {
34+
limit: parseBoundedInteger(limitRaw, defaultLimit, 1, maxLimit),
35+
offset: parseBoundedInteger(offsetRaw, 0, 0, Number.MAX_SAFE_INTEGER),
36+
};
37+
}

packages/api/src/lib/redis.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,98 @@ type RedisClient = {
77
del(key: string | string[]): Promise<number>;
88
incr(key: string): Promise<number>;
99
expire(key: string, seconds: number): Promise<unknown>;
10+
ttl?(key: string): Promise<number>;
1011
connect(): Promise<void>;
1112
on(event: string, handler: (...args: unknown[]) => void): void;
1213
};
1314

1415
let client: RedisClient | null = null;
1516
let connectionAttempted = false;
1617

18+
type UpstashResponse<T> = {
19+
result?: T;
20+
error?: string;
21+
};
22+
23+
function createUpstashRestClient(url: string, token: string): RedisClient {
24+
const endpoint = url.replace(/\/+$/, '');
25+
26+
async function command<T>(parts: Array<string | number>): Promise<T> {
27+
const response = await fetch(endpoint, {
28+
method: 'POST',
29+
headers: {
30+
Authorization: `Bearer ${token}`,
31+
'Content-Type': 'application/json',
32+
},
33+
body: JSON.stringify(parts),
34+
});
35+
36+
if (!response.ok) {
37+
throw new Error(`Upstash Redis REST request failed: ${response.status}`);
38+
}
39+
40+
const body = await response.json() as UpstashResponse<T>;
41+
if (body.error) {
42+
throw new Error(`Upstash Redis REST command failed: ${body.error}`);
43+
}
44+
45+
return body.result as T;
46+
}
47+
48+
return {
49+
async get(key) {
50+
const value = await command<string | null>(['GET', key]);
51+
return value === null || value === undefined ? null : String(value);
52+
},
53+
async set(key, value, options) {
54+
const parts: Array<string | number> = ['SET', key, value];
55+
if (options?.EX) {
56+
parts.push('EX', options.EX);
57+
}
58+
if (options?.NX) {
59+
parts.push('NX');
60+
}
61+
return command<string | null>(parts);
62+
},
63+
async del(key) {
64+
const keys = Array.isArray(key) ? key : [key];
65+
return Number(await command<number>(['DEL', ...keys]));
66+
},
67+
async incr(key) {
68+
return Number(await command<number>(['INCR', key]));
69+
},
70+
async expire(key, seconds) {
71+
return command<number>(['EXPIRE', key, seconds]);
72+
},
73+
async ttl(key) {
74+
return Number(await command<number>(['TTL', key]));
75+
},
76+
async connect() {
77+
return undefined;
78+
},
79+
on() {
80+
return undefined;
81+
},
82+
};
83+
}
84+
1785
export async function getRedisClient(): Promise<RedisClient | null> {
1886
if (client) return client;
1987
if (connectionAttempted) return null;
2088

2189
connectionAttempted = true;
2290

91+
const upstashUrl = process.env.UPSTASH_REDIS_REST_URL?.trim();
92+
const upstashToken = process.env.UPSTASH_REDIS_REST_TOKEN?.trim();
93+
if (upstashUrl && upstashToken) {
94+
client = createUpstashRestClient(upstashUrl, upstashToken);
95+
return client;
96+
}
97+
98+
if (upstashUrl || upstashToken) {
99+
console.error('[REDIS] UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN must both be configured');
100+
}
101+
23102
const url = process.env.REDIS_URL;
24103
if (!url) return null;
25104

@@ -95,7 +174,8 @@ export async function redisTtl(key: string): Promise<number> {
95174
const c = await getRedisClient();
96175
if (!c) return -1;
97176
try {
98-
return await (c as unknown as { ttl(key: string): Promise<number> }).ttl(key);
177+
if (!c.ttl) return -1;
178+
return await c.ttl(key);
99179
} catch (err) {
100180
console.error('[REDIS] TTL error:', err);
101181
return -1;
@@ -135,3 +215,8 @@ export async function redisReleaseLock(key: string, token: string): Promise<void
135215
console.error('[REDIS] lock release error:', err);
136216
}
137217
}
218+
219+
export function resetRedisClientForTests(): void {
220+
client = null;
221+
connectionAttempted = false;
222+
}

packages/api/src/routes/admin.ts

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Hono, type Context } from 'hono';
22
import { db } from '../db/client.js';
33
import { agents, tasks, escrowTransactions, transactions, agentRatings, disputes, anomalyEvents, agentReputation } from '../db/schema.js';
4-
import { eq, sql, count, desc, and } from 'drizzle-orm';
4+
import { eq, sql, count, desc, and, inArray } from 'drizzle-orm';
55
import { timingSafeEqual } from 'node:crypto';
66
import { createMiddleware } from 'hono/factory';
77
import { HTTPException } from 'hono/http-exception';
@@ -35,6 +35,24 @@ const adminAuth = createMiddleware(async (c, next) => {
3535

3636
const ADMIN_LIST_MAX_LIMIT = 200;
3737
const ADMIN_LIST_DEFAULT_LIMIT = 50;
38+
export const TRIBUNAL_SELECTABLE_STATUSES = [
39+
DISPUTE_STATUS.OPEN,
40+
DISPUTE_STATUS.ESCALATED,
41+
] as const;
42+
export const ADMIN_RESOLVABLE_DISPUTE_STATUSES = [
43+
DISPUTE_STATUS.OPEN,
44+
DISPUTE_STATUS.ESCALATED,
45+
DISPUTE_STATUS.TRIBUNAL,
46+
DISPUTE_STATUS.ADMIN_REQUIRED,
47+
] as const;
48+
49+
export function isDisputeStatusSelectableForTribunal(status: string): boolean {
50+
return (TRIBUNAL_SELECTABLE_STATUSES as readonly string[]).includes(status);
51+
}
52+
53+
export function isDisputeStatusResolvableByAdmin(status: string): boolean {
54+
return (ADMIN_RESOLVABLE_DISPUTE_STATUSES as readonly string[]).includes(status);
55+
}
3856

3957
function parseListLimit(raw: string | undefined): number {
4058
const parsed = parseInt(raw ?? String(ADMIN_LIST_DEFAULT_LIMIT), 10);
@@ -199,31 +217,42 @@ app.get('/disputes', adminAuth, async (c) => {
199217
});
200218
});
201219

202-
// GET /api/v1/admin/disputes/:id/tribunal — Trigger tribunal selection
203-
app.get('/disputes/:id/tribunal', adminAuth, async (c) => {
220+
// GET /api/v1/admin/disputes/:id/tribunal — Deprecated: use POST for state changes
221+
app.get('/disputes/:id/tribunal', adminAuth, (c) =>
222+
c.json({ error: 'Use POST /api/v1/admin/disputes/:id/tribunal to select tribunal judges' }, 405),
223+
);
224+
225+
// POST /api/v1/admin/disputes/:id/tribunal — Trigger tribunal selection
226+
app.post('/disputes/:id/tribunal', adminAuth, async (c) => {
204227
const id = c.req.param('id');
205228

206229
const [dispute] = await db
207230
.select()
208231
.from(disputes)
209-
.where(and(eq(disputes.id, id), eq(disputes.status, DISPUTE_STATUS.OPEN)))
232+
.where(eq(disputes.id, id))
210233
.limit(1);
211234

212235
if (!dispute) {
213-
return c.json({ error: 'Open dispute not found' }, 404);
236+
return c.json({ error: 'Dispute not found' }, 404);
237+
}
238+
if (!isDisputeStatusSelectableForTribunal(dispute.status)) {
239+
return c.json({ error: `Dispute is not selectable for tribunal in status: ${dispute.status}` }, 409);
214240
}
215241

216242
const tribunalAgents = await selectTribunalJudges(dispute.id);
217-
218243
const [updatedDispute] = await db
219-
.update(disputes)
220-
.set({
221-
status: DISPUTE_STATUS.TRIBUNAL,
222-
tribunalAgents,
223-
updatedAt: new Date(),
224-
})
244+
.select()
245+
.from(disputes)
225246
.where(eq(disputes.id, id))
226-
.returning();
247+
.limit(1);
248+
249+
if (tribunalAgents.length === 0) {
250+
return c.json({
251+
dispute: updatedDispute,
252+
tribunalAgents,
253+
error: 'Not enough eligible judges; admin resolution required',
254+
}, 409);
255+
}
227256

228257
// Notify tribunal agents
229258
for (const agentId of tribunalAgents) {
@@ -248,11 +277,11 @@ app.post('/disputes/:id/resolve', adminAuth, async (c) => {
248277
const [dispute] = await db
249278
.select()
250279
.from(disputes)
251-
.where(and(eq(disputes.id, id), eq(disputes.status, DISPUTE_STATUS.OPEN)))
280+
.where(and(eq(disputes.id, id), inArray(disputes.status, [...ADMIN_RESOLVABLE_DISPUTE_STATUSES])))
252281
.limit(1);
253282

254283
if (!dispute) {
255-
return c.json({ error: 'Open dispute not found' }, 404);
284+
return c.json({ error: 'Resolvable dispute not found' }, 404);
256285
}
257286

258287
const [task] = await db.select().from(tasks).where(eq(tasks.id, dispute.taskId)).limit(1);

packages/api/src/routes/bids.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import { Hono } from 'hono';
22
import { db, type Database } from '../db/client.js';
33
import { tasks, taskBids, escrowTransactions } from '../db/schema.js';
4-
import { eq, and, ne, sql } from 'drizzle-orm';
4+
import { eq, and, ne, sql, desc, count } from 'drizzle-orm';
55
import { authMiddleware, optionalAuthMiddleware, requireScope, type AuthContext } from '../middleware/auth.js';
66
import { BidCreateSchema, TASK_STATUS, BID_STATUS, ESCROW_STATUS } from '@swarmdock/shared';
77
import { eventBus } from '../lib/events.js';
88
import { getX402Network, microUsdcToUsdPrice, requireX402Payment } from '../services/x402.js';
99
import { createSimulatedTxHash } from '../services/escrow.js';
1010
import { canReadTask } from './task-access.js';
1111
import { sanitizeFreeText } from '../lib/sanitize.js';
12+
import { parsePagination } from '../lib/pagination.js';
1213

1314
type BidContext = AuthContext & { Variables: AuthContext['Variables'] };
1415

@@ -120,8 +121,17 @@ export function createBidsApp(overrides: Partial<BidRouteDeps> = {}) {
120121
return c.json({ error: 'Task not found' }, 404);
121122
}
122123

123-
const bids = await database.select().from(taskBids).where(eq(taskBids.taskId, taskId));
124-
return c.json({ bids });
124+
const { limit, offset } = parsePagination(c.req.query('limit'), c.req.query('offset'));
125+
const [{ total }] = await database.select({ total: count() }).from(taskBids).where(eq(taskBids.taskId, taskId));
126+
const bids = await database
127+
.select()
128+
.from(taskBids)
129+
.where(eq(taskBids.taskId, taskId))
130+
.orderBy(desc(taskBids.createdAt))
131+
.limit(limit)
132+
.offset(offset);
133+
134+
return c.json({ bids, limit, offset, total: Number(total) });
125135
});
126136

127137
// POST /api/v1/tasks/:taskId/bids/:bidId/accept — Accept bid

packages/api/src/routes/docs.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -378,8 +378,9 @@ const spec = {
378378
post: { tags: ['A2A'], summary: 'Send message via relay', security: [{ bearerAuth: [] }], responses: { 201: { description: 'Message sent' } } },
379379
},
380380
// ── MCP ──
381-
'/agents/{id}/mcp': {
382-
post: { tags: ['MCP'], summary: 'Proxy MCP tool calls to agent', parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], responses: { 200: { description: 'MCP response' } } },
381+
'/mcp': {
382+
get: { tags: ['MCP'], summary: 'MCP health probe', responses: { 200: { description: 'MCP endpoint metadata' } } },
383+
post: { tags: ['MCP'], summary: 'Hosted MCP streamable HTTP endpoint', security: [{ bearerAuth: [] }], responses: { 200: { description: 'MCP response' }, 401: { description: 'Missing or invalid bearer credential' } } },
383384
},
384385
// ── Admin ──
385386
'/api/v1/admin/stats': {
@@ -394,6 +395,9 @@ const spec = {
394395
'/api/v1/admin/disputes': {
395396
get: { tags: ['Admin'], summary: 'List disputes', security: [{ adminKey: [] }], parameters: [{ name: 'status', in: 'query', schema: { type: 'string' } }], responses: { 200: { description: 'Dispute list' } } },
396397
},
398+
'/api/v1/admin/disputes/{id}/tribunal': {
399+
post: { tags: ['Admin'], summary: 'Select tribunal judges for a dispute', security: [{ adminKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], responses: { 200: { description: 'Tribunal selected' }, 409: { description: 'Dispute needs admin resolution or cannot enter tribunal' } } },
400+
},
397401
'/api/v1/admin/disputes/{id}/resolve': {
398402
post: { tags: ['Admin'], summary: 'Resolve a dispute', security: [{ adminKey: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], responses: { 200: { description: 'Dispute resolved' } } },
399403
},

0 commit comments

Comments
 (0)