@bbearai/core 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -263,6 +263,185 @@ interface TesterMessage {
263
263
  name: string;
264
264
  }>;
265
265
  }
266
+ /** Priority factors breakdown for a route */
267
+ interface PriorityFactors {
268
+ bugFrequency: {
269
+ score: number;
270
+ openBugs: number;
271
+ bugs7d: number;
272
+ };
273
+ criticalSeverity: {
274
+ score: number;
275
+ critical: number;
276
+ high: number;
277
+ };
278
+ staleness: {
279
+ score: number;
280
+ daysSinceTest: number | null;
281
+ };
282
+ coverageGap: {
283
+ score: number;
284
+ testCount: number;
285
+ };
286
+ regressionRisk: {
287
+ score: number;
288
+ regressionCount: number;
289
+ };
290
+ }
291
+ /** Route priority with stats and recommendations */
292
+ interface RoutePriority {
293
+ rank: number;
294
+ route: string;
295
+ priorityScore: number;
296
+ urgency: 'critical' | 'high' | 'medium' | 'low';
297
+ stats: {
298
+ openBugs: number;
299
+ criticalBugs: number;
300
+ highBugs: number;
301
+ testCases: number;
302
+ daysSinceTest: number | null;
303
+ regressions: number;
304
+ recentBugs: number;
305
+ };
306
+ factors?: PriorityFactors;
307
+ recommendation?: string;
308
+ }
309
+ /** Coverage gap identification */
310
+ interface CoverageGap {
311
+ route: string;
312
+ severity: 'critical' | 'high' | 'medium';
313
+ type: 'untested' | 'missing_tracks' | 'stale';
314
+ details: {
315
+ missingTracks?: string[];
316
+ daysSinceTest?: number;
317
+ openBugs?: number;
318
+ criticalBugs?: number;
319
+ };
320
+ recommendation: string;
321
+ }
322
+ /** Regression event tracking */
323
+ interface RegressionEvent {
324
+ id: string;
325
+ route: string;
326
+ severity: 'critical' | 'high' | 'medium';
327
+ originalBug: {
328
+ id: string;
329
+ description: string;
330
+ resolvedAt: string;
331
+ };
332
+ newBugs: Array<{
333
+ id: string;
334
+ description: string;
335
+ severity: string;
336
+ createdAt: string;
337
+ }>;
338
+ daysSinceResolution: number;
339
+ regressionCount: number;
340
+ detectedAt: string;
341
+ }
342
+ /** Deploy checklist item */
343
+ interface ChecklistItem {
344
+ testCaseId?: string;
345
+ testKey?: string;
346
+ title: string;
347
+ route: string;
348
+ reason: string;
349
+ priority: 'P0' | 'P1' | 'P2' | 'P3';
350
+ lastTested?: string;
351
+ hasCriticalBugs?: boolean;
352
+ }
353
+ /** Deploy checklist grouped by urgency */
354
+ interface DeployChecklist {
355
+ critical: ChecklistItem[];
356
+ recommended: ChecklistItem[];
357
+ optional: ChecklistItem[];
358
+ gaps: ChecklistItem[];
359
+ }
360
+ /** QA Health metrics with trends */
361
+ interface QAHealthMetrics {
362
+ velocity: {
363
+ testsPerWeek: number;
364
+ testsCompleted: number;
365
+ trend: 'up' | 'down' | 'stable';
366
+ changePercent?: number;
367
+ };
368
+ bugDiscovery: {
369
+ bugsFound: number;
370
+ bugsPerTest: number;
371
+ criticalBugs: number;
372
+ trend: 'up' | 'down' | 'stable';
373
+ changePercent?: number;
374
+ };
375
+ resolution: {
376
+ bugsResolved: number;
377
+ avgResolutionDays: number;
378
+ trend: 'up' | 'down' | 'stable';
379
+ changePercent?: number;
380
+ };
381
+ coverage: {
382
+ routeCoverage: number;
383
+ routesWithTests: number;
384
+ totalRoutes: number;
385
+ };
386
+ testerHealth: {
387
+ activeTesters: number;
388
+ totalTesters: number;
389
+ utilizationPercent: number;
390
+ };
391
+ }
392
+ /** QA Health score with grade */
393
+ interface QAHealthScore {
394
+ score: number;
395
+ grade: 'A' | 'B' | 'C' | 'D' | 'F';
396
+ breakdown: {
397
+ coverage: number;
398
+ velocity: number;
399
+ resolution: number;
400
+ stability: number;
401
+ };
402
+ }
403
+ /** Aggregated route test statistics */
404
+ interface RouteTestStats {
405
+ id: string;
406
+ projectId: string;
407
+ route: string;
408
+ totalBugs: number;
409
+ openBugs: number;
410
+ criticalBugs: number;
411
+ highBugs: number;
412
+ resolvedBugs: number;
413
+ testCaseCount: number;
414
+ testsByTrack: Record<string, number>;
415
+ lastTestedAt: string | null;
416
+ totalTestExecutions: number;
417
+ passCount: number;
418
+ failCount: number;
419
+ passRate: number | null;
420
+ regressionCount: number;
421
+ lastRegressionAt: string | null;
422
+ bugsLast7Days: number;
423
+ bugsLast30Days: number;
424
+ priorityScore: number;
425
+ calculatedAt: string;
426
+ }
427
+ /** Coverage matrix cell */
428
+ interface CoverageMatrixCell {
429
+ testCount: number;
430
+ passCount: number;
431
+ failCount: number;
432
+ passRate: number | null;
433
+ lastTestedAt: string | null;
434
+ staleDays: number | null;
435
+ }
436
+ /** Coverage matrix row (route × tracks) */
437
+ interface CoverageMatrixRow {
438
+ route: string;
439
+ totalTests: number;
440
+ openBugs: number;
441
+ criticalBugs: number;
442
+ lastTestedAt: string | null;
443
+ tracks: Record<string, CoverageMatrixCell>;
444
+ }
266
445
 
