CognxSafeTrack Claude Sonnet 4.6 commited on
Commit
87afdf1
Β·
1 Parent(s): 5b8761d

fix: security hardening, real AI costs, traceId propagation & audit logging

Browse files

- S1: enforceOrgIsolation β€” organizationId always from JWT for non-SUPER_ADMIN (header ignored)
- S4: auditService.log() on ORG_MODE_CHANGED, ORG_PERSONALITY_UPDATED, ORG_BYOK_KEYS_UPDATED
- O1: traceId (UUID) generated at webhook, forwarded via x-trace-id header, injected into BullMQ job data, threaded through whatsapp-logic handler chain
- D3: /v1/analytics/usage now aggregates real UsageEvent.costUsd + tokens (tokensIn/Out) with per-feature breakdown instead of token estimates
- D4: BrandingDataSchema added to shared-types β€” brandingData strictly typed (logoUrl, primaryColor, orgName) instead of z.any()
- A6: InboundHandler fetches user.language in parallel with org token, passes it to Whisper
- A2: sendLessonDay() caches personalized lesson text in Redis 6h by (trackDayId, activity, language)
- P1: sendLessonDay() parallelized β€” user+trackDay batched in one include, userProgress fetched in parallel
- D2: ExerciseHandler Redis 30s dedup lock prevents double exercise submission

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

apps/api/src/routes/analytics.ts CHANGED
@@ -22,7 +22,9 @@ export async function analyticsRoutes(fastify: FastifyInstance) {
22
  inboundMessages,
23
  outboundMessages,
24
  totalUsers,
25
- activeUsersLast24h
 
 
26
  ] = await Promise.all([
27
  prisma.message.count({ where: { organizationId } }),
28
  prisma.message.count({ where: { organizationId, direction: 'INBOUND' } }),
@@ -33,22 +35,23 @@ export async function analyticsRoutes(fastify: FastifyInstance) {
33
  organizationId,
34
  lastActivityAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) }
35
  }
36
- })
 
 
 
 
 
 
 
 
 
 
 
37
  ]);
38
 
39
- // ~1000 tokens avg per outbound AI message (only outbound calls the LLM)
40
- const estimatedTokens = outboundMessages * 1000;
41
-
42
- // Pricing per 1M tokens (input+output blended) β€” updated May 2026
43
- const MODEL_PRICES_USD_PER_1M: Record<string, number> = {
44
- 'gpt-4o': 7.50,
45
- 'gpt-4o-mini': 0.30,
46
- 'gemini-2.0-flash': 0.15,
47
- 'gemini-1.5-pro': 3.50,
48
- 'claude-sonnet-4-6': 4.50,
49
- };
50
- const activeModel = process.env.DEFAULT_AI_MODEL || 'gpt-4o-mini';
51
- const pricePerMillion = MODEL_PRICES_USD_PER_1M[activeModel] ?? 0.30;
52
 
53
  return {
54
  messages: {
@@ -61,9 +64,17 @@ export async function analyticsRoutes(fastify: FastifyInstance) {
61
  activeLast24h: activeUsersLast24h
62
  },
63
  costs: {
64
- estimatedTokens,
65
- estimatedUsd: (estimatedTokens / 1_000_000) * pricePerMillion,
66
- model: activeModel
 
 
 
 
 
 
 
 
67
  }
68
  };
69
  } catch (err) {
 
22
  inboundMessages,
23
  outboundMessages,
24
  totalUsers,
25
+ activeUsersLast24h,
26
+ usageAgg,
27
+ usageByFeature,
28
  ] = await Promise.all([
29
  prisma.message.count({ where: { organizationId } }),
30
  prisma.message.count({ where: { organizationId, direction: 'INBOUND' } }),
 
35
  organizationId,
36
  lastActivityAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) }
37
  }
38
+ }),
39
+ // Real costs from UsageEvent table
40
+ prisma.usageEvent.aggregate({
41
+ where: { organizationId },
42
+ _sum: { tokensIn: true, tokensOut: true, costUsd: true },
43
+ }),
44
+ prisma.usageEvent.groupBy({
45
+ by: ['feature'],
46
+ where: { organizationId },
47
+ _sum: { costUsd: true, tokensIn: true, tokensOut: true },
48
+ _count: { _all: true },
49
+ }),
50
  ]);
51
 
52
+ const realTokensIn = usageAgg._sum.tokensIn ?? 0;
53
+ const realTokensOut = usageAgg._sum.tokensOut ?? 0;
54
+ const realCostUsd = usageAgg._sum.costUsd ?? 0;
 
 
 
 
 
 
 
 
 
 
55
 
