From 44afa0a2c72f8dce6e84477a09a15d5f72df37a4 Mon Sep 17 00:00:00 2001 From: Josh <160112355+Josh-wt@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:11:45 +0530 Subject: [PATCH] fix: harden feature-service queries and presence participant targeting --- .../src/audit/Services/AuditLogService.ts | 48 +++++++++++++++---- .../src/cost/Services/CostTrackingService.ts | 19 +++++--- .../memory/Services/ProjectMemoryService.ts | 14 ++++-- .../src/presence/Services/PresenceService.ts | 45 ++++++++--------- packages/contracts/src/presence.ts | 2 + 5 files changed, 87 insertions(+), 41 deletions(-) diff --git a/apps/server/src/audit/Services/AuditLogService.ts b/apps/server/src/audit/Services/AuditLogService.ts index bc1e617ffa..f949e6c888 100644 --- a/apps/server/src/audit/Services/AuditLogService.ts +++ b/apps/server/src/audit/Services/AuditLogService.ts @@ -91,23 +91,53 @@ const makeAuditLogService = Effect.gen(function* () { const query: AuditLogServiceShape["query"] = (input) => Effect.gen(function* () { const conditions: Array = []; - if (input.projectId) conditions.push(`project_id = '${input.projectId}'`); - if (input.threadId) conditions.push(`thread_id = '${input.threadId}'`); - if (input.category) conditions.push(`category = '${input.category}'`); - if (input.severity) conditions.push(`severity = '${input.severity}'`); - if (input.actor) conditions.push(`actor = '${input.actor}'`); - if (input.fromTimestamp) conditions.push(`timestamp >= '${input.fromTimestamp}'`); - if (input.toTimestamp) conditions.push(`timestamp <= '${input.toTimestamp}'`); + const params: Array = []; + + if (input.projectId) { + conditions.push("project_id = ?"); + params.push(input.projectId); + } + if (input.threadId) { + conditions.push("thread_id = ?"); + params.push(input.threadId); + } + if (input.category) { + conditions.push("category = ?"); + params.push(input.category); + } + if (input.severity) { + conditions.push("severity = ?"); + params.push(input.severity); + } + if (input.actor) { + conditions.push("actor = ?"); + params.push(input.actor); + } + if (input.fromTimestamp) { + conditions.push("timestamp >= ?"); + params.push(input.fromTimestamp); + } + if (input.toTimestamp) { + conditions.push("timestamp <= ?"); + params.push(input.toTimestamp); + } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const limit = Math.max(1, input.limit); + const offset = Math.max(0, input.offset); const countResult = yield* sql.unsafe<{ total: number }>( `SELECT COUNT(*) as total FROM audit_log ${whereClause}`, + params, ); const total = Number(countResult[0]?.total ?? 0); const rows = yield* sql.unsafe>( - `SELECT id, timestamp, actor, actor_id, category, action, severity, project_id, thread_id, command_id, event_id, summary, detail, metadata FROM audit_log ${whereClause} ORDER BY timestamp DESC LIMIT ${input.limit} OFFSET ${input.offset}`, + `SELECT id, timestamp, actor, actor_id, category, action, severity, project_id, thread_id, command_id, event_id, summary, detail, metadata + FROM audit_log ${whereClause} + ORDER BY timestamp DESC + LIMIT ? OFFSET ?`, + [...params, limit, offset], ); const entries: AuditEntry[] = rows.map((r) => ({ @@ -131,7 +161,7 @@ const makeAuditLogService = Effect.gen(function* () { return { entries, total: total as AuditQueryResult["total"], - hasMore: input.offset + input.limit < total, + hasMore: offset + limit < total, } satisfies AuditQueryResult; }).pipe(Effect.orDie); diff --git a/apps/server/src/cost/Services/CostTrackingService.ts b/apps/server/src/cost/Services/CostTrackingService.ts index 39106c829a..ee4a6c18b3 100644 --- a/apps/server/src/cost/Services/CostTrackingService.ts +++ b/apps/server/src/cost/Services/CostTrackingService.ts @@ -144,12 +144,16 @@ const makeCostTrackingService = Effect.gen(function* () { input.periodStart ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); const periodEnd = input.periodEnd ?? now; - const conditions: Array = [ - `created_at >= '${periodStart}'`, - `created_at <= '${periodEnd}'`, - ]; - if (input.projectId) conditions.push(`project_id = '${input.projectId}'`); - if (input.threadId) conditions.push(`thread_id = '${input.threadId}'`); + const conditions: Array = ["created_at >= ?", "created_at <= ?"]; + const params: Array = [periodStart, periodEnd]; + if (input.projectId) { + conditions.push("project_id = ?"); + params.push(input.projectId); + } + if (input.threadId) { + conditions.push("thread_id = ?"); + params.push(input.threadId); + } const whereClause = `WHERE ${conditions.join(" AND ")}`; const totals = yield* sql.unsafe<{ @@ -159,6 +163,7 @@ const makeCostTrackingService = Effect.gen(function* () { total_thinking: number; }>( `SELECT COALESCE(SUM(cost_cents), 0) as total_cost, COALESCE(SUM(input_tokens), 0) as total_input, COALESCE(SUM(output_tokens), 0) as total_output, COALESCE(SUM(thinking_tokens), 0) as total_thinking FROM cost_entries ${whereClause}`, + params, ); const byProvider = yield* sql.unsafe<{ provider: string; @@ -167,9 +172,11 @@ const makeCostTrackingService = Effect.gen(function* () { output_tokens: number; }>( `SELECT provider, COALESCE(SUM(cost_cents), 0) as cost_cents, COALESCE(SUM(input_tokens), 0) as input_tokens, COALESCE(SUM(output_tokens), 0) as output_tokens FROM cost_entries ${whereClause} GROUP BY provider`, + params, ); const byThread = yield* sql.unsafe<{ threadId: string; cost_cents: number }>( `SELECT thread_id as threadId, COALESCE(SUM(cost_cents), 0) as cost_cents FROM cost_entries ${whereClause} GROUP BY thread_id ORDER BY cost_cents DESC LIMIT 20`, + params, ); const row = totals[0] ?? { diff --git a/apps/server/src/memory/Services/ProjectMemoryService.ts b/apps/server/src/memory/Services/ProjectMemoryService.ts index 5b4152631e..7463e0deae 100644 --- a/apps/server/src/memory/Services/ProjectMemoryService.ts +++ b/apps/server/src/memory/Services/ProjectMemoryService.ts @@ -56,6 +56,10 @@ const makeProjectMemoryService = Effect.gen(function* () { const search: ProjectMemoryServiceShape["search"] = (input) => Effect.gen(function* () { const start = Date.now(); + const kindClause = input.kind ? "AND m.kind = ?" : ""; + const queryParams: Array = input.kind + ? [input.query, input.projectId, input.kind, input.limit] + : [input.query, input.projectId, input.limit]; // Use FTS5 for full-text search const rows = yield* sql.unsafe<{ id: string; @@ -77,18 +81,20 @@ const makeProjectMemoryService = Effect.gen(function* () { JOIN memory_entries m ON m.rowid = fts.rowid WHERE memory_fts MATCH ? AND m.project_id = ? - ${input.kind ? `AND m.kind = '${input.kind}'` : ""} + ${kindClause} AND (m.expires_at IS NULL OR m.expires_at > datetime('now')) ORDER BY fts.rank LIMIT ?`, - [input.query, input.projectId, input.limit], + queryParams, ); // Increment access count if (rows.length > 0) { - const ids = rows.map((r) => `'${r.id}'`).join(","); + const placeholders = rows.map(() => "?").join(","); + const ids = rows.map((r) => r.id); yield* sql.unsafe( - `UPDATE memory_entries SET access_count = access_count + 1 WHERE id IN (${ids})`, + `UPDATE memory_entries SET access_count = access_count + 1 WHERE id IN (${placeholders})`, + ids, ); } diff --git a/apps/server/src/presence/Services/PresenceService.ts b/apps/server/src/presence/Services/PresenceService.ts index df0f74d98b..aefd4c2ffc 100644 --- a/apps/server/src/presence/Services/PresenceService.ts +++ b/apps/server/src/presence/Services/PresenceService.ts @@ -86,37 +86,38 @@ const makePresenceService = Effect.gen(function* () { const leave: PresenceServiceShape["leave"] = (input) => Effect.gen(function* () { - // We need participant id — we'll clean up all stale entries const threadMap = presenceMap.get(input.threadId); if (!threadMap) return; - for (const [participantId] of threadMap) { - threadMap.delete(participantId); - yield* PubSub.publish(pubsub, { - type: "presence.left" as const, - participantId: participantId as ParticipantId, - threadId: input.threadId, - }); + const existed = threadMap.delete(input.participantId); + if (threadMap.size === 0) { + presenceMap.delete(input.threadId); } + if (!existed) return; + yield* PubSub.publish(pubsub, { + type: "presence.left" as const, + participantId: input.participantId, + threadId: input.threadId, + }); }); const updateCursor: PresenceServiceShape["updateCursor"] = (input) => Effect.gen(function* () { const threadMap = presenceMap.get(input.threadId); if (!threadMap) return; - for (const [id, p] of threadMap) { - const updated = { - ...p, - cursor: input.cursor as PresenceCursorKind, - lastSeenAt: new Date().toISOString(), - }; - threadMap.set(id, updated); - yield* PubSub.publish(pubsub, { - type: "presence.cursor.updated" as const, - participantId: id as ParticipantId, - cursor: input.cursor as PresenceCursorKind, - threadId: input.threadId, - }); - } + const participant = threadMap.get(input.participantId); + if (!participant) return; + const updated = { + ...participant, + cursor: input.cursor as PresenceCursorKind, + lastSeenAt: new Date().toISOString(), + }; + threadMap.set(input.participantId, updated); + yield* PubSub.publish(pubsub, { + type: "presence.cursor.updated" as const, + participantId: input.participantId, + cursor: input.cursor as PresenceCursorKind, + threadId: input.threadId, + }); }); const share: PresenceServiceShape["share"] = (input) => diff --git a/packages/contracts/src/presence.ts b/packages/contracts/src/presence.ts index bd3d5352c6..ed662e1165 100644 --- a/packages/contracts/src/presence.ts +++ b/packages/contracts/src/presence.ts @@ -48,11 +48,13 @@ export type PresenceJoinInput = typeof PresenceJoinInput.Type; export const PresenceLeaveInput = Schema.Struct({ threadId: ThreadId, + participantId: ParticipantId, }); export type PresenceLeaveInput = typeof PresenceLeaveInput.Type; export const PresenceUpdateCursorInput = Schema.Struct({ threadId: ThreadId, + participantId: ParticipantId, cursor: PresenceCursorKind, }); export type PresenceUpdateCursorInput = typeof PresenceUpdateCursorInput.Type;