@bbearai/core 0.1.5 → 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
@@ -217,9 +217,24 @@ interface TesterInfo {
217
217
  id: string;
218
218
  name: string;
219
219
  email: string;
220
+ /** Additional email addresses for testing on different accounts */
221
+ additionalEmails: string[];
222
+ /** URL to profile photo/avatar */
223
+ avatarUrl?: string;
224
+ /** Testing platforms (ios, android, web) */
225
+ platforms: string[];
220
226
  assignedTests: number;
221
227
  completedTests: number;
222
228
  }
229
+ interface TesterProfileUpdate {
230
+ name?: string;
231
+ /** Additional email addresses for testing */
232
+ additionalEmails?: string[];
233
+ /** URL to profile photo/avatar */
234
+ avatarUrl?: string;
235
+ /** Testing platforms */
236
+ platforms?: string[];
237
+ }
223
238
  type ThreadType = 'announcement' | 'direct' | 'report' | 'general_note';
224
239
  type ThreadPriority = 'low' | 'normal' | 'high' | 'urgent';
225
240
  type MessageSenderType = 'admin' | 'tester';
@@ -248,6 +263,185 @@ interface TesterMessage {
248
263
  name: string;
249
264
  }>;
250
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
+ }
251
445
 
252
446
  /**
253
447
  * BugBear Client
@@ -289,8 +483,37 @@ declare class BugBearClient {
289
483
  /**
290
484
  * Get current tester info
291
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
292
488
  */
293
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;
509
+ /**
510
+ * Update tester profile
511
+ * Allows testers to update their name, additional emails, avatar, and platforms
512
+ */
513
+ updateTesterProfile(updates: TesterProfileUpdate): Promise<{
514
+ success: boolean;
515
+ error?: string;
516
+ }>;
294
517
  /**
295
518
  * Check if current user is a tester for this project
296
519
  */
@@ -444,4 +667,4 @@ declare function captureError(error: Error, errorInfo?: {
444
667
  componentStack?: string;
445
668
  };
446
669
 
447
- 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 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
@@ -217,9 +217,24 @@ interface TesterInfo {
217
217
  id: string;
218
218
  name: string;
219
219
  email: string;
220
+ /** Additional email addresses for testing on different accounts */
221
+ additionalEmails: string[];
222
+ /** URL to profile photo/avatar */
223
+ avatarUrl?: string;
224
+ /** Testing platforms (ios, android, web) */
225
+ platforms: string[];
220
226
  assignedTests: number;
221
227
  completedTests: number;
222
228
  }
229
+ interface TesterProfileUpdate {
230
+ name?: string;
231
+ /** Additional email addresses for testing */
232
+ additionalEmails?: string[];
233
+ /** URL to profile photo/avatar */
234
+ avatarUrl?: string;
235
+ /** Testing platforms */
236
+ platforms?: string[];
237
+ }
223
238
  type ThreadType = 'announcement' | 'direct' | 'report' | 'general_note';
224
239
  type ThreadPriority = 'low' | 'normal' | 'high' | 'urgent';
225
240
  type MessageSenderType = 'admin' | 'tester';
@@ -248,6 +263,185 @@ interface TesterMessage {
248
263
  name: string;
249
264
  }>;
250
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
+ }
251
445
 
252
446
  /**
253
447
  * BugBear Client
@@ -289,8 +483,37 @@ declare class BugBearClient {
289
483
  /**
290
484
  * Get current tester info
291
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
292
488
  */
293
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;
509
+ /**
510
+ * Update tester profile
511
+ * Allows testers to update their name, additional emails, avatar, and platforms
512
+ */
513
+ updateTesterProfile(updates: TesterProfileUpdate): Promise<{
514
+ success: boolean;
515
+ error?: string;
516
+ }>;
294
517
  /**
295
518
  * Check if current user is a tester for this project
296
519
  */
@@ -444,4 +667,4 @@ declare function captureError(error: Error, errorInfo?: {
444
667
  componentStack?: string;
445
668
  };
446
669
 
447
- 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 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,25 +201,189 @@ 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
- assignedTests: data.assigned_count || 0,
207
- 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
208
230
  };