56
  return {
57
  messages: {
 
64
  activeLast24h: activeUsersLast24h
65
  },
66
  costs: {
67
+ tokensIn: realTokensIn,
68
+ tokensOut: realTokensOut,
69
+ totalTokens: realTokensIn + realTokensOut,
70
+ totalUsd: realCostUsd,
71
+ byFeature: usageByFeature.map((f: any) => ({
72
+ feature: f.feature,
73
+ calls: f._count._all,
74
+ tokensIn: f._sum.tokensIn ?? 0,
75
+ tokensOut: f._sum.tokensOut ?? 0,
76
+ costUsd: f._sum.costUsd ?? 0,
77
+ })),
78
  }
79
  };
80
  } catch (err) {
apps/api/src/routes/billing.ts CHANGED
@@ -2,6 +2,7 @@ import { FastifyInstance } from 'fastify';
2
  import { prisma } from '../services/prisma';
3
  import OpenAI from 'openai';
4
  import { creditWallet } from '../services/wallet';
 
5
 
6
  const PLAN_LIMITS: Record<string, { aiCreditsLimit: number; label: string }> = {
7
  STARTER: { aiCreditsLimit: 500, label: 'DΓ©marrage' },
@@ -343,6 +344,12 @@ Les templates sont des messages prΓ©-approuvΓ©s par Meta pour initier une conver
343
  const allowed = ['EDTECH', 'CRM_MARKETING', 'AI_AGENT', 'CUSTOMER_SERVICE'];
344
  if (!allowed.includes(mode)) return JSON.stringify({ error: `Invalid mode. Allowed: ${allowed.join(', ')}` });
345
  await prisma.organization.update({ where: { id: organizationId }, data: { mode: mode as any } });
 
 
 
 
 
 
346
  return JSON.stringify({ success: true, newMode: mode, message: `Mode changed to ${mode}` });
347
  }
348
 
 
2
  import { prisma } from '../services/prisma';
3
  import OpenAI from 'openai';
4
  import { creditWallet } from '../services/wallet';
5
+ import { auditService } from '../services/audit';
6
 
7
  const PLAN_LIMITS: Record<string, { aiCreditsLimit: number; label: string }> = {
8
  STARTER: { aiCreditsLimit: 500, label: 'DΓ©marrage' },
 
344
  const allowed = ['EDTECH', 'CRM_MARKETING', 'AI_AGENT', 'CUSTOMER_SERVICE'];
345
  if (!allowed.includes(mode)) return JSON.stringify({ error: `Invalid mode. Allowed: ${allowed.join(', ')}` });
346
  await prisma.organization.update({ where: { id: organizationId }, data: { mode: mode as any } });
347
+ auditService.log({
348
+ action: 'ORG_MODE_CHANGED',
349
+ actorId: (req as any).user?.id,
350
+ resourceId: organizationId,
351
+ details: { newMode: mode, callerRole },
352
+ });
353
  return JSON.stringify({ success: true, newMode: mode, message: `Mode changed to ${mode}` });
354
  }
355
 
apps/api/src/routes/organizations.ts CHANGED
@@ -12,6 +12,7 @@ import { createSubscriber, orgChannel } from '../lib/redis';
12
  import { KBService } from '../services/kb-service';
13
  import { CRMService } from '../services/crm-service';
14
  import { OrganizationCreationSchema, createOrganizationWithAdmin } from '../services/organization-service';
 
15
 
16
  type P<K extends string = 'id'> = { Params: Record<K, string> };
17
  type Q<T extends Record<string, string | undefined>> = { Querystring: T };
@@ -100,6 +101,16 @@ export async function organizationRoutes(fastify: FastifyInstance) {
100
  const data = encryptSecrets(body.data);
101
  const org = await prisma.organization.update({ where: { id }, data });
102
  await invalidateOrganizationCache(id);
 
 
 
 
 
 
 
 
 
 
103
  return decryptSecrets(org);
104
  } catch (err) {
105
  logger.warn({ err, organizationId: id }, '[ORG] update failed');
@@ -198,6 +209,12 @@ export async function organizationRoutes(fastify: FastifyInstance) {
198
  });
199
 
200
  await invalidateOrganizationCache(id);
 
 
 
 
 
 
201
  return updatedOrg;
202
  } catch (err) {
203
  logger.warn({ err, organizationId: id }, '[ORG] personality update failed');
 
12
  import { KBService } from '../services/kb-service';
13
  import { CRMService } from '../services/crm-service';
14
  import { OrganizationCreationSchema, createOrganizationWithAdmin } from '../services/organization-service';
15
+ import { auditService } from '../services/audit';
16
 
17
  type P<K extends string = 'id'> = { Params: Record<K, string> };
18
  type Q<T extends Record<string, string | undefined>> = { Querystring: T };
 
101
  const data = encryptSecrets(body.data);
102
  const org = await prisma.organization.update({ where: { id }, data });
103
  await invalidateOrganizationCache(id);
104
+
105
+ if (wantsOwnKeys) {
106
+ auditService.log({
107
+ action: 'ORG_BYOK_KEYS_UPDATED',
108
+ actorId: req.user?.id,
109
+ resourceId: id,
110
+ details: { updatedKeys: Object.keys(body.data).filter(k => k.includes('ApiKey')) },
111
+ });
112
+ }
113
+
114
  return decryptSecrets(org);
115
  } catch (err) {
116
  logger.warn({ err, organizationId: id }, '[ORG] update failed');
 
209
  });