267
446
  /**
268
447
  * BugBear Client
@@ -304,8 +483,29 @@ declare class BugBearClient {
304
483
  /**
305
484
  * Get current tester info
306
485
  * Looks up tester by email from the host app's authenticated user
486
+ * Checks both primary email AND additional_emails array
487
+ * Uses parameterized RPC function to prevent SQL injection
307
488
  */
308
489
  getTesterInfo(): Promise<TesterInfo | null>;
490
+ /**
491
+ * Basic email format validation (defense in depth)
492
+ */
493
+ private isValidEmail;
494
+ /**
495
+ * Validate report input before submission
496
+ * Returns error message if invalid, null if valid
497
+ */
498
+ private validateReport;
499
+ /**
500
+ * Validate profile update input
501
+ * Returns error message if invalid, null if valid
502
+ */
503
+ private validateProfileUpdate;
504
+ /**
505
+ * Check rate limit for an action
506
+ * Returns { allowed: boolean, error?: string, remaining?: number }
507
+ */
508
+ private checkRateLimit;
309
509
  /**
310
510
  * Update tester profile
311
511
  * Allows testers to update their name, additional emails, avatar, and platforms
@@ -467,4 +667,4 @@ declare function captureError(error: Error, errorInfo?: {
467
667
  componentStack?: string;
468
668
  };
469
669
 
470
- export { type AppContext, BugBearClient, type BugBearConfig, type BugBearReport, type BugBearTheme, type ChecklistResult, type ConsoleLogEntry, type DeviceInfo, type EnhancedBugContext, type HostUserInfo, type MessageSenderType, type NetworkRequest, type QATrack, type ReportStatus, type ReportType, type RubricMode, type RubricResult, type Severity, type TestAssignment, type TestResult, type TestStep, type TestTemplate, type TesterInfo, type TesterMessage, type TesterProfileUpdate, type TesterThread, type ThreadPriority, type ThreadType, captureError, contextCapture, createBugBear };
670
+ export { type AppContext, BugBearClient, type BugBearConfig, type BugBearReport, type BugBearTheme, type ChecklistItem, type ChecklistResult, type ConsoleLogEntry, type CoverageGap, type CoverageMatrixCell, type CoverageMatrixRow, type DeployChecklist, type DeviceInfo, type EnhancedBugContext, type HostUserInfo, type MessageSenderType, type NetworkRequest, type PriorityFactors, type QAHealthMetrics, type QAHealthScore, type QATrack, type RegressionEvent, type ReportStatus, type ReportType, type RoutePriority, type RouteTestStats, type RubricMode, type RubricResult, type Severity, type TestAssignment, type TestResult, type TestStep, type TestTemplate, type TesterInfo, type TesterMessage, type TesterProfileUpdate, type TesterThread, type ThreadPriority, type ThreadType, captureError, contextCapture, createBugBear };
package/dist/index.d.ts CHANGED
@@ -263,6 +263,185 @@ interface TesterMessage {
263
263
  name: string;
264
264
  }>;
265
265
  }
266
+ /** Priority factors breakdown for a route */
267
+ interface PriorityFactors {
268
+ bugFrequency: {
269
+ score: number;
270
+ openBugs: number;
271
+ bugs7d: number;
272
+ };
273
+ criticalSeverity: {
274
+ score: number;
275
+ critical: number;
276
+ high: number;
277
+ };
278
+ staleness: {
279
+ score: number;
280
+ daysSinceTest: number | null;
281
+ };
282
+ coverageGap: {
283
+ score: number;
284
+ testCount: number;
285
+ };
286
+ regressionRisk: {
287
+ score: number;
288
+ regressionCount: number;
289
+ };
290
+ }
291
+ /** Route priority with stats and recommendations */
292
+ interface RoutePriority {
293
+ rank: number;
294
+ route: string;
295
+ priorityScore: number;
296
+ urgency: 'critical' | 'high' | 'medium' | 'low';
297
+ stats: {
298
+ openBugs: number;
299
+ criticalBugs: number;
300
+ highBugs: number;
301
+ testCases: number;
302
+ daysSinceTest: number | null;
303
+ regressions: number;
304
+ recentBugs: number;
305
+ };
306
+ factors?: PriorityFactors;
307
+ recommendation?: string;
308
+ }
309
+ /** Coverage gap identification */
310
+ interface CoverageGap {
311
+ route: string;
312
+ severity: 'critical' | 'high' | 'medium';
313
+ type: 'untested' | 'missing_tracks' | 'stale';
314
+ details: {
315
+ missingTracks?: string[];
316
+ daysSinceTest?: number;
317
+ openBugs?: number;
318
+ criticalBugs?: number;
319
+ };
320
+ recommendation: string;
321
+ }
322
+ /** Regression event tracking */
323
+ interface RegressionEvent {
324
+ id: string;
325
+ route: string;
326
+ severity: 'critical' | 'high' | 'medium';
327
+ originalBug: {
328
+ id: string;
329
+ description: string;
330
+ resolvedAt: string;
331
+ };
332
+ newBugs: Array<{
333
+ id: string;
334
+ description: string;
335
+ severity: string;
336
+ createdAt: string;
337
+ }>;
338
+ daysSinceResolution: number;
339
+ regressionCount: number;
340
+ detectedAt: string;
341
+ }
342
+ /** Deploy checklist item */
343
+ interface ChecklistItem {
344
+ testCaseId?: string;
345
+ testKey?: string;
346
+ title: string;
347
+ route: string;
348
+ reason: string;
349
+ priority: 'P0' | 'P1' | 'P2' | 'P3';
350
+ lastTested?: string;
351
+ hasCriticalBugs?: boolean;
352
+ }
353
+ /** Deploy checklist grouped by urgency */
354
+ interface DeployChecklist {
355
+ critical: ChecklistItem[];
356
+ recommended: ChecklistItem[];
357
+ optional: ChecklistItem[];
358
+ gaps: ChecklistItem[];
359
+ }
360
+ /** QA Health metrics with trends */
361
+ interface QAHealthMetrics {
362
+ velocity: {
363
+ testsPerWeek: number;
364
+ testsCompleted: number;
365
+ trend: 'up' | 'down' | 'stable';
366
+ changePercent?: number;
367
+ };
368
+ bugDiscovery: {
369
+ bugsFound: number;
370
+ bugsPerTest: number;
371
+ criticalBugs: number;
372
+ trend: 'up' | 'down' | 'stable';
373
+ changePercent?: number;
374
+ };
375
+ resolution: {
376
+ bugsResolved: number;
377
+ avgResolutionDays: number;
378
+ trend: 'up' | 'down' | 'stable';
379
+ changePercent?: number;
380
+ };
381
+ coverage: {
382
+ routeCoverage: number;
383
+ routesWithTests: number;
384
+ totalRoutes: number;
385
+ };
386
+ testerHealth: {
387
+ activeTesters: number;
388
+ totalTesters: number;
389
+ utilizationPercent: number;
390
+ };
391
+ }
392
+ /** QA Health score with grade */
393
+ interface QAHealthScore {
394
+ score: number;
395
+ grade: 'A' | 'B' | 'C' | 'D' | 'F';
396
+ breakdown: {
397
+ coverage: number;
398
+ velocity: number;
399
+ resolution: number;
400
+ stability: number;
401
+ };
402
+ }
403
+ /** Aggregated route test statistics */
404
+ interface RouteTestStats {
405
+ id: string;
406
+ projectId: string;
407
+ route: string;
408
+ totalBugs: number;
409
+ openBugs: number;
410
+ criticalBugs: number;
411
+ highBugs: number;
412
+ resolvedBugs: number;
413
+ testCaseCount: number;
414
+ testsByTrack: Record<string, number>;
415
+ lastTestedAt: string | null;
416
+ totalTestExecutions: number;
417
+ passCount: number;
418
+ failCount: number;
419
+ passRate: number | null;
420
+ regressionCount: number;
421
+ lastRegressionAt: string | null;
422
+ bugsLast7Days: number;
423
+ bugsLast30Days: number;
424
+ priorityScore: number;
425
+ calculatedAt: string;
426
+ }
427
+ /** Coverage matrix cell */
428
+ interface CoverageMatrixCell {
429
+ testCount: number;
430
+ passCount: number;
431
+ failCount: number;
432
+ passRate: number | null;
433
+ lastTestedAt: string | null;
434
+ staleDays: number | null;
435
+ }
436
+ /** Coverage matrix row (route × tracks) */
437
+ interface CoverageMatrixRow {
438
+ route: string;
439
+ totalTests: number;
440
+ openBugs: number;
441
+ criticalBugs: number;
442
+ lastTestedAt: string | null;
443
+ tracks: Record<string, CoverageMatrixCell>;
444
+ }
266
445
 