209
231
  } catch (err) {
210
232
  console.error("BugBear: getTesterInfo error", err);
211
233
  return null;
212
234
  }
213
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
+ }
349
+ /**
350
+ * Update tester profile
351
+ * Allows testers to update their name, additional emails, avatar, and platforms
352
+ */
353
+ async updateTesterProfile(updates) {
354
+ try {
355
+ const validationError = this.validateProfileUpdate(updates);
356
+ if (validationError) {
357
+ return { success: false, error: validationError };
358
+ }
359
+ const userInfo = await this.getCurrentUserInfo();
360
+ if (!userInfo) {
361
+ return { success: false, error: "Not authenticated" };
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
+ }
371
+ const updateData = {};
372
+ if (updates.name !== void 0) updateData.name = updates.name;
373
+ if (updates.additionalEmails !== void 0) updateData.additional_emails = updates.additionalEmails;
374
+ if (updates.avatarUrl !== void 0) updateData.avatar_url = updates.avatarUrl;
375
+ if (updates.platforms !== void 0) updateData.platforms = updates.platforms;
376
+ const { error } = await this.supabase.from("testers").update(updateData).eq("id", testerInfo.id);
377
+ if (error) {
378
+ console.error("BugBear: updateTesterProfile error", error);
379
+ return { success: false, error: error.message };
380
+ }
381
+ return { success: true };
382
+ } catch (err) {
383
+ console.error("BugBear: updateTesterProfile error", err);
384
+ return { success: false, error: "Failed to update profile" };
385
+ }
386
+ }
214
387
  /**
215
388
  * Check if current user is a tester for this project
216
389
  */
@@ -476,6 +649,11 @@ var BugBearClient = class {
476
649
  console.error("BugBear: No tester info, cannot send message");
477
650
  return false;
478
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
+ }
479
657
  const { error } = await this.supabase.from("discussion_messages").insert({
480
658
  thread_id: threadId,
481
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,25 +172,189 @@ 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
- assignedTests: data.assigned_count || 0,
178
- 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
179
201
  };
180
202
  } catch (err) {
181
203
  console.error("BugBear: getTesterInfo error", err);
182
204
  return null;
183
205
  }
184
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
+ }
320
+ /**
321
+ * Update tester profile
322
+ * Allows testers to update their name, additional emails, avatar, and platforms
323
+ */
324
+ async updateTesterProfile(updates) {
325
+ try {
326
+ const validationError = this.validateProfileUpdate(updates);
327
+ if (validationError) {
328
+ return { success: false, error: validationError };
329
+ }
330
+ const userInfo = await this.getCurrentUserInfo();
331
+ if (!userInfo) {
332
+ return { success: false, error: "Not authenticated" };
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
+ }
342
+ const updateData = {};
343
+ if (updates.name !== void 0) updateData.name = updates.name;
344
+ if (updates.additionalEmails !== void 0) updateData.additional_emails = updates.additionalEmails;
345
+ if (updates.avatarUrl !== void 0) updateData.avatar_url = updates.avatarUrl;
346
+ if (updates.platforms !== void 0) updateData.platforms = updates.platforms;
347
+ const { error } = await this.supabase.from("testers").update(updateData).eq("id", testerInfo.id);
348
+ if (error) {
349
+ console.error("BugBear: updateTesterProfile error", error);
350
+ return { success: false, error: error.message };
351
+ }
352
+ return { success: true };
353
+ } catch (err) {
354
+ console.error("BugBear: updateTesterProfile error", err);
355
+ return { success: false, error: "Failed to update profile" };
356
+ }
357
+ }
185
358
  /**
186
359
  * Check if current user is a tester for this project
187
360
  */
@@ -447,6 +620,11 @@ var BugBearClient = class {
447
620
  console.error("BugBear: No tester info, cannot send message");
448
621
  return false;
449
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
+ }
450
628
  const { error } = await this.supabase.from("discussion_messages").insert({
451
629
  thread_id: threadId,
452
630
  sender_type: "tester",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bbearai/core",
3
- "version": "0.1.5",
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",