Conversation
Add extractHeaders() utility function to normalize request header extraction across authentication flows. Handles both Headers object and plain object formats, reducing code duplication by ~100 lines. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Fix silent email failures by adding comprehensive breadcrumbs and proper error checking throughout email sending flows. - Add breadcrumbs at email service level (attempt, API call, result) - Change Better Auth callbacks from .catch() to .then() to check emailResult.success (email service returns errors, doesn't throw) - Add breadcrumbs for verification, welcome, and password reset emails - Add Sentry capture for settings check failures - Ensures all email failures are tracked for debugging 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Add complete tracking and security logging throughout authentication endpoints to prevent silent failures and enable monitoring. Login flow enhancements: - Wrap in Sentry span for performance tracking - Add breadcrumbs for login attempts and results - Add security audit logging for successful and failed logins - Add metrics for monitoring login success rates and duration - Use extractHeaders() utility for consistency Password operation security: - Add security audit logging to password change endpoint - Query verification token before reset to capture userId - Add security audit logging to password reset completion - Non-blocking audit logging to prevent operation failures All audit logging wrapped in try/catch to ensure authentication operations succeed even if logging fails. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Wrap verification and welcome email operations in Sentry spans to track success/failure even though emails are sent in a fire-and-forget pattern. - Add span tracking for verification email with status codes - Add span tracking for welcome email with status codes - Track email success/failure as span attributes - Maintain existing breadcrumbs for debugging - Preserve fire-and-forget behavior (no await on email send) This provides visibility into email delivery without blocking user registration/verification flows. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Add comprehensive Sentry span tracking around batch database operations to monitor performance and catch failures. - Wrap mark articles read/unread batch in Sentry span - Wrap mark all articles read batch in Sentry span - Track batch size, operation type, and user ID as attributes - Capture exceptions with full context (batch size, user, article count) - Set appropriate span status codes for success/failure Batch operations are critical for user experience - this ensures we're alerted to failures and can identify performance patterns. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Add Sentry span tracking for OpenGraph image extraction with metrics emission for pattern analysis. - Wrap OG image fetch in Sentry span with domain tracking - Track success (found/not found) and failures separately - Emit metrics counters for error patterns (by domain and error type) - Set appropriate span status for found/not found/error cases - Avoid spamming Sentry with every failure (use metrics instead) This allows us to identify problematic domains and error patterns without creating excessive noise in Sentry. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Implement automatic retry for transient feed fetch failures and add full transaction monitoring for OPML import operations. Subscription Creation & Preview Enhancements: - Add automatic retry logic for HTTP 502, 503, 504, 429 errors - Implement exponential backoff (1s, 2s delays) with max 2 retries - Track retry attempts in Sentry breadcrumbs and error context - Enhanced error tagging with domain and HTTP status codes - Track attempt count and last status code for debugging OPML Import Transaction Monitoring: - Wrap entire import in Sentry transaction span - Create individual spans for each feed import - Track feed-level attributes (URL, title, domain, filters, categories) - Calculate and report success rate as span attribute - Proper error boundaries with per-feed exception capture - Handle limit reached and already subscribed cases gracefully - Fix loop control flow to work within Sentry span callbacks Benefits: - Improved resilience against transient failures (network, server) - Clear visibility into which feeds fail and why during bulk imports - Domain-based error pattern analysis for subscription issues - Success rate tracking for OPML import operations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
|
@sentry review |
| { | ||
| name: "auth.login", | ||
| op: "auth.signin", | ||
| attributes: { | ||
| "auth.method": "username_password", |
There was a problem hiding this comment.
The code uses await Sentry.startSpan() syntax on line 426, but Sentry's startSpan is not an async function and should not be awaited. The Sentry.startSpan() signature takes a callback that can be async, but the startSpan call itself is synchronous. The correct pattern is return Sentry.startSpan({...}, async (span) => {...}) without awaiting the startSpan call.
Severity: HIGH
🤖 Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.
Location: packages/api/src/routers/auth.ts#L426-L430
Potential issue: The code uses `await Sentry.startSpan()` syntax on line 426, but
Sentry's startSpan is not an async function and should not be awaited. The
`Sentry.startSpan()` signature takes a callback that can be async, but the startSpan
call itself is synchronous. The correct pattern is `return Sentry.startSpan({...}, async
(span) => {...})` without awaiting the startSpan call.
Did we get this right? 👍 / 👎 to inform future reviews.
Reference ID: 5366126
| error instanceof Error ? error.message : "Unknown error"; | ||
| const errorStack = error instanceof Error ? error.stack : undefined; | ||
|
|
||
| // Add breadcrumb for exception | ||
| await Sentry.addBreadcrumb({ | ||
| category: "email.service", | ||
| message: "Exception thrown while sending email", | ||
| level: "error", | ||
| data: { | ||
| emailType: type, | ||
| recipient: to, | ||
| errorMessage, | ||
| errorType: error instanceof Error ? error.name : "Unknown", | ||
| }, | ||
| }); | ||
|
|
||
| console.error(`Error sending ${type} email:`, { |
There was a problem hiding this comment.
Unnecessary awaits on non-async breadcrumb calls. Lines 121-130, 142-152, 222-230, and 237-245 all contain await Sentry.addBreadcrumb(...). However, Sentry.addBreadcrumb() is a synchronous function and should not be awaited. Removing these awaits will improve performance and align with Sentry API usage patterns.
Severity: MEDIUM
🤖 Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.
Location: packages/api/src/services/email.ts#L121-L245
Potential issue: Unnecessary awaits on non-async breadcrumb calls. Lines 121-130,
142-152, 222-230, and 237-245 all contain `await Sentry.addBreadcrumb(...)`. However,
`Sentry.addBreadcrumb()` is a synchronous function and should not be awaited. Removing
these awaits will improve performance and align with Sentry API usage patterns.
Did we get this right? 👍 / 👎 to inform future reviews.
Reference ID: 5366126
| body: { | ||
| username: input.username, | ||
| password: input.password, | ||
| }, | ||
| headers: authHeaders, | ||
| }); | ||
|
|
||
| if (!dbUser) { | ||
| throw new TRPCError({ | ||
| code: "UNAUTHORIZED", | ||
| message: "User not found", | ||
| }); | ||
| } | ||
| if (!result || !result.user) { | ||
| throw new TRPCError({ |
There was a problem hiding this comment.
On line 468, parentSpan?.setAttributes() is called but the previous pattern throughout the codebase uses parentSpan?.setAttribute() (singular). This inconsistency may indicate a typo. Verify the correct Sentry API method name - it should likely be setAttributes() for setting multiple attributes at once, but the singular form is used elsewhere in the codebase.
Severity: MEDIUM
🤖 Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.
Location: packages/api/src/routers/auth.ts#L468-L476
Potential issue: On line 468, `parentSpan?.setAttributes()` is called but the previous
pattern throughout the codebase uses `parentSpan?.setAttribute()` (singular). This
inconsistency may indicate a typo. Verify the correct Sentry API method name - it should
likely be `setAttributes()` for setting multiple attributes at once, but the singular
form is used elsewhere in the codebase.
Did we get this right? 👍 / 👎 to inform future reviews.
Reference ID: 5366126
|
|
||
| // Check if source exists | ||
| // Normalize Reddit URLs to prevent duplicates across different domains | ||
| const normalizedFeedUrl = normalizeRedditUrl(feedInfo.url); | ||
|
|
||
| const existingSources = await ctx.db | ||
| .select() |
There was a problem hiding this comment.
In the OPML import span loop (lines 1879-2099), the loop uses for (const feedInfo of feedsToProcess) with a shouldStopImporting flag that gets set to true when the limit is reached. However, the shouldStopImporting variable is declared inside the Sentry.startSpan callback scope on line 1834, but it's set inside nested Sentry.startSpan callbacks (line 1932). This creates a scope issue where the outer loop's break condition might not work correctly. The variable should be declared in the outer scope before the loop.
Severity: HIGH
🤖 Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.
Location: packages/api/src/routers/subscriptions.ts#L1834-L1840
Potential issue: In the OPML import span loop (lines 1879-2099), the loop uses `for
(const feedInfo of feedsToProcess)` with a `shouldStopImporting` flag that gets set to
true when the limit is reached. However, the `shouldStopImporting` variable is declared
inside the `Sentry.startSpan` callback scope on line 1834, but it's set inside nested
`Sentry.startSpan` callbacks (line 1932). This creates a scope issue where the outer
loop's break condition might not work correctly. The variable should be declared in the
outer scope before the loop.
Did we get this right? 👍 / 👎 to inform future reviews.
Reference ID: 5366126
There was a problem hiding this comment.
Pull request overview
This pull request enhances observability, security auditing, and reliability across authentication and subscription-related API endpoints by integrating Sentry instrumentation, implementing security event logging, and adding retry logic for transient failures.
Key Changes:
- Added comprehensive Sentry spans and breadcrumbs across authentication flows (login, password reset, password change) and article operations for better observability and performance tracking
- Integrated security audit logging for authentication events (successful/failed logins, password changes, resets) with IP address and user agent tracking
- Implemented exponential backoff retry logic for transient HTTP errors (502, 503, 504, 429) when fetching RSS feeds
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/api/src/auth/security.ts | Added extractHeaders utility function to normalize request headers from different formats (Headers object vs plain object) |
| packages/api/src/routers/auth.ts | Wrapped login endpoint in Sentry span with detailed tracking; added security audit logging for login, password change, and password reset events with comprehensive error handling |
| packages/api/src/auth/better-auth.ts | Enhanced email sending callbacks with Sentry spans and breadcrumbs; refactored header extraction to use new utility function for consistency |
| packages/api/src/routers/subscriptions.ts | Implemented retry logic with exponential backoff for feed fetching in both create and preview endpoints; added Sentry instrumentation for OPML import with per-feed tracking |
| packages/api/src/routers/articles.ts | Wrapped batch operations for marking articles read/unread in Sentry spans for performance monitoring and error tracking |
| packages/api/src/services/rss-fetcher.ts | Added Sentry span around OpenGraph image extraction with detailed error tracking and metrics emission |
| packages/api/src/services/email.ts | Enhanced email service with additional breadcrumbs at key points (before API call, on success, on error) for better debugging |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Check if we should retry this error | ||
| const errorMessage = lastError.message; | ||
| const httpStatusMatch = errorMessage.match(/HTTP (\d+):/); | ||
| const httpStatus = httpStatusMatch ? parseInt(httpStatusMatch[1]) : 0; | ||
|
|
||
| if (!TRANSIENT_STATUS_CODES.includes(httpStatus)) { | ||
| // Non-transient error - don't retry | ||
| await Sentry.captureException(lastError, { | ||
| level: "error", | ||
| tags: { | ||
| operation: "subscription_preview_parse", | ||
| domain: domain || "unknown", | ||
| http_status: httpStatus.toString(), | ||
| attempts: (attempt + 1).toString(), | ||
| }, | ||
| extra: { | ||
| url: input.url, | ||
| error_message: errorMessage, | ||
| }, | ||
| }); | ||
|
|
||
| throw new TRPCError({ | ||
| code: "BAD_REQUEST", | ||
| message: `Failed to fetch or parse feed: ${errorMessage}`, | ||
| }); | ||
| } |
There was a problem hiding this comment.
The retry logic has the same issue as in the create endpoint: when parseFeed() throws an error (e.g., invalid feed XML), the error message won't match the regex /HTTP (\d+):/, so httpStatus will be 0. Since 0 is not in TRANSIENT_STATUS_CODES, the code will throw immediately without retrying. However, this also means that even if the fetch succeeded but parsing failed, we're still inside the retry loop. The parsing errors should not be retried - only fetch errors should be. Consider checking if the error occurred during fetch vs parse, and only apply retry logic to fetch errors.
packages/api/src/routers/auth.ts
Outdated
| try { | ||
| await logSecurityEvent(ctx.db, { | ||
| userId, | ||
| action: "password_reset_success", |
There was a problem hiding this comment.
The action logged for a failed password reset should be "password_reset_failed" but it's currently set to "password_reset_success". This will cause failed password reset attempts to be logged as successful, which is incorrect and could lead to confusion in security auditing.
| action: "password_reset_success", | |
| action: "password_reset_failed", |
packages/api/src/auth/security.ts
Outdated
| headers[key.toLowerCase()] = Array.isArray(value) | ||
| ? value[0] | ||
| : String(value); |
There was a problem hiding this comment.
When value is undefined, calling String(value) will convert it to the string "undefined" instead of keeping it as undefined. This could lead to unexpected behavior where headers with undefined values are treated as having the literal string "undefined" as their value. Consider checking for undefined explicitly: value === undefined ? undefined : (Array.isArray(value) ? value[0] : String(value)).
| headers[key.toLowerCase()] = Array.isArray(value) | |
| ? value[0] | |
| : String(value); | |
| headers[key.toLowerCase()] = value === undefined | |
| ? undefined | |
| : Array.isArray(value) | |
| ? value[0] | |
| : String(value); |
| // Check if we should retry this error | ||
| const errorMessage = lastError.message; | ||
| const httpStatusMatch = errorMessage.match(/HTTP (\d+):/); | ||
| const httpStatus = httpStatusMatch ? parseInt(httpStatusMatch[1]) : 0; | ||
|
|
||
| if (!TRANSIENT_STATUS_CODES.includes(httpStatus)) { | ||
| // Non-transient error - don't retry | ||
| await Sentry.captureException(lastError, { | ||
| level: "error", | ||
| tags: { | ||
| operation: "subscription_feed_parse", | ||
| domain: domain || "unknown", | ||
| http_status: httpStatus.toString(), | ||
| attempts: (attempt + 1).toString(), | ||
| }, | ||
| extra: { | ||
| url: input.url, | ||
| user_id: userId, | ||
| error_message: errorMessage, | ||
| }, | ||
| }); | ||
|
|
||
| throw new TRPCError({ | ||
| code: "BAD_REQUEST", | ||
| message: `Failed to fetch or parse feed: ${errorMessage}`, | ||
| }); | ||
| } |
There was a problem hiding this comment.
The retry logic has a potential issue: when parseFeed() throws an error (e.g., invalid feed XML), the error message won't match the regex /HTTP (\d+):/, so httpStatus will be 0. Since 0 is not in TRANSIENT_STATUS_CODES, the code will throw immediately without retrying. However, this also means that even if the fetch succeeded but parsing failed, we're still inside the retry loop. The parsing errors should not be retried - only fetch errors should be. Consider checking if the error occurred during fetch vs parse, and only apply retry logic to fetch errors.
| TRANSIENT_STATUS_CODES.includes(response.status) && | ||
| attempt < MAX_RETRIES | ||
| ) { | ||
| lastError = new Error(errorMessage); |
There was a problem hiding this comment.
The value assigned to lastError here is unused.
| lastError = new Error(errorMessage); |
| TRANSIENT_STATUS_CODES.includes(response.status) && | ||
| attempt < MAX_RETRIES | ||
| ) { | ||
| lastError = new Error(errorMessage); |
There was a problem hiding this comment.
The value assigned to lastError here is unused.
| lastError = new Error(errorMessage); |
Fix critical bugs identified in code review: 1. Security Audit Logging (HIGH): - Fix incorrect action type for failed password resets - Changed "password_reset_success" to "password_reset_failed" in error handler - Added missing "password_reset_failed" to SecurityAction type 2. Retry Logic Separation (HIGH): - Separate fetch errors from parse errors in retry logic - Parse errors now throw immediately without retry - Only HTTP fetch errors (502, 503, 504, 429) are retried - Improves error classification: "fetch_error" vs "parse_error" - Applied to both create and preview endpoints 3. Header Extraction (MEDIUM): - Fix String(undefined) converting undefined to "undefined" string - Preserve undefined values correctly in header extraction - Prevents confusion where missing headers appear as "undefined" 4. Code Cleanup (LOW): - Remove unused lastError assignments in retry loops - Assignments were made but value never used before continue All changes verified with 819 passing tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Add explicit paths for packages/api/wrangler.toml files since root-level patterns don't match subdirectories 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Admin dashboard was missing emailVerified field, causing unverified users to appear as "active" when they weren't. This adds: - emailVerified field to AdminUserSchema - emailVerified filter option for queries - Metrics tracking for when filter is used Fixes user status discrepancy where unverified users showed as active 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Security audit logging was completely non-functional due to missing SQL default on created_at column. This caused silent failures: Root cause: - security_audit_log.created_at was NOT NULL without SQL DEFAULT - Drizzle's $defaultFn() only works at app level, not in database - logSecurityEvent() didn't provide createdAt value - All insert attempts failed silently (caught and logged to console) - Result: Zero audit logs ever written to production Fix (3-part): 1. Quick fix: logSecurityEvent() now explicitly provides createdAt 2. Schema fix: Changed to SQL DEFAULT using unixepoch() 3. Migration: 0006_fix_security_audit_log_default.sql adds DEFAULT to DB Impact: - Enables tracking of login attempts, password resets, registrations - Critical for security monitoring and compliance - Consistent with other tables using same timestamp pattern 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
While .claude/ directory is gitignored, settings.json should be committed to share team-wide Claude Code configurations like security policies and approved tool permissions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Add shared Claude Code configuration with: - Security deny rules for .env, secrets, production operations - Broader allow patterns for common workflows - Git co-authoring enabled - MCP server integrations (Sentry, Better Auth, Shadcn) Establishes team-wide safety guardrails and tool permissions for AI-assisted development 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 15 changed files in this pull request and generated 8 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
packages/api/src/routers/auth.ts
Outdated
| const headers = extractHeaders(ctx.req.headers); | ||
| const ipAddress = getClientIp(headers); | ||
| const userAgent = getUserAgent(headers); | ||
|
|
||
| try { | ||
| await logSecurityEvent(ctx.db, { | ||
| userId, | ||
| action: "login_success", | ||
| ipAddress, | ||
| userAgent, | ||
| success: true, | ||
| }); | ||
| } catch (auditError) { |
There was a problem hiding this comment.
Header extraction and IP/user agent retrieval logic (lines 507-509) is duplicated at lines 608-610. Similarly, this pattern is repeated at lines 867-868, 884-886, 1061-1062, and 1078-1080. Consider extracting this into a helper function to reduce duplication and improve maintainability.
| const headers = extractHeaders(ctx.req.headers); | |
| const ipAddress = getClientIp(headers); | |
| const userAgent = getUserAgent(headers); | |
| try { | |
| await logSecurityEvent(ctx.db, { | |
| userId, | |
| action: "login_success", | |
| ipAddress, | |
| userAgent, | |
| success: true, | |
| }); | |
| } catch (auditError) { | |
| const { headers, ipAddress, userAgent } = getRequestClientInfo(ctx.req.headers, { getClientIp, getUserAgent, extractHeaders }); |
| Sentry.startSpan( | ||
| { | ||
| op: "email.verification", | ||
| name: "Send Verification Email (Fire-and-Forget)", | ||
| attributes: { | ||
| user_id: user.id, | ||
| user_email: user.email, | ||
| fire_and_forget: true, | ||
| }, | ||
| extra: { | ||
| userEmail: user.email, | ||
| userId: user.id, | ||
| }, | ||
| level: "error", | ||
| }); | ||
| }, | ||
| async (span) => { | ||
| // Add breadcrumb for email sending attempt | ||
| await Sentry.addBreadcrumb({ | ||
| category: "email", | ||
| message: "Sending verification email", | ||
| level: "info", | ||
| data: { | ||
| userEmail: user.email, | ||
| userId: user.id, | ||
| emailType: "verification", | ||
| }, | ||
| }); | ||
|
|
||
| console.error("Failed to send verification email:", { | ||
| userEmail: user.email, | ||
| userId: user.id, | ||
| error: error instanceof Error ? error.message : String(error), | ||
| }); | ||
| }); | ||
| sendVerificationEmail(env, { | ||
| to: user.email, | ||
| username: | ||
| (userWithPlugins.username as string | undefined) || | ||
| user.name || | ||
| "User", | ||
| verificationToken: token, | ||
| verificationUrl: frontendVerificationUrl, // Frontend URL instead of backend URL | ||
| }) | ||
| .then(async (emailResult) => { | ||
| // Track result in span | ||
| span?.setAttribute("email.success", emailResult.success); | ||
|
|
||
| // Check if email sending failed | ||
| if (!emailResult.success) { | ||
| span?.setAttribute( | ||
| "email.error", | ||
| emailResult.error || "Unknown error" | ||
| ); | ||
| span?.setStatus({ code: 2, message: "email failed" }); | ||
|
|
||
| // Add breadcrumb for failure | ||
| await Sentry.addBreadcrumb({ | ||
| category: "email", | ||
| message: "Verification email failed", | ||
| level: "error", | ||
| data: { | ||
| userEmail: user.email, | ||
| userId: user.id, | ||
| error: emailResult.error, | ||
| emailType: "verification", | ||
| }, | ||
| }); | ||
|
|
||
| // Log critical email failures to Sentry | ||
| await Sentry.captureException( | ||
| new Error( | ||
| emailResult.error || | ||
| "Failed to send verification email" | ||
| ), | ||
| { | ||
| tags: { | ||
| component: "better-auth", | ||
| operation: "email-verification", | ||
| email_type: "verification", | ||
| }, | ||
| extra: { | ||
| userEmail: user.email, | ||
| userId: user.id, | ||
| errorMessage: emailResult.error, | ||
| }, | ||
| level: "error", | ||
| } | ||
| ); | ||
|
|
||
| console.error("Failed to send verification email:", { | ||
| userEmail: user.email, | ||
| userId: user.id, | ||
| error: emailResult.error, | ||
| }); | ||
| } else { | ||
| span?.setStatus({ code: 1, message: "ok" }); | ||
|
|
||
| // Add breadcrumb for success | ||
| await Sentry.addBreadcrumb({ | ||
| category: "email", | ||
| message: "Verification email sent successfully", | ||
| level: "info", | ||
| data: { | ||
| userEmail: user.email, | ||
| userId: user.id, | ||
| emailType: "verification", | ||
| }, | ||
| }); | ||
| } | ||
| }) | ||
| .catch(async (error) => { | ||
| span?.setAttribute( | ||
| "email.exception", | ||
| error instanceof Error ? error.message : String(error) | ||
| ); | ||
| span?.setStatus({ code: 2, message: "exception" }); | ||
|
|
||
| // Add breadcrumb for unexpected error | ||
| await Sentry.addBreadcrumb({ | ||
| category: "email", | ||
| message: "Unexpected error sending verification email", | ||
| level: "error", | ||
| data: { | ||
| userEmail: user.email, | ||
| userId: user.id, | ||
| error: | ||
| error instanceof Error | ||
| ? error.message | ||
| : String(error), | ||
| emailType: "verification", | ||
| }, | ||
| }); | ||
|
|
||
| // Log unexpected exceptions (e.g., network errors, timeouts) | ||
| await Sentry.captureException(error, { | ||
| tags: { | ||
| component: "better-auth", | ||
| operation: "email-verification", | ||
| email_type: "verification", | ||
| }, | ||
| extra: { | ||
| userEmail: user.email, | ||
| userId: user.id, | ||
| }, | ||
| level: "error", | ||
| }); | ||
|
|
||
| console.error( | ||
| "Unexpected error sending verification email:", | ||
| { | ||
| userEmail: user.email, | ||
| userId: user.id, | ||
| error: | ||
| error instanceof Error | ||
| ? error.message | ||
| : String(error), | ||
| } | ||
| ); | ||
| }); | ||
| } | ||
| ); |
There was a problem hiding this comment.
The Sentry span for sending verification email is started without awaiting its completion (fire-and-forget pattern). While this is intentional for non-blocking email operations, the span is started but never properly awaited, which means the span tracking may not complete before the function returns. Consider using Sentry.startSpan(...).then(...) pattern or documenting this behavior more clearly to avoid confusion about incomplete spans.
| for (const feedInfo of feedsToProcess) { | ||
| if (shouldStopImporting) { | ||
| break; // Stop processing remaining feeds if limit reached | ||
| } | ||
|
|
||
| if (existingSubscription.length > 0) { | ||
| // Already subscribed, skip | ||
| successCount++; | ||
| continue; | ||
| } | ||
| // Create a span for each feed import | ||
| await Sentry.startSpan( | ||
| { | ||
| op: "opml.import_feed", | ||
| name: `Import Feed: ${feedInfo.title}`, | ||
| attributes: { | ||
| "feed.url": feedInfo.url, | ||
| "feed.title": feedInfo.title, | ||
| "feed.domain": extractDomain(feedInfo.url) || "unknown", | ||
| "feed.has_filters": feedInfo.filters | ||
| ? feedInfo.filters.length > 0 | ||
| : false, | ||
| "feed.categories_count": feedInfo.categories.length, | ||
| }, | ||
| }, | ||
| async (feedSpan) => { | ||
| try { | ||
| // Fetch and validate feed | ||
| const response = await fetch(feedInfo.url, { | ||
| headers: { | ||
| "User-Agent": "TuvixRSS/1.0", | ||
| Accept: | ||
| "application/rss+xml, application/atom+xml, application/xml, text/xml, */*", | ||
| }, | ||
| signal: AbortSignal.timeout(15000), | ||
| }); | ||
|
|
||
| if (!response.ok) { | ||
| throw new Error(`HTTP ${response.status}`); | ||
| } | ||
|
|
||
| // Check source limit before creating subscription | ||
| const limitCheck = await checkSourceLimit(ctx.db, userId); | ||
| if (!limitCheck.allowed) { | ||
| // Limit reached, stop importing | ||
| errorCount++; | ||
| errors.push({ | ||
| url: feedInfo.url, | ||
| error: `Source limit reached (${limitCheck.limit}/${limitCheck.limit}). Remaining feeds not imported.`, | ||
| }); | ||
| break; // Stop processing remaining feeds | ||
| } | ||
| const feedContent = await response.text(); | ||
| const feedResult = parseFeed(feedContent); | ||
| const feedData = feedResult.feed; | ||
|
|
||
| // Extract metadata | ||
| const feedTitle = | ||
| "title" in feedData && feedData.title | ||
| ? feedData.title | ||
| : feedInfo.title; | ||
| const feedDescription = | ||
| "description" in feedData && feedData.description | ||
| ? stripHtml(feedData.description) | ||
| : "subtitle" in feedData && feedData.subtitle | ||
| ? stripHtml(feedData.subtitle) | ||
| : undefined; | ||
| const siteUrl = | ||
| "link" in feedData && feedData.link | ||
| ? feedData.link | ||
| : "links" in feedData && | ||
| Array.isArray(feedData.links) && | ||
| feedData.links[0]?.href | ||
| ? feedData.links[0].href | ||
| : undefined; | ||
|
|
||
| // Check if source exists | ||
| // Normalize Reddit URLs to prevent duplicates across different domains | ||
| const normalizedFeedUrl = normalizeRedditUrl(feedInfo.url); | ||
|
|
||
| const existingSources = await ctx.db | ||
| .select() | ||
| .from(schema.sources) | ||
| .where(eq(schema.sources.url, normalizedFeedUrl)) | ||
| .limit(1); | ||
|
|
||
| let sourceId: number; | ||
|
|
||
| if (existingSources.length > 0) { | ||
| sourceId = existingSources[0].id; | ||
| await ctx.db | ||
| .update(schema.sources) | ||
| .set({ | ||
| title: feedTitle, | ||
| description: feedDescription, | ||
| siteUrl, | ||
| lastFetched: new Date(), | ||
| }) | ||
| .where(eq(schema.sources.id, sourceId)); | ||
| } else { | ||
| const newSource = await ctx.db | ||
| .insert(schema.sources) | ||
| .values({ | ||
| url: normalizedFeedUrl, | ||
| title: feedTitle, | ||
| description: feedDescription, | ||
| siteUrl, | ||
| iconType: "auto", | ||
| lastFetched: new Date(), | ||
| }) | ||
| .returning(); | ||
| sourceId = newSource[0].id; | ||
| } | ||
|
|
||
| // Determine filter settings from OPML data | ||
| const filterEnabled = | ||
| feedInfo.filterEnabled !== undefined | ||
| ? feedInfo.filterEnabled | ||
| : feedInfo.filters && feedInfo.filters.length > 0 | ||
| ? true | ||
| : false; | ||
| const filterMode = feedInfo.filterMode || "include"; | ||
|
|
||
| // Create subscription | ||
| const newSubscription = await ctx.db | ||
| .insert(schema.subscriptions) | ||
| .values({ | ||
| userId, | ||
| sourceId, | ||
| customTitle: null, | ||
| filterEnabled, | ||
| filterMode, | ||
| }) | ||
| .returning(); | ||
|
|
||
| const subscriptionId = newSubscription[0].id; | ||
|
|
||
| // Create/link categories (using normalization helper to prevent duplicates) | ||
| if (feedInfo.categories.length > 0) { | ||
| // Track category IDs to prevent duplicate links | ||
| const linkedCategoryIds = new Set<number>(); | ||
|
|
||
| for (const categoryName of feedInfo.categories) { | ||
| // Use findOrCreateCategory for case-insensitive normalization | ||
| const categoryId = await findOrCreateCategory( | ||
| ctx.db, | ||
| schema.categories, | ||
| userId, | ||
| categoryName, | ||
| generateColorFromString | ||
| ); | ||
|
|
||
| // Only link if we haven't already linked this category | ||
| if (!linkedCategoryIds.has(categoryId)) { | ||
| linkedCategoryIds.add(categoryId); | ||
| await ctx.db.insert(schema.subscriptionCategories).values({ | ||
| subscriptionId, | ||
| categoryId, | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| // Check if already subscribed | ||
| const existingSubscription = await ctx.db | ||
| .select() | ||
| .from(schema.subscriptions) | ||
| .where( | ||
| and( | ||
| eq(schema.subscriptions.userId, userId), | ||
| eq(schema.subscriptions.sourceId, sourceId) | ||
| ) | ||
| ) | ||
| .limit(1); | ||
|
|
||
| if (existingSubscription.length > 0) { | ||
| // Already subscribed, skip | ||
| successCount++; | ||
|
|
||
| // Mark span as successful (already exists) | ||
| feedSpan.setAttribute("feed.status", "already_exists"); | ||
| feedSpan.setStatus({ | ||
| code: 1, | ||
| message: "already subscribed", | ||
| }); | ||
| return; // Return from span callback (equivalent to continue) | ||
| } | ||
|
|
||
| // Create filters if provided | ||
| if (feedInfo.filters && feedInfo.filters.length > 0) { | ||
| for (const filter of feedInfo.filters) { | ||
| try { | ||
| // Validate regex pattern if matchType is 'regex' | ||
| if (filter.matchType === "regex") { | ||
| try { | ||
| new RegExp(filter.pattern); | ||
| } catch (regexError) { | ||
| // Invalid regex - skip this filter but continue | ||
| // Check source limit before creating subscription | ||
| const limitCheck = await checkSourceLimit(ctx.db, userId); | ||
| if (!limitCheck.allowed) { | ||
| // Limit reached, stop importing | ||
| errorCount++; | ||
| errors.push({ | ||
| url: feedInfo.url, | ||
| error: `Invalid regex pattern in filter: ${regexError instanceof Error ? regexError.message : "Unknown error"}`, | ||
| error: `Source limit reached (${limitCheck.limit}/${limitCheck.limit}). Remaining feeds not imported.`, | ||
| }); | ||
|
|
||
| // Mark span as failed due to limit | ||
| feedSpan.setAttribute("feed.status", "limit_reached"); | ||
| feedSpan.setStatus({ | ||
| code: 2, | ||
| message: "source limit reached", | ||
| }); | ||
| continue; | ||
|
|
||
| shouldStopImporting = true; // Signal to stop processing remaining feeds | ||
| return; // Return from span callback | ||
| } | ||
|
|
||
| // Determine filter settings from OPML data | ||
| const filterEnabled = | ||
| feedInfo.filterEnabled !== undefined | ||
| ? feedInfo.filterEnabled | ||
| : feedInfo.filters && feedInfo.filters.length > 0 | ||
| ? true | ||
| : false; | ||
| const filterMode = feedInfo.filterMode || "include"; | ||
|
|
||
| // Create subscription | ||
| const newSubscription = await ctx.db | ||
| .insert(schema.subscriptions) | ||
| .values({ | ||
| userId, | ||
| sourceId, | ||
| customTitle: null, | ||
| filterEnabled, | ||
| filterMode, | ||
| }) | ||
| .returning(); | ||
|
|
||
| const subscriptionId = newSubscription[0].id; | ||
|
|
||
| // Create/link categories (using normalization helper to prevent duplicates) | ||
| if (feedInfo.categories.length > 0) { | ||
| // Track category IDs to prevent duplicate links | ||
| const linkedCategoryIds = new Set<number>(); | ||
|
|
||
| for (const categoryName of feedInfo.categories) { | ||
| // Use findOrCreateCategory for case-insensitive normalization | ||
| const categoryId = await findOrCreateCategory( | ||
| ctx.db, | ||
| schema.categories, | ||
| userId, | ||
| categoryName, | ||
| generateColorFromString | ||
| ); | ||
|
|
||
| // Only link if we haven't already linked this category | ||
| if (!linkedCategoryIds.has(categoryId)) { | ||
| linkedCategoryIds.add(categoryId); | ||
| await ctx.db | ||
| .insert(schema.subscriptionCategories) | ||
| .values({ | ||
| subscriptionId, | ||
| categoryId, | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Insert filter directly (more efficient than API call) | ||
| await ctx.db.insert(schema.subscriptionFilters).values({ | ||
| subscriptionId, | ||
| field: filter.field, | ||
| matchType: filter.matchType, | ||
| pattern: filter.pattern, | ||
| caseSensitive: filter.caseSensitive, | ||
| }); | ||
| } catch (filterError) { | ||
| // Filter creation failed - log error but continue | ||
| errors.push({ | ||
| url: feedInfo.url, | ||
| error: `Failed to create filter: ${filterError instanceof Error ? filterError.message : "Unknown error"}`, | ||
| }); | ||
| // Continue with next filter | ||
| // Create filters if provided | ||
| if (feedInfo.filters && feedInfo.filters.length > 0) { | ||
| for (const filter of feedInfo.filters) { | ||
| try { | ||
| // Validate regex pattern if matchType is 'regex' | ||
| if (filter.matchType === "regex") { | ||
| try { | ||
| new RegExp(filter.pattern); | ||
| } catch (regexError) { | ||
| // Invalid regex - skip this filter but continue | ||
| errors.push({ | ||
| url: feedInfo.url, | ||
| error: `Invalid regex pattern in filter: ${regexError instanceof Error ? regexError.message : "Unknown error"}`, | ||
| }); | ||
| continue; | ||
| } | ||
| } | ||
|
|
||
| // Insert filter directly (more efficient than API call) | ||
| await ctx.db.insert(schema.subscriptionFilters).values({ | ||
| subscriptionId, | ||
| field: filter.field, | ||
| matchType: filter.matchType, | ||
| pattern: filter.pattern, | ||
| caseSensitive: filter.caseSensitive, | ||
| }); | ||
| } catch (filterError) { | ||
| // Filter creation failed - log error but continue | ||
| errors.push({ | ||
| url: feedInfo.url, | ||
| error: `Failed to create filter: ${filterError instanceof Error ? filterError.message : "Unknown error"}`, | ||
| }); | ||
| // Continue with next filter | ||
| } | ||
| } | ||
| } | ||
|
|
||
| successCount++; | ||
|
|
||
| // Mark span as successful | ||
| feedSpan.setAttribute("feed.status", "success"); | ||
| feedSpan.setStatus({ code: 1, message: "ok" }); | ||
| } catch (error) { | ||
| errorCount++; | ||
| const errorMessage = | ||
| error instanceof Error ? error.message : "Unknown error"; | ||
|
|
||
| errors.push({ | ||
| url: feedInfo.url, | ||
| error: errorMessage, | ||
| }); | ||
|
|
||
| // Mark span as failed | ||
| feedSpan.setAttribute("feed.status", "error"); | ||
| feedSpan.setAttribute("feed.error", errorMessage); | ||
| feedSpan.setStatus({ | ||
| code: 2, | ||
| message: "feed import failed", | ||
| }); | ||
|
|
||
| // Capture exception for this specific feed | ||
| await Sentry.captureException(error, { | ||
| level: "warning", | ||
| tags: { | ||
| operation: "opml_import_feed", | ||
| domain: extractDomain(feedInfo.url) || "unknown", | ||
| }, | ||
| extra: { | ||
| feed_url: feedInfo.url, | ||
| feed_title: feedInfo.title, | ||
| user_id: userId, | ||
| }, | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| ); | ||
| } |
There was a problem hiding this comment.
The OPML import loop processes feeds sequentially and creates a Sentry span for each feed. For large OPML files with many feeds, this could result in a very long operation with many spans. Consider adding a progress indicator or implementing batch processing with concurrent feed fetches (with appropriate rate limiting) to improve performance and user experience.
packages/api/src/auth/security.ts
Outdated
| userAgent: params.userAgent, | ||
| metadata: params.metadata ? JSON.stringify(params.metadata) : undefined, | ||
| success: params.success, | ||
| createdAt: new Date(), |
There was a problem hiding this comment.
The createdAt field is being manually set to new Date() in the logSecurityEvent function, but the database schema now has a SQL-level default for this column. This manual assignment is redundant and could potentially cause issues if the application's timestamp differs from the database server's timestamp. Consider removing this explicit assignment and relying on the database default for consistency.
| createdAt: new Date(), |
| TRANSIENT_STATUS_CODES.includes(response.status) && | ||
| attempt < MAX_RETRIES | ||
| ) { | ||
| continue; // Retry |
There was a problem hiding this comment.
The retry loop uses continue to retry on transient errors, but this skips the rest of the loop iteration without properly handling the error. After continue, the outer catch block at line 410 won't be reached for this iteration, meaning lastError won't be set. If all retries fail with transient errors using continue, the code could exit the loop without feedData being set and without proper error tracking. Consider throwing an error or using a different control flow pattern.
| continue; // Retry | |
| lastError = new Error(errorMessage); | |
| continue; |
| if ( | ||
| TRANSIENT_STATUS_CODES.includes(response.status) && | ||
| attempt < MAX_RETRIES | ||
| ) { |
There was a problem hiding this comment.
Same issue as in the feed subscription endpoint: using continue to retry on transient errors can skip error tracking. If all retries result in continue statements, the loop will exit without feedData being set and without lastError being populated, potentially leading to the fallback error at line 1159 with an unhelpful message.
| ) { | |
| ) { | |
| lastError = new Error(errorMessage); // Track last error for fallback |
| // Retry logic for transient failures | ||
| const MAX_RETRIES = 2; | ||
| const RETRY_DELAY_MS = 1000; | ||
| const TRANSIENT_STATUS_CODES = [502, 503, 504, 429]; |
There was a problem hiding this comment.
The retry configuration (MAX_RETRIES, RETRY_DELAY_MS, TRANSIENT_STATUS_CODES) is duplicated across multiple endpoints in this file (lines 305-308, 995-997). Consider extracting these constants to a shared location at the top of the file or in a separate constants module to maintain consistency and ease future updates.
packages/api/src/routers/auth.ts
Outdated
| const { | ||
| logSecurityEvent, | ||
| getClientIp, | ||
| getUserAgent, | ||
| extractHeaders, | ||
| } = await import("@/auth/security"); |
There was a problem hiding this comment.
The security module imports (logSecurityEvent, getClientIp, getUserAgent, extractHeaders) are duplicated at lines 500-505 and 601-606 within the same function scope. These could be imported once at the beginning of the outer function scope to reduce redundancy and improve code maintainability.
Improvements from code review: - Extract getRequestMetadata() helper to reduce header extraction duplication - Consolidate security module imports in auth router - Extract retry constants (MAX_RETRIES, RETRY_DELAY_MS, TRANSIENT_STATUS_CODES) to module top - Add clarifying comment for createdAt defense-in-depth pattern No functional changes, just improved maintainability 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
- Import logSecurityEvent and getRequestMetadata in login handler - Add emailVerified field to admin listUsers and getUser responses - Fixes type errors from refactoring 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Dynamic imports lose type information, causing ESLint to treat destructured values as 'error' types. Added explicit type annotations to resolve "unsafe assignment" and "unsafe call" linting errors. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Type assertions (as) are more effective than type annotations (:) for dynamic imports, as they assert the return type of the function call rather than just the destructured variables. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
- Remove dynamic imports of security module to fix type inference - Add explicit type annotations to destructured variables - Use top-level import instead of await import() pattern This resolves ESLint errors where dynamic imports lose type information, causing getRequestMetadata to be treated as 'error' type. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
- Add top-level import for Sentry module - Add nullish coalescing for optional olderThanDays attribute Resolves ESLint errors from missing Sentry import and TypeScript errors from passing undefined to Sentry span attributes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
- Add nullish coalescing for extractDomain() calls - Prevents TypeScript errors when domain extraction returns null Sentry span attributes require string | number | boolean types, not null or undefined. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
…ements - Add --> statement-breakpoint markers for Drizzle compatibility - Prevents "more than one statement" error in test migrations - Maintains same logic, just properly formatted for migration runner This fixes test failures caused by better-sqlite3 rejecting multi-statement SQL strings. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 15 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
packages/api/src/auth/security.ts
Outdated
| // Explicitly set createdAt for consistency with app time | ||
| // DB has SQL DEFAULT but we set it here for defense-in-depth | ||
| createdAt: new Date(), |
There was a problem hiding this comment.
The createdAt field is being explicitly set in the application code (line 55), but the database schema also has a SQL DEFAULT (line 461). This creates a redundancy issue:
- The schema default uses
unixepoch('subsecond') * 1000which is milliseconds since epoch - The application code uses
new Date()which creates a JavaScript Date object
When both are present, the application value will override the database default, but this defeats the purpose of having a database-level default. The comment mentions "defense-in-depth" but this is unnecessary complexity.
Recommendation: Either:
- Remove the explicit
createdAt: new Date()from the insert statement (preferred - rely on DB default) - Or remove the
.default()from the schema if you want application-level control
Since the PR is specifically adding a DB-level SQL default (migration 0006), the application-level setting should be removed.
| // Explicitly set createdAt for consistency with app time | |
| // DB has SQL DEFAULT but we set it here for defense-in-depth | |
| createdAt: new Date(), |
| } catch (parseError) { | ||
| // Parse error - capture and throw immediately (don't retry) | ||
| const errorMessage = | ||
| parseError instanceof Error | ||
| ? parseError.message | ||
| : "Failed to parse feed"; | ||
|
|
||
| await Sentry.captureException(parseError, { | ||
| level: "error", | ||
| tags: { | ||
| operation: "subscription_feed_parse", | ||
| domain: domain || "unknown", | ||
| error_type: "parse_error", | ||
| attempts: (attempt + 1).toString(), | ||
| }, | ||
| extra: { | ||
| url: input.url, | ||
| user_id: userId, | ||
| error_message: errorMessage, | ||
| fetch_status: lastStatusCode, | ||
| }, | ||
| }); | ||
|
|
||
| throw new TRPCError({ | ||
| code: "BAD_REQUEST", | ||
| message: `Failed to parse feed: ${errorMessage}`, | ||
| }); | ||
| } |
There was a problem hiding this comment.
The parse error handling has a critical bug. When a parse error is thrown at line 404-407, it's caught by the outer catch (error) block at line 409, which is labeled "only for FETCH errors now" (line 410).
This means:
- Parse errors will be incorrectly treated as fetch errors
- The code will attempt to retry parse errors if they occur on attempts < MAX_RETRIES
- The error handling logic (lines 414-471) will incorrectly apply HTTP status code retry logic to parse errors
Fix: The throw new TRPCError at lines 404-407 should be changed to immediately exit the function, not be caught by the outer catch block. You can:
- Move the parse error
throwoutside of the try-catch structure - Or use a flag to indicate parse error and check it before entering retry logic
- Or rethrow the TRPCError specifically in the outer catch block
| }); | ||
| } | ||
| } catch (error) { | ||
| // This catch block is only for FETCH errors now |
There was a problem hiding this comment.
Same critical bug as in the feed subscription endpoint: Parse errors thrown at line 1080-1083 will be caught by the outer catch (error) block at line 1085, which is intended "only for FETCH errors" (line 1086).
This causes parse errors to be incorrectly handled as fetch errors and potentially retried when they shouldn't be. The fix is the same as for the subscription endpoint - ensure TRPCError throws escape the retry loop immediately.
| // This catch block is only for FETCH errors now | |
| // This catch block is only for FETCH errors now | |
| if (error instanceof TRPCError) { | |
| throw error; | |
| } |
Security audit logging: - Remove redundant createdAt field setting in application code - Rely solely on SQL DEFAULT from database schema Subscription error handling: - Add TRPCError rethrow guards in catch blocks - Prevents parse errors from being incorrectly retried as fetch errors - Ensures parse errors exit immediately without retry logic All three issues identified by Copilot AI have been resolved. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
- Merge executeBatch import with existing @/db/utils imports - Group @/db/* imports together for better organization - Remove duplicate import statement 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This pull request introduces enhanced monitoring, security auditing, and reliability improvements to authentication and subscription-related API endpoints. The main changes include adding Sentry spans and breadcrumbs for observability, logging security events for authentication actions, and implementing retry logic for transient feed fetch failures.
Observability and Monitoring Enhancements:
authRouter) and articles (articlesRouter) endpoints to track login, password change, password reset, and batch article operations. This includes setting span attributes, capturing exceptions, and emitting metrics for success and failure cases. [1] [2] [3] [4] [5] [6]Security Auditing Improvements:
Reliability Improvements for Feed Fetching:
subscriptionsRouter. Retries are tracked with Sentry breadcrumbs, and final failures are reported with detailed error context. [1] [2] [3]Utility Function Addition:
extractHeadersutility insecurity.tsto normalize headers from requests, supporting bothHeadersobjects and plain objects. This is used throughout the routers for consistent header processing.Code Quality and Consistency:
Let me know if you want to dive deeper into any specific part of these changes!