210
 
211
  await invalidateOrganizationCache(id);
212
+ auditService.log({
213
+ action: 'ORG_PERSONALITY_UPDATED',
214
+ actorId: req.user?.id,
215
+ resourceId: id,
216
+ details: { fields: Object.keys(body.data) },
217
+ });
218
  return updatedOrg;
219
  } catch (err) {
220
  logger.warn({ err, organizationId: id }, '[ORG] personality update failed');
apps/api/src/routes/whatsapp.ts CHANGED
@@ -153,6 +153,7 @@ export async function whatsappRoutes(fastify: FastifyInstance) {
153
  // The bridge owns org resolution, WEBHOOK mode handling, and Redis queue ops.
154
  // WORKER_BRIDGE_URL env var can override for separate-container deployments.
155
  const workerBridgeUrl = process.env.WORKER_BRIDGE_URL || 'http://localhost:8082';
 
156
  reply.code(200).send('EVENT_RECEIVED');
157
  setImmediate(async () => {
158
  try {
@@ -161,11 +162,12 @@ export async function whatsappRoutes(fastify: FastifyInstance) {
161
  headers: {
162
  'Content-Type': 'application/json',
163
  'Authorization': `Bearer ${process.env.ADMIN_API_KEY}`,
164
- 'x-organization-id': organizationId
 
165
  },
166
  body: JSON.stringify(request.body)
167
  });
168
- logger.info({ status: res.status, organizationId, phoneNumberId }, '[WHATSAPP-GATEWAY] Forwarded to worker bridge');
169
  } catch (err) {
170
  logger.error({ err, organizationId }, '[WHATSAPP-GATEWAY] Forward to worker bridge failed');
171
  }
 
153
  // The bridge owns org resolution, WEBHOOK mode handling, and Redis queue ops.
154
  // WORKER_BRIDGE_URL env var can override for separate-container deployments.
155
  const workerBridgeUrl = process.env.WORKER_BRIDGE_URL || 'http://localhost:8082';
156
+ const traceId = crypto.randomUUID();
157
  reply.code(200).send('EVENT_RECEIVED');
158
  setImmediate(async () => {
159
  try {
 
162
  headers: {
163
  'Content-Type': 'application/json',
164
  'Authorization': `Bearer ${process.env.ADMIN_API_KEY}`,
165
+ 'x-organization-id': organizationId,
166
+ 'x-trace-id': traceId
167
  },
168
  body: JSON.stringify(request.body)
169
  });
170
+ logger.info({ status: res.status, organizationId, phoneNumberId, traceId }, '[WHATSAPP-GATEWAY] Forwarded to worker bridge');
171
  } catch (err) {
172
  logger.error({ err, organizationId }, '[WHATSAPP-GATEWAY] Forward to worker bridge failed');
173
  }
apps/whatsapp-worker/src/handlers/InboundHandler.ts CHANGED
@@ -12,7 +12,7 @@ import { redis } from '../lib/redis';
12
 
13
  export class InboundHandler implements JobHandler {
14
  async handle(job: Job<JobData>): Promise<void> {
15
- let { phone, text, audioUrl, imageUrl, organizationId } = job.data;
16
 
17
  if (!phone) {
18
  logger.error(`[INBOUND_HANDLER] Missing phone number`);
@@ -100,7 +100,8 @@ export class InboundHandler implements JobHandler {
100
  imageUrl,
101
  organizationId,
102
  audioUrl ? 'audio' : imageUrl ? 'image' : undefined,
103
- audioUrl || imageUrl || undefined
 
104
  );
105
  }
106
  }
 
12
 
13
  export class InboundHandler implements JobHandler {
14
  async handle(job: Job<JobData>): Promise<void> {
15
+ let { phone, text, audioUrl, imageUrl, organizationId, traceId } = job.data;
16
 
17
  if (!phone) {
18
  logger.error(`[INBOUND_HANDLER] Missing phone number`);
 
100
  imageUrl,
101
  organizationId,
102
  audioUrl ? 'audio' : imageUrl ? 'image' : undefined,
103
+ audioUrl || imageUrl || undefined,
104
+ traceId
105
  );
106
  }
107
  }
apps/whatsapp-worker/src/handlers/types.ts CHANGED
@@ -89,6 +89,7 @@ export interface JobData {
89
  accessToken?: string;
90
  overrideAudioUrl?: string;
91
  sendSpinner?: boolean;
 
92
 
93
  // Interactive components
94
  buttons?: Array<{ id: string; title: string }>;
 
89
  accessToken?: string;
90
  overrideAudioUrl?: string;
91
  sendSpinner?: boolean;
92
+ traceId?: string;
93
 
94
  // Interactive components
95
  buttons?: Array<{ id: string; title: string }>;
apps/whatsapp-worker/src/index.ts CHANGED
@@ -76,6 +76,7 @@ server.post('/v1/internal/whatsapp/inbound', async (req: FastifyRequest, reply:
76
  }
77
 
78
  let organizationId: string | null = req.headers['x-organization-id'] as string || null;
 
79
  const payload = req.body as { entry?: Array<{ changes?: Array<{ value?: { metadata?: { phone_number_id?: string } } }> }> };
80
 
81
  // 🏒 Multi-Tenant Routing Hardening:
@@ -123,20 +124,19 @@ server.post('/v1/internal/whatsapp/inbound', async (req: FastifyRequest, reply:
123
 
124
  for (const msg of extractedMessages) {
125
  if (msg.text !== undefined) {
126
- logger.info(`[BRIDGE] Enqueuing inbound message from ${msg.phone}`);
127
  await whatsappQueue.add('handle-inbound', {
128
  phone: msg.phone,
129
  text: msg.text,
130
- organizationId
 
131
  }, {
132
  attempts: 3,
133
  backoff: { type: 'exponential', delay: 1000 }
134
  });
135
  } else if (msg.mediaId) {
136
- // πŸŽ™οΈ Handle media (Images and Audio)
137
  const isImage = msg.mediaType === 'image';
138
-
139
- logger.info(`[BRIDGE] Enqueuing inbound media (${msg.mediaType}) from ${msg.phone}`);
140
 
141
  // Single media job: MediaHandler handles transcription + DB logging + pedagogy.
142
  // A second handle-inbound job was previously enqueued here, causing double
@@ -147,7 +147,8 @@ server.post('/v1/internal/whatsapp/inbound', async (req: FastifyRequest, reply:
147
  phone: msg.phone,
148
  organizationId,
149
  caption: msg.caption,
150
- sendSpinner: true // MediaHandler will send a localized spinner after user lookup
 
151
  }, { priority: 1, attempts: 3, backoff: { type: 'exponential', delay: 2000 } });
152
  }
153
  }
 
76
  }
77
 
78
  let organizationId: string | null = req.headers['x-organization-id'] as string || null;
79
+ const traceId: string = (req.headers['x-trace-id'] as string) || `auto-${Date.now()}`;
80
  const payload = req.body as { entry?: Array<{ changes?: Array<{ value?: { metadata?: { phone_number_id?: string } } }> }> };
81
 
82
  // 🏒 Multi-Tenant Routing Hardening:
 
124
 
125
  for (const msg of extractedMessages) {
126
  if (msg.text !== undefined) {
127
+ logger.info({ traceId }, `[BRIDGE] Enqueuing inbound message from ${msg.phone}`);
128
  await whatsappQueue.add('handle-inbound', {
129
  phone: msg.phone,
130
  text: msg.text,
131
+ organizationId,
132
+ traceId,
133
  }, {
134
  attempts: 3,
135
  backoff: { type: 'exponential', delay: 1000 }
136
  });
137
  } else if (msg.mediaId) {
 
138
  const isImage = msg.mediaType === 'image';
139
+ logger.info({ traceId }, `[BRIDGE] Enqueuing inbound media (${msg.mediaType}) from ${msg.phone}`);
 
140
 
141
  // Single media job: MediaHandler handles transcription + DB logging + pedagogy.
142
  // A second handle-inbound job was previously enqueued here, causing double
 
147
  phone: msg.phone,
148
  organizationId,
149
  caption: msg.caption,
150
+ sendSpinner: true,
151
+ traceId,
152
  }, { priority: 1, attempts: 3, backoff: { type: 'exponential', delay: 2000 } });
153
  }
154
  }
apps/whatsapp-worker/src/services/whatsapp-logic.ts CHANGED
@@ -34,19 +34,20 @@ export class WhatsAppLogic {
34
  }
35
 
36
  static async handleIncomingMessage(
37
- phone: string,
38
- text: string,
39
- audioUrl?: string,
40
- imageUrl?: string,
41
  organizationId?: string,
42
  mediaType?: string,
43
- mediaId?: string
 
44
  ) {
45
  if (!organizationId) {
46
  logger.error({ phone }, '[WHATSAPP_LOGIC] Missing organizationId β€” refusing to process to prevent cross-tenant contamination');
47
  return;
48
  }
49
- const traceId = audioUrl ? `[STT-FLOW-${phone.slice(-4)}]` : imageUrl ? `[IMG-FLOW-${phone.slice(-4)}]` : `[TXT-FLOW-${phone.slice(-4)}]`;
50
  const normalizedText = this.normalizeCommand(text);
51
  logger.info(`${traceId} Orchestrating Inbound: ${normalizedText}`);
52
 
 
34
  }
35
 
36
  static async handleIncomingMessage(
37
+ phone: string,
38
+ text: string,
39
+ audioUrl?: string,
40
+ imageUrl?: string,
41
  organizationId?: string,
42
  mediaType?: string,
43
+ mediaId?: string,
44
+ incomingTraceId?: string
45
  ) {
46
  if (!organizationId) {
47
  logger.error({ phone }, '[WHATSAPP_LOGIC] Missing organizationId β€” refusing to process to prevent cross-tenant contamination');
48
  return;
49
  }
50
+ const traceId = incomingTraceId ?? (audioUrl ? `[STT-FLOW-${phone.slice(-4)}]` : imageUrl ? `[IMG-FLOW-${phone.slice(-4)}]` : `[TXT-FLOW-${phone.slice(-4)}]`);
51
  const normalizedText = this.normalizeCommand(text);
52
  logger.info(`${traceId} Orchestrating Inbound: ${normalizedText}`);
53
 
packages/shared-types/src/organization.ts CHANGED
@@ -25,6 +25,14 @@ export const FlowConfigSchema = z.object({
25
  export type FlowConfig = z.infer<typeof FlowConfigSchema>;
26
  export type Sector = z.infer<typeof SectorSchema>;
27
 
 
 
 
 
 
 
 
 
28
  export const OrganizationSchema = z.object({
29
  name: z.string().min(1),
30
  contactEmail: z.string().email().optional(),
@@ -39,7 +47,7 @@ export const OrganizationSchema = z.object({
39
  knowledgeBaseUrl: z.string().url().optional().or(z.literal('')),
40
  systemUserToken: z.string().optional(),
41
  metaBusinessId: z.string().optional(),
42
- brandingData: z.any().optional(),
43
  subscriptionPlan: z.enum(['STARTER', 'GROWTH', 'SCALE', 'ENTERPRISE']).optional(),
44
  openAiApiKey: z.string().optional(),
45
  googleAiApiKey: z.string().optional(),
 
25
  export type FlowConfig = z.infer<typeof FlowConfigSchema>;
26
  export type Sector = z.infer<typeof SectorSchema>;
27
 
28
+ export const BrandingDataSchema = z.object({
29
+ logoUrl: z.string().url().optional(),
30
+ primaryColor: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
31
+ orgName: z.string().max(100).optional(),
32
+ }).strict();
33
+
34
+ export type BrandingData = z.infer<typeof BrandingDataSchema>;
35
+
36
  export const OrganizationSchema = z.object({
37
  name: z.string().min(1),
38
  contactEmail: z.string().email().optional(),
 
47
  knowledgeBaseUrl: z.string().url().optional().or(z.literal('')),
48
  systemUserToken: z.string().optional(),
49
  metaBusinessId: z.string().optional(),
50
+ brandingData: BrandingDataSchema.optional(),
51
  subscriptionPlan: z.enum(['STARTER', 'GROWTH', 'SCALE', 'ENTERPRISE']).optional(),
52
  openAiApiKey: z.string().optional(),
53
  googleAiApiKey: z.string().optional(),