267
446
  /**
268
447
  * BugBear Client
@@ -304,8 +483,29 @@ declare class BugBearClient {
304
483
  /**
305
484
  * Get current tester info
306
485
  * Looks up tester by email from the host app's authenticated user
486
+ * Checks both primary email AND additional_emails array
487
+ * Uses parameterized RPC function to prevent SQL injection
307
488
  */
308
489
  getTesterInfo(): Promise<TesterInfo | null>;
490
+ /**
491
+ * Basic email format validation (defense in depth)
492
+ */
493
+ private isValidEmail;
494
+ /**
495
+ * Validate report input before submission
496
+ * Returns error message if invalid, null if valid
497
+ */
498
+ private validateReport;
499
+ /**
500
+ * Validate profile update input
501
+ * Returns error message if invalid, null if valid
502
+ */
503
+ private validateProfileUpdate;
504
+ /**
505
+ * Check rate limit for an action
506
+ * Returns { allowed: boolean, error?: string, remaining?: number }
507
+ */
508
+ private checkRateLimit;
309
509
  /**
310
510
  * Update tester profile
311
511
  * Allows testers to update their name, additional emails, avatar, and platforms
@@ -467,4 +667,4 @@ declare function captureError(error: Error, errorInfo?: {
467
667
  componentStack?: string;
468
668
  };
469
669
 
470
- export { type AppContext, BugBearClient, type BugBearConfig, type BugBearReport, type BugBearTheme, type ChecklistResult, type ConsoleLogEntry, type DeviceInfo, type EnhancedBugContext, type HostUserInfo, type MessageSenderType, type NetworkRequest, type QATrack, type ReportStatus, type ReportType, type RubricMode, type RubricResult, type Severity, type TestAssignment, type TestResult, type TestStep, type TestTemplate, type TesterInfo, type TesterMessage, type TesterProfileUpdate, type TesterThread, type ThreadPriority, type ThreadType, captureError, contextCapture, createBugBear };
670
+ export { type AppContext, BugBearClient, type BugBearConfig, type BugBearReport, type BugBearTheme, type ChecklistItem, type ChecklistResult, type ConsoleLogEntry, type CoverageGap, type CoverageMatrixCell, type CoverageMatrixRow, type DeployChecklist, type DeviceInfo, type EnhancedBugContext, type HostUserInfo, type MessageSenderType, type NetworkRequest, type PriorityFactors, type QAHealthMetrics, type QAHealthScore, type QATrack, type RegressionEvent, type ReportStatus, type ReportType, type RoutePriority, type RouteTestStats, type RubricMode, type RubricResult, type Severity, type TestAssignment, type TestResult, type TestStep, type TestTemplate, type TesterInfo, type TesterMessage, type TesterProfileUpdate, type TesterThread, type ThreadPriority, type ThreadType, captureError, contextCapture, createBugBear };
package/dist/index.js CHANGED
@@ -86,7 +86,16 @@ var BugBearClient = class {
86
86
  */
87
87
  async submitReport(report) {
88
88
  try {
89
+ const validationError = this.validateReport(report);
90
+ if (validationError) {
91
+ return { success: false, error: validationError };
92
+ }
89
93
  const userInfo = await this.getCurrentUserInfo();
94
+ const rateLimitId = userInfo?.email || this.config.projectId;
95
+ const rateLimit = await this.checkRateLimit(rateLimitId, "report_submit");
96
+ if (!rateLimit.allowed) {
97
+ return { success: false, error: rateLimit.error };
98
+ }
90
99
  if (!userInfo) {
91
100
  console.error("BugBear: No user info available, cannot submit report");
92
101
  return { success: false, error: "User not authenticated" };
@@ -192,44 +201,179 @@ var BugBearClient = class {
192
201
  /**
193
202
  * Get current tester info
194
203
  * Looks up tester by email from the host app's authenticated user
204
+ * Checks both primary email AND additional_emails array
205
+ * Uses parameterized RPC function to prevent SQL injection
195
206
  */
196
207
  async getTesterInfo() {
197
208
  try {
198
209
  const userInfo = await this.getCurrentUserInfo();
199
- if (!userInfo) return null;
200
- const { data, error } = await this.supabase.from("testers").select("*").eq("project_id", this.config.projectId).eq("email", userInfo.email).eq("status", "active").single();
210
+ if (!userInfo?.email) return null;
211
+ if (!this.isValidEmail(userInfo.email)) {
212
+ console.warn("BugBear: Invalid email format");
213
+ return null;
214
+ }
215
+ const { data, error } = await this.supabase.rpc("lookup_tester_by_email", {
216
+ p_project_id: this.config.projectId,
217
+ p_email: userInfo.email
218
+ }).maybeSingle();
201
219
  if (error || !data) return null;
220
+ const tester = data;
202
221
  return {
203
- id: data.id,
204
- name: data.name,
205
- email: data.email,
206
- additionalEmails: data.additional_emails || [],
207
- avatarUrl: data.avatar_url || void 0,
208
- platforms: data.platforms || [],
209
- assignedTests: data.assigned_count || 0,
210
- completedTests: data.completed_count || 0
222
+ id: tester.id,
223
+ name: tester.name,
224
+ email: tester.email,
225
+ additionalEmails: tester.additional_emails || [],
226
+ avatarUrl: tester.avatar_url || void 0,
227
+ platforms: tester.platforms || [],
228
+ assignedTests: tester.assigned_count || 0,
229
+ completedTests: tester.completed_count || 0
211
230
  };
212
231
  } catch (err) {
213
232
  console.error("BugBear: getTesterInfo error", err);
214
233
  return null;
215
234
  }
216
235
  }
236
+ /**
237
+ * Basic email format validation (defense in depth)
238
+ */
239
+ isValidEmail(email) {
240
+ if (!email || email.length > 254) return false;
241
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
242
+ return emailRegex.test(email);
243
+ }
244
+ /**
245
+ * Validate report input before submission
246
+ * Returns error message if invalid, null if valid
247
+ */
248
+ validateReport(report) {
249
+ const validTypes = ["bug", "feedback", "suggestion", "test_pass", "test_fail"];
250
+ if (report.type && !validTypes.includes(report.type)) {
251
+ return `Invalid report type: ${report.type}. Must be one of: ${validTypes.join(", ")}`;
252
+ }
253
+ const validSeverities = ["critical", "high", "medium", "low"];
254
+ if (report.severity && !validSeverities.includes(report.severity)) {
255
+ return `Invalid severity: ${report.severity}. Must be one of: ${validSeverities.join(", ")}`;
256
+ }
257
+ if (report.title && report.title.length > 500) {
258
+ return "Title must be 500 characters or less";
259
+ }
260
+ if (report.description && report.description.length > 1e4) {
261
+ return "Description must be 10,000 characters or less";
262
+ }
263
+ if (report.screenshots && Array.isArray(report.screenshots)) {
264
+ if (report.screenshots.length > 10) {
265
+ return "Maximum 10 screenshots allowed";
266
+ }
267
+ for (const url of report.screenshots) {
268
+ if (typeof url !== "string" || url.length > 2e3) {
269
+ return "Invalid screenshot URL";
270
+ }
271
+ }
272
+ }
273
+ return null;
274
+ }
275
+ /**
276
+ * Validate profile update input
277
+ * Returns error message if invalid, null if valid
278
+ */
279
+ validateProfileUpdate(updates) {
280
+ if (updates.name !== void 0) {
281
+ if (typeof updates.name !== "string" || updates.name.length > 100) {
282
+ return "Name must be 100 characters or less";
283
+ }
284
+ }
285
+ if (updates.additionalEmails !== void 0) {
286
+ if (!Array.isArray(updates.additionalEmails)) {
287
+ return "Additional emails must be an array";
288
+ }
289
+ if (updates.additionalEmails.length > 5) {
290
+ return "Maximum 5 additional emails allowed";
291
+ }
292
+ for (const email of updates.additionalEmails) {
293
+ if (!this.isValidEmail(email)) {
294
+ return `Invalid email format: ${email}`;
295
+ }
296
+ }
297
+ }
298
+ if (updates.avatarUrl !== void 0 && updates.avatarUrl !== null) {
299
+ if (typeof updates.avatarUrl !== "string" || updates.avatarUrl.length > 2e3) {
300
+ return "Invalid avatar URL";
301
+ }
302
+ }
303
+ if (updates.platforms !== void 0) {
304
+ if (!Array.isArray(updates.platforms)) {
305
+ return "Platforms must be an array";
306
+ }
307
+ const validPlatforms = ["ios", "android", "web", "desktop", "other"];
308
+ for (const platform of updates.platforms) {
309
+ if (!validPlatforms.includes(platform)) {
310
+ return `Invalid platform: ${platform}. Must be one of: ${validPlatforms.join(", ")}`;
311
+ }
312
+ }
313
+ }
314
+ return null;
315
+ }
316
+ /**
317
+ * Check rate limit for an action
318
+ * Returns { allowed: boolean, error?: string, remaining?: number }
319
+ */
320
+ async checkRateLimit(identifier, action) {
321
+ try {
322
+ const { data, error } = await this.supabase.rpc("check_rate_limit", {
323
+ p_identifier: identifier,
324
+ p_action: action,
325
+ p_project_id: this.config.projectId
326
+ });
327
+ if (error) {
328
+ console.warn("BugBear: Rate limit check failed, allowing request", error.message);
329
+ return { allowed: true };
330
+ }
331
+ if (!data.allowed) {
332
+ return {
333
+ allowed: false,
334
+ error: `Rate limit exceeded. Try again in ${Math.ceil((new Date(data.reset_at).getTime() - Date.now()) / 1e3)} seconds.`,
335
+ remaining: 0,
336
+ resetAt: data.reset_at
337
+ };
338
+ }
339
+ return {
340
+ allowed: true,
341
+ remaining: data.remaining,
342
+ resetAt: data.reset_at
343
+ };
344
+ } catch (err) {
345
+ console.warn("BugBear: Rate limit check error", err);
346
+ return { allowed: true };
347
+ }
348
+ }
217
349
  /**
218
350
  * Update tester profile
219
351
  * Allows testers to update their name, additional emails, avatar, and platforms
220
352
  */
221
353
  async updateTesterProfile(updates) {
222
354
  try {
355
+ const validationError = this.validateProfileUpdate(updates);
356
+ if (validationError) {
357
+ return { success: false, error: validationError };
358
+ }
223
359
  const userInfo = await this.getCurrentUserInfo();
224
360
  if (!userInfo) {
225
361
  return { success: false, error: "Not authenticated" };
226
362
  }
363
+ const rateLimit = await this.checkRateLimit(userInfo.email, "profile_update");
364
+ if (!rateLimit.allowed) {
365
+ return { success: false, error: rateLimit.error };
366
+ }
367
+ const testerInfo = await this.getTesterInfo();
368
+ if (!testerInfo) {
369
+ return { success: false, error: "Not a registered tester" };
370
+ }
227
371
  const updateData = {};
228
372
  if (updates.name !== void 0) updateData.name = updates.name;
229
373
  if (updates.additionalEmails !== void 0) updateData.additional_emails = updates.additionalEmails;
230
374
  if (updates.avatarUrl !== void 0) updateData.avatar_url = updates.avatarUrl;
231
375
  if (updates.platforms !== void 0) updateData.platforms = updates.platforms;
232
- const { error } = await this.supabase.from("testers").update(updateData).eq("project_id", this.config.projectId).eq("email", userInfo.email);
376
+ const { error } = await this.supabase.from("testers").update(updateData).eq("id", testerInfo.id);
233
377
  if (error) {
234
378
  console.error("BugBear: updateTesterProfile error", error);
235
379
  return { success: false, error: error.message };
@@ -505,6 +649,11 @@ var BugBearClient = class {
505
649
  console.error("BugBear: No tester info, cannot send message");
506
650
  return false;
507
651
  }
652
+ const rateLimit = await this.checkRateLimit(testerInfo.email, "message_send");
653
+ if (!rateLimit.allowed) {
654
+ console.error("BugBear: Rate limit exceeded for messages");
655
+ return false;
656
+ }
508
657
  const { error } = await this.supabase.from("discussion_messages").insert({
509
658
  thread_id: threadId,
510
659
  sender_type: "tester",
package/dist/index.mjs CHANGED
@@ -57,7 +57,16 @@ var BugBearClient = class {
57
57
  */
58
58
  async submitReport(report) {
59
59
  try {
60
+ const validationError = this.validateReport(report);
61
+ if (validationError) {
62
+ return { success: false, error: validationError };
63
+ }
60
64
  const userInfo = await this.getCurrentUserInfo();
65
+ const rateLimitId = userInfo?.email || this.config.projectId;
66
+ const rateLimit = await this.checkRateLimit(rateLimitId, "report_submit");
67
+ if (!rateLimit.allowed) {
68
+ return { success: false, error: rateLimit.error };
69
+ }
61
70
  if (!userInfo) {
62
71
  console.error("BugBear: No user info available, cannot submit report");
63
72
  return { success: false, error: "User not authenticated" };
@@ -163,44 +172,179 @@ var BugBearClient = class {
163
172
  /**
164
173
  * Get current tester info
165
174
  * Looks up tester by email from the host app's authenticated user
175
+ * Checks both primary email AND additional_emails array
176
+ * Uses parameterized RPC function to prevent SQL injection
166
177
  */
167
178
  async getTesterInfo() {
168
179
  try {
169
180
  const userInfo = await this.getCurrentUserInfo();
170
- if (!userInfo) return null;
171
- const { data, error } = await this.supabase.from("testers").select("*").eq("project_id", this.config.projectId).eq("email", userInfo.email).eq("status", "active").single();
181
+ if (!userInfo?.email) return null;
182
+ if (!this.isValidEmail(userInfo.email)) {
183
+ console.warn("BugBear: Invalid email format");
184
+ return null;
185
+ }
186
+ const { data, error } = await this.supabase.rpc("lookup_tester_by_email", {
187
+ p_project_id: this.config.projectId,
188
+ p_email: userInfo.email
189
+ }).maybeSingle();
172
190
  if (error || !data) return null;
191
+ const tester = data;
173
192
  return {
174
- id: data.id,
175
- name: data.name,
176
- email: data.email,
177
- additionalEmails: data.additional_emails || [],
178
- avatarUrl: data.avatar_url || void 0,
179
- platforms: data.platforms || [],
180
- assignedTests: data.assigned_count || 0,
181
- completedTests: data.completed_count || 0
193
+ id: tester.id,
194
+ name: tester.name,
195
+ email: tester.email,
196
+ additionalEmails: tester.additional_emails || [],
197
+ avatarUrl: tester.avatar_url || void 0,
198
+ platforms: tester.platforms || [],
199
+ assignedTests: tester.assigned_count || 0,
200
+ completedTests: tester.completed_count || 0
182
201
  };
183
202
  } catch (err) {
184
203
  console.error("BugBear: getTesterInfo error", err);
185
204
  return null;
186
205
  }
187
206
  }
207
+ /**
208
+ * Basic email format validation (defense in depth)
209
+ */
210
+ isValidEmail(email) {
211
+ if (!email || email.length > 254) return false;
212
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
213
+ return emailRegex.test(email);
214
+ }
215
+ /**
216
+ * Validate report input before submission
217
+ * Returns error message if invalid, null if valid
218
+ */
219
+ validateReport(report) {
220
+ const validTypes = ["bug", "feedback", "suggestion", "test_pass", "test_fail"];
221
+ if (report.type && !validTypes.includes(report.type)) {
222
+ return `Invalid report type: ${report.type}. Must be one of: ${validTypes.join(", ")}`;
223
+ }
224
+ const validSeverities = ["critical", "high", "medium", "low"];
225
+ if (report.severity && !validSeverities.includes(report.severity)) {
226
+ return `Invalid severity: ${report.severity}. Must be one of: ${validSeverities.join(", ")}`;
227
+ }
228
+ if (report.title && report.title.length > 500) {
229
+ return "Title must be 500 characters or less";
230
+ }
231
+ if (report.description && report.description.length > 1e4) {
232
+ return "Description must be 10,000 characters or less";
233
+ }
234
+ if (report.screenshots && Array.isArray(report.screenshots)) {
235
+ if (report.screenshots.length > 10) {
236
+ return "Maximum 10 screenshots allowed";
237
+ }
238
+ for (const url of report.screenshots) {
239
+ if (typeof url !== "string" || url.length > 2e3) {
240
+ return "Invalid screenshot URL";
241
+ }
242
+ }
243
+ }
244
+ return null;
245
+ }
246
+ /**
247
+ * Validate profile update input
248
+ * Returns error message if invalid, null if valid
249
+ */
250
+ validateProfileUpdate(updates) {
251
+ if (updates.name !== void 0) {
252
+ if (typeof updates.name !== "string" || updates.name.length > 100) {
253
+ return "Name must be 100 characters or less";
254
+ }
255
+ }
256
+ if (updates.additionalEmails !== void 0) {
257
+ if (!Array.isArray(updates.additionalEmails)) {
258
+ return "Additional emails must be an array";
259
+ }
260
+ if (updates.additionalEmails.length > 5) {
261
+ return "Maximum 5 additional emails allowed";
262
+ }
263
+ for (const email of updates.additionalEmails) {
264
+ if (!this.isValidEmail(email)) {
265
+ return `Invalid email format: ${email}`;
266
+ }
267
+ }
268
+ }
269
+ if (updates.avatarUrl !== void 0 && updates.avatarUrl !== null) {
270
+ if (typeof updates.avatarUrl !== "string" || updates.avatarUrl.length > 2e3) {
271
+ return "Invalid avatar URL";
272
+ }
273
+ }
274
+ if (updates.platforms !== void 0) {
275
+ if (!Array.isArray(updates.platforms)) {
276
+ return "Platforms must be an array";
277
+ }
278
+ const validPlatforms = ["ios", "android", "web", "desktop", "other"];
279
+ for (const platform of updates.platforms) {
280
+ if (!validPlatforms.includes(platform)) {
281
+ return `Invalid platform: ${platform}. Must be one of: ${validPlatforms.join(", ")}`;
282
+ }
283
+ }
284
+ }
285
+ return null;
286
+ }
287
+ /**
288
+ * Check rate limit for an action
289
+ * Returns { allowed: boolean, error?: string, remaining?: number }
290
+ */
291
+ async checkRateLimit(identifier, action) {
292
+ try {
293
+ const { data, error } = await this.supabase.rpc("check_rate_limit", {
294
+ p_identifier: identifier,
295
+ p_action: action,
296
+ p_project_id: this.config.projectId
297
+ });
298
+ if (error) {
299
+ console.warn("BugBear: Rate limit check failed, allowing request", error.message);
300
+ return { allowed: true };
301
+ }
302
+ if (!data.allowed) {
303
+ return {
304
+ allowed: false,
305
+ error: `Rate limit exceeded. Try again in ${Math.ceil((new Date(data.reset_at).getTime() - Date.now()) / 1e3)} seconds.`,
306
+ remaining: 0,
307
+ resetAt: data.reset_at
308
+ };
309
+ }
310
+ return {
311
+ allowed: true,
312
+ remaining: data.remaining,
313
+ resetAt: data.reset_at
314
+ };
315
+ } catch (err) {
316
+ console.warn("BugBear: Rate limit check error", err);
317
+ return { allowed: true };
318
+ }
319
+ }
188
320
  /**
189
321
  * Update tester profile
190
322
  * Allows testers to update their name, additional emails, avatar, and platforms
191
323
  */
192
324
  async updateTesterProfile(updates) {
193
325
  try {
326
+ const validationError = this.validateProfileUpdate(updates);
327
+ if (validationError) {
328
+ return { success: false, error: validationError };
329
+ }
194
330
  const userInfo = await this.getCurrentUserInfo();
195
331
  if (!userInfo) {
196
332
  return { success: false, error: "Not authenticated" };
197
333
  }
334
+ const rateLimit = await this.checkRateLimit(userInfo.email, "profile_update");
335
+ if (!rateLimit.allowed) {
336
+ return { success: false, error: rateLimit.error };
337
+ }
338
+ const testerInfo = await this.getTesterInfo();
339
+ if (!testerInfo) {
340
+ return { success: false, error: "Not a registered tester" };
341
+ }
198
342
  const updateData = {};
199
343
  if (updates.name !== void 0) updateData.name = updates.name;
200
344
  if (updates.additionalEmails !== void 0) updateData.additional_emails = updates.additionalEmails;
201
345
  if (updates.avatarUrl !== void 0) updateData.avatar_url = updates.avatarUrl;
202
346
  if (updates.platforms !== void 0) updateData.platforms = updates.platforms;
203
- const { error } = await this.supabase.from("testers").update(updateData).eq("project_id", this.config.projectId).eq("email", userInfo.email);
347
+ const { error } = await this.supabase.from("testers").update(updateData).eq("id", testerInfo.id);
204
348
  if (error) {
205
349
  console.error("BugBear: updateTesterProfile error", error);
206
350
  return { success: false, error: error.message };
@@ -476,6 +620,11 @@ var BugBearClient = class {
476
620
  console.error("BugBear: No tester info, cannot send message");
477
621
  return false;
478
622
  }
623
+ const rateLimit = await this.checkRateLimit(testerInfo.email, "message_send");
624
+ if (!rateLimit.allowed) {
625
+ console.error("BugBear: Rate limit exceeded for messages");
626
+ return false;
627
+ }
479
628
  const { error } = await this.supabase.from("discussion_messages").insert({
480
629
  thread_id: threadId,
481
630
  sender_type: "tester",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bbearai/core",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
4
4
  "description": "Core utilities and types for BugBear QA platform",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",