[{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/api/admin-disputes.js","messages":[{"ruleId":"complexity","severity":1,"message":"Async function 'handleAdminUpdateDispute' has a complexity of 18. Maximum allowed is 10.","line":111,"column":8,"nodeType":"FunctionDeclaration","messageId":"complex","endLine":255,"endColumn":2},{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 20 to the 15 allowed.","line":111,"column":23,"nodeType":null,"messageId":"refactorFunction","endLine":111,"endColumn":47}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Admin Disputes API\n * Admin-only dispute management endpoints\n */\n\n/**\n * Check if user is admin\n */\nfunction isAdmin(request, env) {\n  if (!request.user?.userId || !env.ADMIN_USER_ID) {\n    return false;\n  }\n  return request.user.userId === env.ADMIN_USER_ID;\n}\n\n/**\n * GET /api/admin/disputes\n * List all disputes (not just open) with full details\n */\nexport async function handleAdminListDisputes(request, env) {\n  try {\n    // Check admin authorization\n    if (!isAdmin(request, env)) {\n      return new Response(JSON.stringify({ \n        error: request.user?.userId ? 'NOT_ADMIN' : 'UNAUTHORIZED' \n      }), {\n        status: request.user?.userId ? 403 : 401,\n        headers: { 'Content-Type': 'application/json' }\n      });\n    }\n\n    const url = new URL(request.url);\n    \n    // Get query parameters\n    const status = url.searchParams.get('status') || 'all';\n    const claimType = url.searchParams.get('claim_type');\n    const limit = Math.min(parseInt(url.searchParams.get('limit') || '50'), 100);\n    const offset = parseInt(url.searchParams.get('offset') || '0');\n\n    // Get DisputeIndex\n    const indexId = env.DISPUTE_INDEX.idFromName('dispute-index:global');\n    const indexStub = env.DISPUTE_INDEX.get(indexId);\n\n    // For admin, we need to fetch disputes from index with status filter\n    const queryParams = new URLSearchParams();\n    if (status !== 'all') queryParams.set('status', status);\n    if (claimType) queryParams.set('claim_type', claimType);\n    queryParams.set('limit', limit.toString());\n    queryParams.set('offset', offset.toString());\n\n    // Note: DisputeIndex currently only tracks open disputes\n    // For a full admin view, we would need to iterate through all CIDs\n    // For now, return open disputes from index\n    const listResponse = await indexStub.fetch(\n      new Request(`http://internal/list?${queryParams.toString()}`)\n    );\n\n    const listData = await listResponse.json();\n\n    // Fetch full dispute details including contact info for each\n    const disputesWithDetails = await Promise.all(\n      listData.disputes.map(async (dispute) => {\n        try {\n          const disputeRecordId = env.DISPUTE_RECORD.idFromName(`dispute:${dispute.cid}`);\n          const disputeRecordStub = env.DISPUTE_RECORD.get(disputeRecordId);\n\n          const detailResponse = await disputeRecordStub.fetch(\n            new Request('http://internal/dispute')\n          );\n\n          if (detailResponse.ok) {\n            const detailData = await detailResponse.json();\n            return detailData.dispute;\n          }\n\n          return dispute;\n        } catch (error) {\n          console.error(`Error fetching details for dispute ${dispute.dispute_id}:`, error);\n          return dispute;\n        }\n      })\n    );\n\n    return new Response(JSON.stringify({\n      disputes: disputesWithDetails,\n      total: listData.total,\n      limit,\n      offset\n    }), {\n      status: 200,\n      headers: { 'Content-Type': 'application/json' }\n    });\n\n  } catch (error) {\n    console.error('Error listing admin disputes:', error);\n    return new Response(JSON.stringify({ \n      error: 'INTERNAL_ERROR', \n      message: error.message \n    }), {\n      status: 500,\n      headers: { 'Content-Type': 'application/json' }\n    });\n  }\n}\n\n/**\n * PATCH /api/admin/disputes/{dispute_id}\n * Update dispute status (admin only)\n * Note: dispute_id is in format like \"disp_xxx\", we need CID to look it up\n */\nexport async function handleAdminUpdateDispute(request, env, cid) {\n  try {\n    // Check admin authorization\n    if (!isAdmin(request, env)) {\n      return new Response(JSON.stringify({ \n        error: request.user?.userId ? 'NOT_ADMIN' : 'UNAUTHORIZED' \n      }), {\n        status: request.user?.userId ? 403 : 401,\n        headers: { 'Content-Type': 'application/json' }\n      });\n    }\n\n    // Parse request body\n    const data = await request.json();\n\n    // Validation\n    const validStatuses = ['open', 'under_review', 'closed_deleted', 'closed_denied', 'closed_expired'];\n    if (!data.status || !validStatuses.includes(data.status)) {\n      return new Response(JSON.stringify({ \n        error: 'INVALID_STATUS',\n        valid_statuses: validStatuses\n      }), {\n        status: 400,\n        headers: { 'Content-Type': 'application/json' }\n      });\n    }\n\n    // Get DisputeRecord for this CID\n    const disputeId = env.DISPUTE_RECORD.idFromName(`dispute:${cid}`);\n    const disputeStub = env.DISPUTE_RECORD.get(disputeId);\n\n    // Get current dispute\n    const currentDisputeResponse = await disputeStub.fetch(\n      new Request('http://internal/dispute')\n    );\n\n    if (!currentDisputeResponse.ok) {\n      return new Response(JSON.stringify({ error: 'DISPUTE_NOT_FOUND' }), {\n        status: 404,\n        headers: { 'Content-Type': 'application/json' }\n      });\n    }\n\n    const currentDispute = await currentDisputeResponse.json();\n\n    // Update dispute status\n    const updateResponse = await disputeStub.fetch(new Request('http://internal/dispute', {\n      method: 'PATCH',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify({\n        status: data.status,\n        resolution: data.status.startsWith('closed_') ? data.status.replace('closed_', '') : null,\n        resolution_reason: data.resolution_reason || null,\n        resolved_by: 'admin'\n      })\n    }));\n\n    if (!updateResponse.ok) {\n      const error = await updateResponse.json();\n      return new Response(JSON.stringify(error), {\n        status: updateResponse.status,\n        headers: { 'Content-Type': 'application/json' }\n      });\n    }\n\n    const updatedDispute = await updateResponse.json();\n\n    // If status changed to closed, update DisputeIndex\n    if (data.status.startsWith('closed_') && currentDispute.dispute.status === 'open') {\n      const indexId = env.DISPUTE_INDEX.idFromName('dispute-index:global');\n      const indexStub = env.DISPUTE_INDEX.get(indexId);\n\n      await indexStub.fetch(new Request('http://internal/remove', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({\n          dispute_id: currentDispute.dispute.dispute_id\n        })\n      }));\n    }\n\n    try {\n      if (data.status === 'open' || data.status === 'under_review') {\n        await env.CONTENT_BUCKET.put(`${cid}.disputed`, JSON.stringify({\n          dispute_id: currentDispute.dispute.dispute_id,\n          status: data.status,\n          updated_at: new Date().toISOString()\n        }), {\n          httpMetadata: {\n            contentType: 'application/json'\n          }\n        });\n      } else {\n        await env.CONTENT_BUCKET.delete(`${cid}.disputed`);\n      }\n    } catch (markerError) {\n      console.error('Error updating dispute marker:', markerError);\n    }\n\n    // Log admin action\n    const adminLogId = env.ADMIN_ACTION_LOG.idFromName('admin-action-log:global');\n    const adminLogStub = env.ADMIN_ACTION_LOG.get(adminLogId);\n\n    let actionType = 'dispute_status_changed';\n    if (data.status === 'closed_denied') {\n      actionType = 'dispute_denied';\n    } else if (data.status === 'closed_deleted') {\n      actionType = 'dispute_approved';\n    }\n\n    await adminLogStub.fetch(new Request('http://internal/log', {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify({\n        admin_user_id: request.user.userId,\n        action_type: actionType,\n        target_cid: cid,\n        target_dispute_id: currentDispute.dispute.dispute_id,\n        details: {\n          old_status: currentDispute.dispute.status,\n          new_status: data.status,\n          resolution_reason: data.resolution_reason\n        }\n      })\n    }));\n\n    return new Response(JSON.stringify({\n      success: true,\n      dispute: updatedDispute.dispute\n    }), {\n      status: 200,\n      headers: { 'Content-Type': 'application/json' }\n    });\n\n  } catch (error) {\n    console.error('Error updating dispute:', error);\n    return new Response(JSON.stringify({ \n      error: 'INTERNAL_ERROR', \n      message: error.message \n    }), {\n      status: 500,\n      headers: { 'Content-Type': 'application/json' }\n    });\n  }\n}\n\n/**\n * GET /api/admin/actions\n * Get admin action log\n */\nexport async function handleGetAdminActions(request, env) {\n  try {\n    // Check admin authorization\n    if (!isAdmin(request, env)) {\n      return new Response(JSON.stringify({ \n        error: request.user?.userId ? 'NOT_ADMIN' : 'UNAUTHORIZED' \n      }), {\n        status: request.user?.userId ? 403 : 401,\n        headers: { 'Content-Type': 'application/json' }\n      });\n    }\n\n    const url = new URL(request.url);\n    \n    // Build query string\n    const queryParams = new URLSearchParams();\n    const actionType = url.searchParams.get('action_type');\n    const limit = url.searchParams.get('limit') || '50';\n    const offset = url.searchParams.get('offset') || '0';\n\n    if (actionType) queryParams.set('action_type', actionType);\n    queryParams.set('limit', limit);\n    queryParams.set('offset', offset);\n\n    // Get AdminActionLog\n    const adminLogId = env.ADMIN_ACTION_LOG.idFromName('admin-action-log:global');\n    const adminLogStub = env.ADMIN_ACTION_LOG.get(adminLogId);\n\n    const actionsResponse = await adminLogStub.fetch(\n      new Request(`http://internal/actions?${queryParams.toString()}`)\n    );\n\n    const actionsData = await actionsResponse.json();\n\n    return new Response(JSON.stringify(actionsData), {\n      status: 200,\n      headers: { 'Content-Type': 'application/json' }\n    });\n\n  } catch (error) {\n    console.error('Error getting admin actions:', error);\n    return new Response(JSON.stringify({ \n      error: 'INTERNAL_ERROR', \n      message: error.message \n    }), {\n      status: 500,\n      headers: { 'Content-Type': 'application/json' }\n    });\n  }\n}\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/api/admin-disputes.test.js","messages":[{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 24 to the 15 allowed.","line":10,"column":10,"nodeType":null,"messageId":"refactorFunction","endLine":10,"endColumn":23}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Admin Disputes API Tests\n */\n\nimport { describe, it, expect, beforeEach, vi } from 'vitest';\n\n/**\n * Mock environment bindings\n */\nfunction createMockEnv(isAdmin = true) {\n  return {\n    ADMIN_USER_ID: isAdmin ? 'admin-user-123' : null,\n    DISPUTE_INDEX: {\n      idFromName: vi.fn(() => 'mock-index-id'),\n      get: vi.fn(() => ({\n        fetch: vi.fn(async (request) => {\n          const url = new URL(request.url);\n          if (url.pathname === '/list') {\n            return new Response(JSON.stringify({\n              disputes: [\n                {\n                  cid: 'test-cid-1',\n                  dispute_id: 'disp-123',\n                  claim_type: 'copyright',\n                  created_at: '2024-01-01T00:00:00Z',\n                  expires_at: '2024-01-31T00:00:00Z'\n                }\n              ],\n              total: 1,\n              cache_info: {\n                last_modified: '2024-01-01T00:00:00Z'\n              }\n            }), { status: 200 });\n          }\n          if (url.pathname === '/remove') {\n            return new Response(JSON.stringify({ success: true }), { status: 200 });\n          }\n          return new Response('Not Found', { status: 404 });\n        })\n      }))\n    },\n    DISPUTE_RECORD: {\n      idFromName: vi.fn(() => 'mock-dispute-id'),\n      get: vi.fn(() => ({\n        fetch: vi.fn(async (request) => {\n          const url = new URL(request.url);\n          if (url.pathname === '/dispute') {\n            if (request.method === 'GET') {\n              return new Response(JSON.stringify({\n                dispute: {\n                  dispute_id: 'disp-123',\n                  cid: 'test-cid-1',\n                  status: 'open',\n                  claim_type: 'copyright',\n                  evidence: 'Evidence text',\n                  evidence_urls: ['https://example.com/proof'],\n                  created_at: '2024-01-01T00:00:00Z',\n                  updated_at: '2024-01-01T00:00:00Z',\n                  expires_at: '2024-01-31T00:00:00Z',\n                  submitter_contact: {\n                    type: 'email',\n                    value: 'reporter@example.com'\n                  }\n                }\n              }), { status: 200 });\n            }\n            if (request.method === 'PATCH') {\n              return new Response(JSON.stringify({\n                dispute: {\n                  dispute_id: 'disp-123',\n                  status: 'closed_denied',\n                  resolution: 'denied',\n                  resolution_reason: 'Insufficient evidence',\n                  resolved_by: 'admin'\n                }\n              }), { status: 200 });\n            }\n          }\n          return new Response('Not Found', { status: 404 });\n        })\n      }))\n    },\n    ADMIN_ACTION_LOG: {\n      idFromName: vi.fn(() => 'mock-log-id'),\n      get: vi.fn(() => ({\n        fetch: vi.fn(async (request) => {\n          const url = new URL(request.url);\n          if (url.pathname === '/log') {\n            return new Response(JSON.stringify({ \n              success: true,\n              action_id: 'action-123'\n            }), { status: 201 });\n          }\n          if (url.pathname === '/actions') {\n            return new Response(JSON.stringify({\n              actions: [\n                {\n                  action_id: 'action-123',\n                  admin_user_id: 'admin-user-123',\n                  action_type: 'content_deleted',\n                  timestamp: '2024-01-01T00:00:00Z',\n                  target_cid: 'test-cid',\n                  target_dispute_id: 'disp-123',\n                  details: {\n                    reason: 'Violation'\n                  }\n                }\n              ],\n              total: 1,\n              limit: 50,\n              offset: 0\n            }), { status: 200 });\n          }\n          return new Response('Not Found', { status: 404 });\n        })\n      }))\n    },\n    CONTENT_BUCKET: {\n      put: vi.fn(async () => undefined),\n      delete: vi.fn(async () => undefined)\n    }\n  };\n}\n\n/**\n * Import handlers\n */\nimport { \n  handleAdminListDisputes,\n  handleAdminUpdateDispute,\n  handleGetAdminActions\n} from './admin-disputes.js';\n\ndescribe('Admin Disputes API', () => {\n  let mockEnv;\n\n  beforeEach(() => {\n    mockEnv = createMockEnv(true);\n  });\n\n  describe('GET /api/admin/disputes', () => {\n    it('should return 401 if user is not authenticated', async () => {\n      const request = new Request('http://localhost/api/admin/disputes', {\n        method: 'GET'\n      });\n\n      const response = await handleAdminListDisputes(request, mockEnv);\n      expect(response.status).toBe(401);\n\n      const data = await response.json();\n      expect(data.error).toBe('UNAUTHORIZED');\n    });\n\n    it('should return 403 if user is not admin', async () => {\n      const request = new Request('http://localhost/api/admin/disputes', {\n        method: 'GET'\n      });\n      request.user = { userId: 'regular-user-456' };\n\n      const response = await handleAdminListDisputes(request, mockEnv);\n      expect(response.status).toBe(403);\n\n      const data = await response.json();\n      expect(data.error).toBe('NOT_ADMIN');\n    });\n\n    it('should return all disputes with full details for admin', async () => {\n      const request = new Request('http://localhost/api/admin/disputes', {\n        method: 'GET'\n      });\n      request.user = { userId: 'admin-user-123' };\n\n      const response = await handleAdminListDisputes(request, mockEnv);\n      expect(response.status).toBe(200);\n\n      const data = await response.json();\n      expect(data.disputes).toHaveLength(1);\n      expect(data.disputes[0].dispute_id).toBe('disp-123');\n      expect(data.disputes[0].submitter_contact).toBeDefined();\n      expect(data.disputes[0].submitter_contact.value).toBe('reporter@example.com');\n    });\n\n    it('should support filtering by claim_type', async () => {\n      const request = new Request('http://localhost/api/admin/disputes?claim_type=copyright', {\n        method: 'GET'\n      });\n      request.user = { userId: 'admin-user-123' };\n\n      const response = await handleAdminListDisputes(request, mockEnv);\n      expect(response.status).toBe(200);\n\n      const data = await response.json();\n      expect(data.disputes).toBeDefined();\n    });\n\n    it('should support pagination', async () => {\n      const request = new Request('http://localhost/api/admin/disputes?limit=10&offset=0', {\n        method: 'GET'\n      });\n      request.user = { userId: 'admin-user-123' };\n\n      const response = await handleAdminListDisputes(request, mockEnv);\n      expect(response.status).toBe(200);\n\n      const data = await response.json();\n      expect(data.limit).toBe(10);\n      expect(data.offset).toBe(0);\n    });\n  });\n\n  describe('PATCH /api/admin/disputes/{cid}', () => {\n    it('should return 401 if user is not authenticated', async () => {\n      const request = new Request('http://localhost/api/admin/disputes/test-cid', {\n        method: 'PATCH',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({ \n          status: 'closed_denied',\n          resolution_reason: 'Insufficient evidence'\n        })\n      });\n\n      const response = await handleAdminUpdateDispute(request, mockEnv, 'test-cid');\n      expect(response.status).toBe(401);\n\n      const data = await response.json();\n      expect(data.error).toBe('UNAUTHORIZED');\n    });\n\n    it('should return 403 if user is not admin', async () => {\n      const request = new Request('http://localhost/api/admin/disputes/test-cid', {\n        method: 'PATCH',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({ \n          status: 'closed_denied',\n          resolution_reason: 'Insufficient evidence'\n        })\n      });\n      request.user = { userId: 'regular-user-456' };\n\n      const response = await handleAdminUpdateDispute(request, mockEnv, 'test-cid');\n      expect(response.status).toBe(403);\n\n      const data = await response.json();\n      expect(data.error).toBe('NOT_ADMIN');\n    });\n\n    it('should update dispute status with valid data', async () => {\n      const request = new Request('http://localhost/api/admin/disputes/test-cid', {\n        method: 'PATCH',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({ \n          status: 'closed_denied',\n          resolution_reason: 'Insufficient evidence provided'\n        })\n      });\n      request.user = { userId: 'admin-user-123' };\n\n      const response = await handleAdminUpdateDispute(request, mockEnv, 'test-cid');\n      expect(response.status).toBe(200);\n\n      const data = await response.json();\n      expect(data.success).toBe(true);\n      expect(data.dispute.status).toBe('closed_denied');\n      expect(data.dispute.resolved_by).toBe('admin');\n    });\n\n    it('should return 400 for invalid status', async () => {\n      const request = new Request('http://localhost/api/admin/disputes/test-cid', {\n        method: 'PATCH',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({ \n          status: 'invalid_status',\n          resolution_reason: 'Test'\n        })\n      });\n      request.user = { userId: 'admin-user-123' };\n\n      const response = await handleAdminUpdateDispute(request, mockEnv, 'test-cid');\n      expect(response.status).toBe(400);\n\n      const data = await response.json();\n      expect(data.error).toBe('INVALID_STATUS');\n      expect(data.valid_statuses).toBeDefined();\n    });\n\n    it('should log admin action when updating dispute', async () => {\n      const request = new Request('http://localhost/api/admin/disputes/test-cid', {\n        method: 'PATCH',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({ \n          status: 'closed_denied',\n          resolution_reason: 'Insufficient evidence'\n        })\n      });\n      request.user = { userId: 'admin-user-123' };\n\n      const logFetch = vi.fn(async () => {\n        return new Response(JSON.stringify({ success: true }), { status: 201 });\n      });\n\n      mockEnv.ADMIN_ACTION_LOG.get = vi.fn(() => ({\n        fetch: logFetch\n      }));\n\n      const response = await handleAdminUpdateDispute(request, mockEnv, 'test-cid');\n      expect(response.status).toBe(200);\n\n      // Verify admin action was logged\n      expect(logFetch).toHaveBeenCalled();\n    });\n  });\n\n  describe('GET /api/admin/actions', () => {\n    it('should return 401 if user is not authenticated', async () => {\n      const request = new Request('http://localhost/api/admin/actions', {\n        method: 'GET'\n      });\n\n      const response = await handleGetAdminActions(request, mockEnv);\n      expect(response.status).toBe(401);\n\n      const data = await response.json();\n      expect(data.error).toBe('UNAUTHORIZED');\n    });\n\n    it('should return 403 if user is not admin', async () => {\n      const request = new Request('http://localhost/api/admin/actions', {\n        method: 'GET'\n      });\n      request.user = { userId: 'regular-user-456' };\n\n      const response = await handleGetAdminActions(request, mockEnv);\n      expect(response.status).toBe(403);\n\n      const data = await response.json();\n      expect(data.error).toBe('NOT_ADMIN');\n    });\n\n    it('should return admin action log for admin', async () => {\n      const request = new Request('http://localhost/api/admin/actions', {\n        method: 'GET'\n      });\n      request.user = { userId: 'admin-user-123' };\n\n      const response = await handleGetAdminActions(request, mockEnv);\n      expect(response.status).toBe(200);\n\n      const data = await response.json();\n      expect(data.actions).toHaveLength(1);\n      expect(data.actions[0].action_id).toBe('action-123');\n      expect(data.actions[0].admin_user_id).toBe('admin-user-123');\n    });\n\n    it('should support filtering by action_type', async () => {\n      const request = new Request('http://localhost/api/admin/actions?action_type=content_deleted', {\n        method: 'GET'\n      });\n      request.user = { userId: 'admin-user-123' };\n\n      const response = await handleGetAdminActions(request, mockEnv);\n      expect(response.status).toBe(200);\n\n      const data = await response.json();\n      expect(data.actions).toBeDefined();\n    });\n\n    it('should support pagination', async () => {\n      const request = new Request('http://localhost/api/admin/actions?limit=20&offset=10', {\n        method: 'GET'\n      });\n      request.user = { userId: 'admin-user-123' };\n\n      const response = await handleGetAdminActions(request, mockEnv);\n      expect(response.status).toBe(200);\n\n      const data = await response.json();\n      // Verify pagination parameters were passed through\n      expect(data.actions).toBeDefined();\n      expect(data.total).toBeDefined();\n    });\n  });\n});\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/api/admin.js","messages":[{"ruleId":"complexity","severity":1,"message":"Async function 'handleExportData' has a complexity of 12. Maximum allowed is 10.","line":370,"column":8,"nodeType":"FunctionDeclaration","messageId":"complex","endLine":439,"endColumn":2}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Admin API Handlers\n * Endpoints for system management and monitoring\n */\n\nimport { requireAdmin } from '../auth/admin.js';\n\n/**\n * Get aggregate platform statistics\n * GET /api/admin/stats\n */\nexport async function handleGetStats(request, env) {\n  const authError = requireAdmin(request, env);\n  if (authError) return authError;\n\n  try {\n    // Get PlatformStats Durable Object\n    const statsId = env.PLATFORM_STATS.idFromName('global');\n    const statsStub = env.PLATFORM_STATS.get(statsId);\n    \n    const response = await statsStub.fetch(new Request('https://dummy/stats?type=all'));\n    const stats = await response.json();\n\n    // Log audit entry\n    await logAuditEntry(env, {\n      actor_type: 'admin',\n      actor_id: 'admin',\n      action: 'view_stats',\n      resource_type: 'platform_stats',\n      resource_id: 'global'\n    });\n\n    return new Response(JSON.stringify(stats), {\n      status: 200,\n      headers: { 'Content-Type': 'application/json' }\n    });\n  } catch (error) {\n    console.error('Error getting stats:', error);\n    return new Response(\n      JSON.stringify({ error: 'Failed to retrieve statistics' }),\n      { status: 500, headers: { 'Content-Type': 'application/json' } }\n    );\n  }\n}\n\n/**\n * Get financial statistics\n * GET /api/admin/stats/financial\n */\nexport async function handleGetFinancialStats(request, env) {\n  const authError = requireAdmin(request, env);\n  if (authError) return authError;\n\n  try {\n    const statsId = env.PLATFORM_STATS.idFromName('global');\n    const statsStub = env.PLATFORM_STATS.get(statsId);\n    \n    const response = await statsStub.fetch(new Request('https://dummy/stats?type=financial'));\n    const stats = await response.json();\n\n    await logAuditEntry(env, {\n      actor_type: 'admin',\n      actor_id: 'admin',\n      action: 'view_financial_stats',\n      resource_type: 'platform_stats',\n      resource_id: 'global'\n    });\n\n    return new Response(JSON.stringify(stats), {\n      status: 200,\n      headers: { 'Content-Type': 'application/json' }\n    });\n  } catch (error) {\n    console.error('Error getting financial stats:', error);\n    return new Response(\n      JSON.stringify({ error: 'Failed to retrieve financial statistics' }),\n      { status: 500, headers: { 'Content-Type': 'application/json' } }\n    );\n  }\n}\n\n/**\n * Get content statistics\n * GET /api/admin/stats/content\n */\nexport async function handleGetContentStats(request, env) {\n  const authError = requireAdmin(request, env);\n  if (authError) return authError;\n\n  try {\n    const statsId = env.PLATFORM_STATS.idFromName('global');\n    const statsStub = env.PLATFORM_STATS.get(statsId);\n    \n    const response = await statsStub.fetch(new Request('https://dummy/stats?type=content'));\n    const stats = await response.json();\n\n    await logAuditEntry(env, {\n      actor_type: 'admin',\n      actor_id: 'admin',\n      action: 'view_content_stats',\n      resource_type: 'platform_stats',\n      resource_id: 'global'\n    });\n\n    return new Response(JSON.stringify(stats), {\n      status: 200,\n      headers: { 'Content-Type': 'application/json' }\n    });\n  } catch (error) {\n    console.error('Error getting content stats:', error);\n    return new Response(\n      JSON.stringify({ error: 'Failed to retrieve content statistics' }),\n      { status: 500, headers: { 'Content-Type': 'application/json' } }\n    );\n  }\n}\n\n/**\n * Get user statistics\n * GET /api/admin/stats/users\n */\nexport async function handleGetUserStats(request, env) {\n  const authError = requireAdmin(request, env);\n  if (authError) return authError;\n\n  try {\n    const statsId = env.PLATFORM_STATS.idFromName('global');\n    const statsStub = env.PLATFORM_STATS.get(statsId);\n    \n    const response = await statsStub.fetch(new Request('https://dummy/stats?type=users'));\n    const stats = await response.json();\n\n    await logAuditEntry(env, {\n      actor_type: 'admin',\n      actor_id: 'admin',\n      action: 'view_user_stats',\n      resource_type: 'platform_stats',\n      resource_id: 'global'\n    });\n\n    return new Response(JSON.stringify(stats), {\n      status: 200,\n      headers: { 'Content-Type': 'application/json' }\n    });\n  } catch (error) {\n    console.error('Error getting user stats:', error);\n    return new Response(\n      JSON.stringify({ error: 'Failed to retrieve user statistics' }),\n      { status: 500, headers: { 'Content-Type': 'application/json' } }\n    );\n  }\n}\n\n/**\n * Get extended health check with metrics\n * GET /api/admin/health\n */\nexport async function handleGetAdminHealth(request, env) {\n  const authError = requireAdmin(request, env);\n  if (authError) return authError;\n\n  try {\n    const startTime = Date.now();\n    const health = {\n      status: 'healthy',\n      timestamp: new Date().toISOString(),\n      environment: env.ENVIRONMENT || 'unknown',\n      services: {},\n      response_times: {}\n    };\n\n    // Test worker\n    const workerStart = Date.now();\n    health.services.worker = 'operational';\n    health.response_times.worker_ms = Date.now() - workerStart;\n\n    // Test Durable Objects\n    const doStart = Date.now();\n    try {\n      const testId = env.PLATFORM_STATS.idFromName('health-check');\n      const testStub = env.PLATFORM_STATS.get(testId);\n      await testStub.fetch(new Request('https://dummy/stats?type=all'));\n      health.services.durable_objects = 'operational';\n    } catch (error) {\n      health.services.durable_objects = 'degraded';\n      health.status = 'degraded';\n    }\n    health.response_times.durable_objects_ms = Date.now() - doStart;\n\n    // Test R2\n    const r2Start = Date.now();\n    try {\n      await env.CONTENT_BUCKET.head('health-check');\n      health.services.r2 = 'operational';\n    } catch (error) {\n      // Head operation returns null for missing objects, which is fine\n      health.services.r2 = 'operational';\n    }\n    health.response_times.r2_ms = Date.now() - r2Start;\n\n    // Test Clerk\n    if (env.CLERK_SECRET_KEY) {\n      health.services.clerk = 'configured';\n    } else {\n      health.services.clerk = 'not_configured';\n    }\n\n    // Test Stripe\n    if (env.STRIPE_SECRET_KEY) {\n      health.services.stripe = 'configured';\n    } else {\n      health.services.stripe = 'not_configured';\n    }\n\n    health.response_times.total_ms = Date.now() - startTime;\n\n    await logAuditEntry(env, {\n      actor_type: 'admin',\n      actor_id: 'admin',\n      action: 'view_health',\n      resource_type: 'system',\n      resource_id: 'health'\n    });\n\n    return new Response(JSON.stringify(health), {\n      status: 200,\n      headers: { 'Content-Type': 'application/json' }\n    });\n  } catch (error) {\n    console.error('Error in admin health check:', error);\n    return new Response(\n      JSON.stringify({\n        status: 'unhealthy',\n        error: error.message\n      }),\n      { status: 500, headers: { 'Content-Type': 'application/json' } }\n    );\n  }\n}\n\n/**\n * Get alerts\n * GET /api/admin/alerts\n */\nexport async function handleGetAlerts(request, env) {\n  const authError = requireAdmin(request, env);\n  if (authError) return authError;\n\n  try {\n    const url = new URL(request.url);\n    const severity = url.searchParams.get('severity');\n    const status = url.searchParams.get('status');\n    const limit = url.searchParams.get('limit') || '100';\n\n    const alertId = env.ALERT_STORE.idFromName('global');\n    const alertStub = env.ALERT_STORE.get(alertId);\n    \n    const alertUrl = `https://dummy/list?severity=${severity || ''}&status=${status || ''}&limit=${limit}`;\n    const response = await alertStub.fetch(new Request(alertUrl));\n    const data = await response.json();\n\n    await logAuditEntry(env, {\n      actor_type: 'admin',\n      actor_id: 'admin',\n      action: 'view_alerts',\n      resource_type: 'alerts',\n      resource_id: 'global'\n    });\n\n    return new Response(JSON.stringify(data), {\n      status: 200,\n      headers: { 'Content-Type': 'application/json' }\n    });\n  } catch (error) {\n    console.error('Error getting alerts:', error);\n    return new Response(\n      JSON.stringify({ error: 'Failed to retrieve alerts' }),\n      { status: 500, headers: { 'Content-Type': 'application/json' } }\n    );\n  }\n}\n\n/**\n * Acknowledge an alert\n * POST /api/admin/alerts/:id/acknowledge\n */\nexport async function handleAcknowledgeAlert(request, env, alertId) {\n  const authError = requireAdmin(request, env);\n  if (authError) return authError;\n\n  try {\n    const alertStoreId = env.ALERT_STORE.idFromName('global');\n    const alertStub = env.ALERT_STORE.get(alertStoreId);\n    \n    const response = await alertStub.fetch(\n      new Request(`https://dummy/acknowledge/${alertId}`, {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({ acknowledged_by: 'admin' })\n      })\n    );\n\n    const data = await response.json();\n\n    await logAuditEntry(env, {\n      actor_type: 'admin',\n      actor_id: 'admin',\n      action: 'acknowledge_alert',\n      resource_type: 'alert',\n      resource_id: alertId\n    });\n\n    return new Response(JSON.stringify(data), {\n      status: 200,\n      headers: { 'Content-Type': 'application/json' }\n    });\n  } catch (error) {\n    console.error('Error acknowledging alert:', error);\n    return new Response(\n      JSON.stringify({ error: 'Failed to acknowledge alert' }),\n      { status: 500, headers: { 'Content-Type': 'application/json' } }\n    );\n  }\n}\n\n/**\n * Get audit log entries\n * GET /api/admin/audit-log\n */\nexport async function handleGetAuditLog(request, env) {\n  const authError = requireAdmin(request, env);\n  if (authError) return authError;\n\n  try {\n    const url = new URL(request.url);\n    const params = new URLSearchParams();\n    \n    // Forward query parameters\n    ['actor_type', 'actor_id', 'action', 'resource_type', 'start_date', 'end_date', 'limit', 'offset'].forEach(param => {\n      const value = url.searchParams.get(param);\n      if (value) params.append(param, value);\n    });\n\n    const auditId = env.AUDIT_LOG.idFromName('global');\n    const auditStub = env.AUDIT_LOG.get(auditId);\n    \n    const response = await auditStub.fetch(new Request(`https://dummy/list?${params.toString()}`));\n    const data = await response.json();\n\n    // Don't log this audit entry to avoid recursion\n    // (viewing audit log creates an audit entry creates an audit entry...)\n\n    return new Response(JSON.stringify(data), {\n      status: 200,\n      headers: { 'Content-Type': 'application/json' }\n    });\n  } catch (error) {\n    console.error('Error getting audit log:', error);\n    return new Response(\n      JSON.stringify({ error: 'Failed to retrieve audit log' }),\n      { status: 500, headers: { 'Content-Type': 'application/json' } }\n    );\n  }\n}\n\n/**\n * Export platform data\n * GET /api/admin/export?type=transactions|users|content|audit\n */\nexport async function handleExportData(request, env) {\n  const authError = requireAdmin(request, env);\n  if (authError) return authError;\n\n  try {\n    const url = new URL(request.url);\n    const exportType = url.searchParams.get('type');\n    const limit = parseInt(url.searchParams.get('limit') || '1000');\n    const offset = parseInt(url.searchParams.get('offset') || '0');\n\n    if (!exportType || !['transactions', 'users', 'content', 'audit'].includes(exportType)) {\n      return new Response(\n        JSON.stringify({ error: 'Invalid export type. Must be: transactions, users, content, or audit' }),\n        { status: 400, headers: { 'Content-Type': 'application/json' } }\n      );\n    }\n\n    // Rate limiting: Check last export time (1 export per minute)\n    const rateLimitCheck = await checkExportRateLimit(env);\n    if (rateLimitCheck.limited) {\n      return new Response(\n        JSON.stringify({ \n          error: 'Rate limit exceeded. Maximum 1 export per minute.',\n          retry_after_seconds: rateLimitCheck.retryAfter\n        }),\n        { \n          status: 429, \n          headers: { \n            'Content-Type': 'application/json',\n            'Retry-After': String(rateLimitCheck.retryAfter)\n          } \n        }\n      );\n    }\n\n    let csvData;\n    if (exportType === 'audit') {\n      csvData = await exportAuditLog(env, limit, offset);\n    } else if (exportType === 'transactions') {\n      csvData = await exportTransactions(env, limit, offset);\n    } else if (exportType === 'users') {\n      csvData = await exportUsers(env, limit, offset);\n    } else if (exportType === 'content') {\n      csvData = await exportContent(env, limit, offset);\n    }\n\n    await logAuditEntry(env, {\n      actor_type: 'admin',\n      actor_id: 'admin',\n      action: 'export_data',\n      resource_type: 'export',\n      resource_id: exportType,\n      metadata: { limit, offset }\n    });\n\n    return new Response(csvData, {\n      status: 200,\n      headers: {\n        'Content-Type': 'text/csv',\n        'Content-Disposition': `attachment; filename=\"${exportType}-${Date.now()}.csv\"`\n      }\n    });\n  } catch (error) {\n    console.error('Error exporting data:', error);\n    return new Response(\n      JSON.stringify({ error: 'Failed to export data' }),\n      { status: 500, headers: { 'Content-Type': 'application/json' } }\n    );\n  }\n}\n\n/**\n * Helper: Check export rate limit (1 per minute)\n */\nasync function checkExportRateLimit(env) {\n  try {\n    const statsId = env.PLATFORM_STATS.idFromName('global');\n    const statsStub = env.PLATFORM_STATS.get(statsId);\n    \n    const response = await statsStub.fetch(new Request('https://dummy/stats?type=all'));\n    const stats = await response.json();\n    \n    const lastExportAt = stats.last_export_at;\n    const now = Date.now();\n    \n    if (lastExportAt) {\n      const timeSinceLastExport = now - new Date(lastExportAt).getTime();\n      const oneMinuteMs = 60 * 1000;\n      \n      if (timeSinceLastExport < oneMinuteMs) {\n        return {\n          limited: true,\n          retryAfter: Math.ceil((oneMinuteMs - timeSinceLastExport) / 1000)\n        };\n      }\n    }\n    \n    // Update last export time\n    await statsStub.fetch(new Request('https://dummy/set', {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify({\n        key: 'last_export_at',\n        value: new Date().toISOString()\n      })\n    }));\n    \n    return { limited: false, retryAfter: 0 };\n  } catch (error) {\n    console.error('Error checking export rate limit:', error);\n    // On error, allow the export\n    return { limited: false, retryAfter: 0 };\n  }\n}\n\n/**\n * Helper: Export audit log to CSV\n */\n/**\n * Helper: Export audit log to CSV\n */\nasync function exportAuditLog(env, limit, offset) {\n  const auditId = env.AUDIT_LOG.idFromName('global');\n  const auditStub = env.AUDIT_LOG.get(auditId);\n  \n  const response = await auditStub.fetch(\n    new Request(`https://dummy/list?limit=${limit}&offset=${offset}`)\n  );\n  const data = await response.json();\n\n  // Build CSV\n  let csv = 'id,timestamp,actor_type,actor_id,action,resource_type,resource_id,ip_address\\n';\n  for (const entry of data.entries) {\n    csv += `\"${entry.id}\",\"${entry.timestamp}\",\"${entry.actor_type}\",\"${entry.actor_id}\",\"${entry.action}\",\"${entry.resource_type || ''}\",\"${entry.resource_id || ''}\",\"${entry.ip_address || ''}\"\\n`;\n  }\n\n  return csv;\n}\n\n/**\n * Helper: Export transactions to CSV\n * Note: This is a placeholder implementation\n * In a full implementation, this would iterate through PaymentRecord DOs\n */\nasync function exportTransactions(_env, _limit, _offset) {\n  // This would need to iterate through all payment records\n  // For now, return headers only with a note in the filename\n  const csv = 'id,type,amount_cents,user_id,created_at,status\\n';\n  return csv;\n}\n\n/**\n * Helper: Export users to CSV (no PII)\n * Note: This is a placeholder implementation\n * In a full implementation, this would iterate through UserProfile DOs\n */\nasync function exportUsers(_env, _limit, _offset) {\n  // This would need to iterate through all user profiles\n  // For now, return headers only\n  // PII (email, name) is excluded from export\n  const csv = 'user_id,created_at,balance_cents,total_deposited_cents,total_spent_cents\\n';\n  return csv;\n}\n\n/**\n * Helper: Export content to CSV\n * Note: This is a placeholder implementation\n * In a full implementation, this would iterate through ContentMetadata DOs\n */\nasync function exportContent(_env, _limit, _offset) {\n  // This would need to iterate through all content metadata\n  // For now, return headers only\n  const csv = 'hash,size_bytes,created_at,expires_at,download_count,owner_user_id\\n';\n  return csv;\n}\n\n/**\n * Helper: Log audit entry\n */\nasync function logAuditEntry(env, entry) {\n  try {\n    const auditId = env.AUDIT_LOG.idFromName('global');\n    const auditStub = env.AUDIT_LOG.get(auditId);\n    \n    await auditStub.fetch(\n      new Request('https://dummy/log', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify(entry)\n      })\n    );\n  } catch (error) {\n    console.error('Failed to log audit entry:', error);\n    // Don't fail the request if audit logging fails\n  }\n}\n\n/**\n * Get infrastructure costs\n * GET /api/admin/costs\n */\nexport async function handleGetCosts(request, env) {\n  const authError = requireAdmin(request, env);\n  if (authError) return authError;\n\n  try {\n    const url = new URL(request.url);\n    const period = url.searchParams.get('period') || 'all_time';\n\n    // Get InfrastructureCost Durable Object\n    const costId = env.INFRASTRUCTURE_COST.idFromName('global');\n    const costStub = env.INFRASTRUCTURE_COST.get(costId);\n    \n    // Note: Durable Object stubs use dummy URLs - the DO only looks at pathname and query params\n    const response = await costStub.fetch(\n      new Request(`https://dummy/summary?period=${period}`)\n    );\n    const costs = await response.json();\n\n    await logAuditEntry(env, {\n      actor_type: 'admin',\n      actor_id: 'admin',\n      action: 'view_costs',\n      resource_type: 'infrastructure_cost',\n      resource_id: 'global'\n    });\n\n    return new Response(JSON.stringify(costs), {\n      status: 200,\n      headers: { 'Content-Type': 'application/json' }\n    });\n  } catch (error) {\n    console.error('Error getting costs:', error);\n    return new Response(\n      JSON.stringify({ error: 'Failed to retrieve costs' }),\n      { status: 500, headers: { 'Content-Type': 'application/json' } }\n    );\n  }\n}\n\n/**\n * Get costs by service\n * GET /api/admin/costs/by-service\n */\nexport async function handleGetCostsByService(request, env) {\n  const authError = requireAdmin(request, env);\n  if (authError) return authError;\n\n  try {\n    const url = new URL(request.url);\n    const period = url.searchParams.get('period') || 'all_time';\n\n    const costId = env.INFRASTRUCTURE_COST.idFromName('global');\n    const costStub = env.INFRASTRUCTURE_COST.get(costId);\n    \n    const response = await costStub.fetch(\n      new Request(`https://dummy/by-service?period=${period}`)\n    );\n    const breakdown = await response.json();\n\n    await logAuditEntry(env, {\n      actor_type: 'admin',\n      actor_id: 'admin',\n      action: 'view_costs_by_service',\n      resource_type: 'infrastructure_cost',\n      resource_id: 'global'\n    });\n\n    return new Response(JSON.stringify(breakdown), {\n      status: 200,\n      headers: { 'Content-Type': 'application/json' }\n    });\n  } catch (error) {\n    console.error('Error getting costs by service:', error);\n    return new Response(\n      JSON.stringify({ error: 'Failed to retrieve cost breakdown' }),\n      { status: 500, headers: { 'Content-Type': 'application/json' } }\n    );\n  }\n}\n\n/**\n * Get profitability overview (revenue vs costs)\n * GET /api/admin/profitability\n */\nexport async function handleGetProfitability(request, env) {\n  const authError = requireAdmin(request, env);\n  if (authError) return authError;\n\n  try {\n    const url = new URL(request.url);\n    const period = url.searchParams.get('period') || 'all_time';\n\n    // Get costs\n    const costId = env.INFRASTRUCTURE_COST.idFromName('global');\n    const costStub = env.INFRASTRUCTURE_COST.get(costId);\n    \n    const costResponse = await costStub.fetch(\n      new Request(`https://dummy/summary?period=${period}`)\n    );\n    const costData = await costResponse.json();\n\n    // Get revenue from PlatformStats\n    const statsId = env.PLATFORM_STATS.idFromName('global');\n    const statsStub = env.PLATFORM_STATS.get(statsId);\n    \n    const statsResponse = await statsStub.fetch(\n      new Request('https://dummy/stats?type=financial')\n    );\n    const statsData = await statsResponse.json();\n\n    // Calculate profitability metrics\n    const totalCosts = costData.costs.total;\n    const totalRevenue = statsData.revenue.total_cents;\n    const profit = totalRevenue - totalCosts;\n    const margin = totalRevenue > 0 ? (profit / totalRevenue) * 100 : 0;\n\n    const profitability = {\n      period,\n      revenue_cents: totalRevenue,\n      revenue_dollars: totalRevenue / 100,\n      costs_cents: totalCosts,\n      costs_dollars: totalCosts / 100,\n      profit_cents: profit,\n      profit_dollars: profit / 100,\n      margin_percentage: margin,\n      status: profit >= 0 ? 'profitable' : 'unprofitable',\n      timestamp: new Date().toISOString()\n    };\n\n    await logAuditEntry(env, {\n      actor_type: 'admin',\n      actor_id: 'admin',\n      action: 'view_profitability',\n      resource_type: 'profitability',\n      resource_id: 'global'\n    });\n\n    return new Response(JSON.stringify(profitability), {\n      status: 200,\n      headers: { 'Content-Type': 'application/json' }\n    });\n  } catch (error) {\n    console.error('Error getting profitability:', error);\n    return new Response(\n      JSON.stringify({ error: 'Failed to calculate profitability' }),\n      { status: 500, headers: { 'Content-Type': 'application/json' } }\n    );\n  }\n}\n\n/**\n * Record infrastructure cost\n * POST /api/admin/costs/record\n */\nexport async function handleRecordCost(request, env) {\n  const authError = requireAdmin(request, env);\n  if (authError) return authError;\n\n  try {\n    const data = await request.json();\n\n    const costId = env.INFRASTRUCTURE_COST.idFromName('global');\n    const costStub = env.INFRASTRUCTURE_COST.get(costId);\n    \n    const response = await costStub.fetch(\n      new Request('https://dummy/record', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify(data)\n      })\n    );\n\n    await logAuditEntry(env, {\n      actor_type: 'admin',\n      actor_id: 'admin',\n      action: 'record_cost',\n      resource_type: 'infrastructure_cost',\n      resource_id: 'global',\n      metadata: { service: data.service, cost_cents: data.cost_cents }\n    });\n\n    return response;\n  } catch (error) {\n    console.error('Error recording cost:', error);\n    return new Response(\n      JSON.stringify({ error: 'Failed to record cost' }),\n      { status: 500, headers: { 'Content-Type': 'application/json' } }\n    );\n  }\n}\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/api/auth.js","messages":[{"ruleId":"complexity","severity":1,"message":"Async function 'handleLogout' has a complexity of 22. Maximum allowed is 10.","line":100,"column":8,"nodeType":"FunctionDeclaration","messageId":"complex","endLine":209,"endColumn":2},{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 30 to the 15 allowed.","line":100,"column":23,"nodeType":null,"messageId":"refactorFunction","endLine":100,"endColumn":35},{"ruleId":"complexity","severity":1,"message":"Async function 'handleCreateApiKey' has a complexity of 18. Maximum allowed is 10.","line":258,"column":8,"nodeType":"FunctionDeclaration","messageId":"complex","endLine":446,"endColumn":2},{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 27 to the 15 allowed.","line":258,"column":23,"nodeType":null,"messageId":"refactorFunction","endLine":258,"endColumn":41},{"ruleId":"complexity","severity":1,"message":"Async function 'handleRevealApiKey' has a complexity of 14. Maximum allowed is 10.","line":553,"column":8,"nodeType":"FunctionDeclaration","messageId":"complex","endLine":684,"endColumn":2},{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.","line":553,"column":23,"nodeType":null,"messageId":"refactorFunction","endLine":553,"endColumn":41},{"ruleId":"jsdoc/require-param-description","severity":1,"message":"Missing JSDoc @param \"request\" description.","line":690,"column":1,"nodeType":"Block","endLine":690,"endColumn":1},{"ruleId":"jsdoc/require-param-description","severity":1,"message":"Missing JSDoc @param \"env\" description.","line":691,"column":1,"nodeType":"Block","endLine":691,"endColumn":1},{"ruleId":"jsdoc/require-param-description","severity":1,"message":"Missing JSDoc @param \"keyId\" description.","line":692,"column":1,"nodeType":"Block","endLine":692,"endColumn":1},{"ruleId":"complexity","severity":1,"message":"Async function 'handleUpdateApiKey' has a complexity of 14. Maximum allowed is 10.","line":695,"column":8,"nodeType":"FunctionDeclaration","messageId":"complex","endLine":822,"endColumn":2},{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.","line":695,"column":23,"nodeType":null,"messageId":"refactorFunction","endLine":695,"endColumn":41},{"ruleId":"complexity","severity":1,"message":"Async function 'handleDeleteAccount' has a complexity of 14. Maximum allowed is 10.","line":828,"column":8,"nodeType":"FunctionDeclaration","messageId":"complex","endLine":991,"endColumn":2},{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.","line":828,"column":23,"nodeType":null,"messageId":"refactorFunction","endLine":828,"endColumn":42}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":13,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Authentication API Routes\n * Handles session management, API key operations, and account management\n */\n\nimport { authenticate, requireAuth } from '../auth/middleware.js';\nimport { generateApiKey, hashApiKey, generateKeyId, validateKeyName, validateExpiration, encryptApiKey, decryptApiKey, isSessionFresh } from '../auth/utils.js';\nimport { createClerkClient } from '@clerk/backend';\n\n/**\n * Handle OAuth callback endpoint\n * POST /api/auth/callback\n * \n * Note: In most Clerk integrations, the OAuth callback is handled entirely by\n * Clerk's frontend SDK and redirects. This endpoint provides a server-side\n * callback handler for custom OAuth flows or backend-only integrations.\n */\nexport async function handleAuthCallback(_request, _env) {\n  try {\n    // In a typical Clerk setup, the OAuth flow is handled by the Clerk frontend SDK\n    // This endpoint is provided for completeness but may not be used in standard flows\n    \n    // The callback would typically contain authorization codes or tokens\n    // that need to be exchanged with Clerk's backend\n    \n    return new Response(\n      JSON.stringify({\n        error: 'Not implemented',\n        message: 'OAuth callbacks are typically handled by Clerk frontend SDK. For backend-only flows, use Clerk Backend API directly.'\n      }),\n      {\n        status: 501,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  } catch (error) {\n    return new Response(\n      JSON.stringify({\n        error: 'Callback processing failed',\n        message: error.message\n      }),\n      {\n        status: 500,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n}\n\n/**\n * Handle session info endpoint\n * GET /api/auth/session\n */\nexport async function handleSessionInfo(request, env) {\n  const authResult = await authenticate(request, env);\n\n  // Require authentication\n  const authError = requireAuth(authResult);\n  if (authError) return authError;\n\n  const user = authResult.user;\n\n  // If profile doesn't exist yet, create it\n  if (user.authMethod === 'clerk' && user.profileExists === false) {\n    const userProfileId = env.USER_PROFILES.idFromName(user.userId);\n    const userProfileStub = env.USER_PROFILES.get(userProfileId);\n\n    await userProfileStub.fetch(\n      new Request('http://internal/profile', {\n        method: 'POST',\n        headers: { 'content-type': 'application/json' },\n        body: JSON.stringify({\n          user_id: user.userId,\n          providers: []\n        })\n      })\n    );\n  }\n\n  return new Response(\n    JSON.stringify({\n      user_id: user.userId,\n      auth_method: user.authMethod,\n      session_id: user.sessionId || null,\n      profile: user.profile || null\n    }),\n    {\n      status: 200,\n      headers: { 'content-type': 'application/json' }\n    }\n  );\n}\n\n/**\n * Handle logout endpoint\n * POST /api/auth/logout\n * \n * Invalidates the current Clerk session\n */\nexport async function handleLogout(request, env) {\n  const authResult = await authenticate(request, env);\n  const isLocalMode = env.ENVIRONMENT === 'local';\n\n  // Require authentication\n  const authError = requireAuth(authResult);\n  if (authError) return authError;\n\n  // Only Clerk sessions can be logged out\n  if (authResult.user.authMethod !== 'clerk') {\n    if (isLocalMode && authResult.user.authMethod === 'local') {\n      return new Response(\n        JSON.stringify({\n          success: true,\n          message: 'Local session cleared'\n        }),\n        {\n          status: 200,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    return new Response(\n      JSON.stringify({\n        error: 'Invalid authentication method',\n        message: 'Only Clerk sessions can be logged out. API keys must be revoked instead.'\n      }),\n      {\n        status: 400,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  try {\n    // Verify Clerk secret is configured\n    if (!env.CLERK_SECRET_KEY) {\n      throw new Error('CLERK_SECRET_KEY not configured');\n    }\n\n    // Create Clerk client\n    const clerkClient = createClerkClient({\n      secretKey: env.CLERK_SECRET_KEY\n    });\n\n    // Revoke the session using Clerk Backend API\n    // Note: The sessionId was extracted during authentication\n    const sessionId = authResult.user.sessionId;\n    \n    if (sessionId && typeof sessionId === 'string' && sessionId.length > 0) {\n      try {\n        await clerkClient.sessions.revokeSession(sessionId);\n      } catch (clerkError) {\n        // Log specific Clerk API errors for debugging (without sensitive data)\n        if (env.LOG_LEVEL === 'debug' && env.ENVIRONMENT !== 'production') {\n          console.error('Clerk API error during logout:', {\n            sessionId: sessionId.substring(0, 8) + '...', // Truncated for security\n            error: clerkError.message || 'Unknown error',\n            status: clerkError.status || 'unknown'\n          });\n        }\n        // Re-throw to be handled by outer catch\n        throw clerkError;\n      }\n    } else {\n      // No valid sessionId to revoke, but that's okay\n      // User might have already logged out or session might have expired\n      if (env.LOG_LEVEL === 'debug' && env.ENVIRONMENT !== 'production') {\n        console.warn('Logout called without valid sessionId');\n      }\n    }\n\n    return new Response(\n      JSON.stringify({\n        success: true,\n        message: 'Session logged out successfully'\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  } catch (error) {\n    // Log error with minimal context for debugging (avoid sensitive data in production)\n    if (env.ENVIRONMENT !== 'production') {\n      const userId = authResult.user?.userId;\n      console.error('Logout error:', {\n        error: error.message || 'Unknown error',\n        userId: userId && typeof userId === 'string' ? userId.substring(0, 8) + '...' : 'unknown'\n      });\n    } else {\n      // Production: log only non-sensitive error info\n      console.error('Logout error:', error.message || 'Unknown error');\n    }\n    \n    // Return success to avoid information leakage about session validity\n    // Even if revocation fails, the frontend can clear the token locally\n    return new Response(\n      JSON.stringify({\n        success: true,\n        message: 'Logout processed'\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n}\n\n/**\n * Handle OAuth provider linking endpoint\n * POST /api/auth/link\n * \n * Links additional OAuth provider to existing account\n */\nexport async function handleLinkProvider(request, env) {\n  const authResult = await authenticate(request, env);\n\n  // Require Clerk session\n  const authError = requireAuth(authResult);\n  if (authError) return authError;\n\n  if (authResult.user.authMethod !== 'clerk') {\n    return new Response(\n      JSON.stringify({\n        error: 'Invalid authentication method',\n        message: 'Use Clerk session to link OAuth providers'\n      }),\n      {\n        status: 403,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  // In Clerk's architecture, OAuth provider linking is handled by the frontend SDK\n  // The user initiates the OAuth flow in the browser, and Clerk handles the linking\n  // automatically. The webhook (user.updated) will then notify this backend.\n  \n  // This endpoint serves as a placeholder/documentation endpoint\n  return new Response(\n    JSON.stringify({\n      error: 'Not implemented',\n      message: 'OAuth provider linking is handled by Clerk frontend SDK. Use Clerk Components or SignIn/SignUp components with the \"Link Account\" option. After successful linking, the user.updated webhook will update the backend profile automatically.'\n    }),\n    {\n      status: 501,\n      headers: { 'content-type': 'application/json' }\n    }\n  );\n}\n\n/**\n * Handle API key creation\n * POST /api/auth/apikeys\n */\nexport async function handleCreateApiKey(request, env) {\n  const authResult = await authenticate(request, env);\n  const isLocalMode = env.ENVIRONMENT === 'local';\n\n  // Require Clerk session (API keys can't create other API keys)\n  const authError = requireAuth(authResult);\n  if (authError) return authError;\n\n  if (authResult.user.authMethod !== 'clerk' && !(isLocalMode && authResult.user.authMethod === 'local')) {\n    return new Response(\n      JSON.stringify({\n        error: 'Invalid authentication method',\n        message: 'API keys cannot be used to create new API keys. Use Clerk session (or local auth in local mode).'\n      }),\n      {\n        status: 403,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  // Parse request body\n  let body;\n  try {\n    body = await request.json();\n  } catch (error) {\n    return new Response(\n      JSON.stringify({\n        error: 'Invalid request body',\n        message: 'Request body must be valid JSON'\n      }),\n      {\n        status: 400,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  // Get existing keys to determine default name\n  const userProfileId = env.USER_PROFILES.idFromName(authResult.user.userId);\n  const userProfileStub = env.USER_PROFILES.get(userProfileId);\n\n  let keyName = body.name;\n  \n  // If no name provided, generate a smart default\n  if (!keyName || keyName.trim() === '') {\n    try {\n      const existingKeysResponse = await userProfileStub.fetch(\n        new Request('http://internal/apikeys', {\n          method: 'GET'\n        })\n      );\n      \n      if (!existingKeysResponse.ok) {\n        // If we can't fetch existing keys, just use \"Hosting\" as default\n        keyName = 'Hosting';\n      } else {\n        const existingKeys = await existingKeysResponse.json();\n        const existingNames = existingKeys.map(key => key.name);\n        \n        // Default to \"Hosting\" if no keys exist\n        if (existingKeys.length === 0 || !existingNames.includes('Hosting')) {\n          keyName = 'Hosting';\n        } else {\n          // Find next available \"Hosting n\" name\n          let n = 2;\n          while (existingNames.includes(`Hosting ${n}`)) {\n            n++;\n          }\n          keyName = `Hosting ${n}`;\n        }\n      }\n    } catch (error) {\n      // If error fetching keys, default to \"Hosting\"\n      keyName = 'Hosting';\n    }\n  }\n\n  // Validate key name\n  const nameValidation = validateKeyName(keyName);\n  if (!nameValidation.valid) {\n    return new Response(\n      JSON.stringify({\n        error: 'Invalid key name',\n        message: nameValidation.message\n      }),\n      {\n        status: 400,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  // Validate expiration\n  const expirationValidation = validateExpiration(body.expires_at);\n  if (!expirationValidation.valid) {\n    return new Response(\n      JSON.stringify({\n        error: 'Invalid expiration',\n        message: expirationValidation.message\n      }),\n      {\n        status: 400,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  // Generate API key\n  const apiKey = generateApiKey();\n  const keyHash = await hashApiKey(apiKey);\n  const keyId = generateKeyId();\n\n  // Encrypt the API key for reveal functionality\n  let keyEncrypted = apiKey;\n  if (!isLocalMode) {\n    if (!env.API_KEY_ENCRYPTION_KEY) {\n      return new Response(\n        JSON.stringify({\n          error: 'Encryption key not configured',\n          message: 'API_KEY_ENCRYPTION_KEY secret must be set'\n        }),\n        {\n          status: 500,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n    keyEncrypted = await encryptApiKey(apiKey, env.API_KEY_ENCRYPTION_KEY);\n  }\n\n  // Store in UserProfile DO\n  const response = await userProfileStub.fetch(\n    new Request('http://internal/apikeys', {\n      method: 'POST',\n      headers: { 'content-type': 'application/json' },\n      body: JSON.stringify({\n        key_id: keyId,\n        key_hash: keyHash,\n        key_encrypted: keyEncrypted,\n        name: keyName,\n        expires_at: expirationValidation.expiresAt\n      })\n    })\n  );\n\n  if (!response.ok) {\n    const error = await response.json();\n    return new Response(\n      JSON.stringify(error),\n      {\n        status: response.status,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  const keyInfo = await response.json();\n\n  // Register key in KeyRegistry\n  const registryId = env.KEY_REGISTRY.idFromName('global');\n  const registryStub = env.KEY_REGISTRY.get(registryId);\n\n  await registryStub.fetch(\n    new Request('http://internal/register', {\n      method: 'POST',\n      headers: { 'content-type': 'application/json' },\n      body: JSON.stringify({\n        key_hash: keyHash,\n        user_id: authResult.user.userId,\n        key_id: keyId\n      })\n    })\n  );\n\n  // Return the API key (ONLY SHOWN ONCE!)\n  return new Response(\n    JSON.stringify(\n      Object.assign({}, keyInfo, {\n        api_key: apiKey,\n        warning: 'Save this API key securely. It will not be shown again.'\n      })\n    ),\n    {\n      status: 201,\n      headers: { 'content-type': 'application/json' }\n    }\n  );\n}\n\n/**\n * Handle list API keys\n * GET /api/auth/apikeys\n */\nexport async function handleListApiKeys(request, env) {\n  const authResult = await authenticate(request, env);\n  const isLocalMode = env.ENVIRONMENT === 'local';\n\n  // Require Clerk session\n  const authError = requireAuth(authResult);\n  if (authError) return authError;\n\n  if (authResult.user.authMethod !== 'clerk' && !(isLocalMode && authResult.user.authMethod === 'local')) {\n    return new Response(\n      JSON.stringify({\n        error: 'Invalid authentication method',\n        message: 'Use Clerk session (or local auth in local mode) to list API keys'\n      }),\n      {\n        status: 403,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  // Get keys from UserProfile DO\n  const userProfileId = env.USER_PROFILES.idFromName(authResult.user.userId);\n  const userProfileStub = env.USER_PROFILES.get(userProfileId);\n\n  const response = await userProfileStub.fetch(\n    new Request('http://internal/apikeys', {\n      method: 'GET'\n    })\n  );\n\n  const keys = await response.json();\n\n  return new Response(JSON.stringify(keys), {\n    status: 200,\n    headers: { 'content-type': 'application/json' }\n  });\n}\n\n/**\n * Handle revoke API key\n * DELETE /api/auth/apikeys/:keyId\n */\nexport async function handleRevokeApiKey(request, env, keyId) {\n  const authResult = await authenticate(request, env);\n  const isLocalMode = env.ENVIRONMENT === 'local';\n\n  // Require Clerk session\n  const authError = requireAuth(authResult);\n  if (authError) return authError;\n\n  if (authResult.user.authMethod !== 'clerk' && !(isLocalMode && authResult.user.authMethod === 'local')) {\n    return new Response(\n      JSON.stringify({\n        error: 'Invalid authentication method',\n        message: 'Use Clerk session (or local auth in local mode) to revoke API keys'\n      }),\n      {\n        status: 403,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  // Revoke key in UserProfile DO\n  const userProfileId = env.USER_PROFILES.idFromName(authResult.user.userId);\n  const userProfileStub = env.USER_PROFILES.get(userProfileId);\n\n  const response = await userProfileStub.fetch(\n    new Request(`http://internal/apikeys/${keyId}`, {\n      method: 'DELETE'\n    })\n  );\n\n  if (!response.ok) {\n    const error = await response.json();\n    return new Response(\n      JSON.stringify(error),\n      {\n        status: response.status,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  const result = await response.json();\n\n  // Note: KeyRegistry entry is not removed here because we don't have the key hash\n  // The validation in middleware will check the revoked_at field in the UserProfile\n  // This is acceptable because revoked keys are kept for 5 years for audit purposes\n\n  return new Response(JSON.stringify(result), {\n    status: 200,\n    headers: { 'content-type': 'application/json' }\n  });\n}\n\n/**\n * Handle reveal API key\n * POST /api/auth/apikeys/:keyId/reveal\n */\nexport async function handleRevealApiKey(request, env, keyId) {\n  const authResult = await authenticate(request, env);\n  const isLocalMode = env.ENVIRONMENT === 'local';\n\n  // Require Clerk session (API keys cannot reveal other keys)\n  const authError = requireAuth(authResult);\n  if (authError) return authError;\n\n  if (authResult.user.authMethod !== 'clerk' && !(isLocalMode && authResult.user.authMethod === 'local')) {\n    return new Response(\n      JSON.stringify({\n        error: 'Invalid authentication method',\n        message: 'API keys cannot reveal other API keys. Use Clerk session (or local auth in local mode).'\n      }),\n      {\n        status: 403,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  // Verify session is fresh (authenticated within last 5 minutes)\n  if (!isLocalMode) {\n    try {\n      if (!env.CLERK_SECRET_KEY) {\n        throw new Error('CLERK_SECRET_KEY not configured');\n      }\n\n      const clerkClient = createClerkClient({\n        secretKey: env.CLERK_SECRET_KEY\n      });\n\n      const sessionId = authResult.user.sessionId;\n      const session = await clerkClient.sessions.getSession(sessionId);\n\n      // Check if session is fresh\n      if (!isSessionFresh(session, 5)) {\n        return new Response(\n          JSON.stringify({\n            error: 'FRESH_AUTH_REQUIRED',\n            message: 'This operation requires a fresh authentication session (authenticated within the last 5 minutes). Please re-authenticate.'\n          }),\n          {\n            status: 403,\n            headers: { 'content-type': 'application/json' }\n          }\n        );\n      }\n    } catch (error) {\n      return new Response(\n        JSON.stringify({\n          error: 'Session verification failed',\n          message: error.message\n        }),\n        {\n          status: 500,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n  }\n\n  // Get encrypted key from UserProfile DO (includes rate limiting)\n  const userProfileId = env.USER_PROFILES.idFromName(authResult.user.userId);\n  const userProfileStub = env.USER_PROFILES.get(userProfileId);\n\n  const response = await userProfileStub.fetch(\n    new Request(`http://internal/apikeys/${keyId}/reveal`, {\n      method: 'POST'\n    })\n  );\n\n  if (!response.ok) {\n    const error = await response.json();\n    return new Response(\n      JSON.stringify(error),\n      {\n        status: response.status,\n        headers: response.headers\n      }\n    );\n  }\n\n  const keyData = await response.json();\n\n  // Decrypt the API key\n  if (!isLocalMode && !env.API_KEY_ENCRYPTION_KEY) {\n    return new Response(\n      JSON.stringify({\n        error: 'Encryption key not configured',\n        message: 'API_KEY_ENCRYPTION_KEY secret must be set'\n      }),\n      {\n        status: 500,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  try {\n    const apiKey = isLocalMode\n      ? keyData.key_encrypted\n      : await decryptApiKey(keyData.key_encrypted, env.API_KEY_ENCRYPTION_KEY);\n\n    return new Response(\n      JSON.stringify({\n        key_id: keyData.key_id,\n        name: keyData.name,\n        api_key: apiKey,\n        created_at: keyData.created_at,\n        expires_at: keyData.expires_at,\n        last_used_at: keyData.last_used_at,\n        warning: 'Keep this API key secure. Limit how often you reveal it.'\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  } catch (error) {\n    return new Response(\n      JSON.stringify({\n        error: 'Decryption failed',\n        message: 'Unable to decrypt API key'\n      }),\n      {\n        status: 500,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n}\n\n/**\n * Update API key name\n * Requires fresh Clerk session (authenticated within last 5 minutes)\n * PATCH /api/auth/apikeys/:keyId\n * @param {Request} request\n * @param {Object} env\n * @param {string} keyId\n * @returns {Response}\n */\nexport async function handleUpdateApiKey(request, env, keyId) {\n  const authResult = await authenticate(request, env);\n  const isLocalMode = env.ENVIRONMENT === 'local';\n\n  // Require Clerk session (API keys cannot update themselves)\n  const authError = requireAuth(authResult);\n  if (authError) return authError;\n\n  if (authResult.user.authMethod !== 'clerk' && !(isLocalMode && authResult.user.authMethod === 'local')) {\n    return new Response(\n      JSON.stringify({\n        error: 'Invalid authentication method',\n        message: 'API keys cannot update other API keys. Use Clerk session (or local auth in local mode).'\n      }),\n      {\n        status: 403,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  // Verify session is fresh (authenticated within last 5 minutes)\n  if (!isLocalMode) {\n    try {\n      if (!env.CLERK_SECRET_KEY) {\n        throw new Error('CLERK_SECRET_KEY not configured');\n      }\n\n      const clerkClient = createClerkClient({\n        secretKey: env.CLERK_SECRET_KEY\n      });\n\n      const sessionId = authResult.user.sessionId;\n      const session = await clerkClient.sessions.getSession(sessionId);\n\n      // Check if session is fresh (5 minutes)\n      if (!isSessionFresh(session, 5)) {\n        return new Response(\n          JSON.stringify({\n            error: 'FRESH_AUTH_REQUIRED',\n            message: 'This operation requires a fresh authentication session (authenticated within the last 5 minutes). Please re-authenticate.'\n          }),\n          {\n            status: 403,\n            headers: { 'content-type': 'application/json' }\n          }\n        );\n      }\n    } catch (error) {\n      return new Response(\n        JSON.stringify({\n          error: 'Session verification failed',\n          message: error.message\n        }),\n        {\n          status: 500,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n  }\n\n  // Parse request body\n  let body;\n  try {\n    body = await request.json();\n  } catch (error) {\n    return new Response(\n      JSON.stringify({\n        error: 'Invalid request body',\n        message: 'Request body must be valid JSON'\n      }),\n      {\n        status: 400,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  // Validate name\n  const { name } = body;\n\n  if (!name || typeof name !== 'string') {\n    return new Response(\n      JSON.stringify({\n        error: 'Invalid name',\n        message: 'Name is required and must be a string'\n      }),\n      {\n        status: 400,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  // Validate name length (1-100 characters)\n  if (name.length < 1 || name.length > 100) {\n    return new Response(\n      JSON.stringify({\n        error: 'Invalid name length',\n        message: 'Name must be between 1 and 100 characters'\n      }),\n      {\n        status: 400,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  // Get user profile DO\n  const userId = authResult.user.userId;\n  const userProfileId = env.USER_PROFILES.idFromName(userId);\n  const userProfileStub = env.USER_PROFILES.get(userProfileId);\n\n  // Update API key name\n  const updateResponse = await userProfileStub.fetch(\n    new Request(`http://internal/apikeys/${keyId}/update`, {\n      method: 'PATCH',\n      headers: {\n        'content-type': 'application/json'\n      },\n      body: JSON.stringify({ name })\n    })\n  );\n\n  // Return response from DO\n  return updateResponse;\n}\n\n/**\n * Handle account deletion\n * DELETE /api/auth/account\n */\nexport async function handleDeleteAccount(request, env) {\n  const authResult = await authenticate(request, env);\n\n  // Require Clerk session\n  const authError = requireAuth(authResult);\n  if (authError) return authError;\n\n  if (authResult.user.authMethod !== 'clerk') {\n    return new Response(\n      JSON.stringify({\n        error: 'Invalid authentication method',\n        message: 'Use Clerk session to delete account'\n      }),\n      {\n        status: 403,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  // Parse request body for confirmation\n  // Note: Step-up authentication approach - user must re-authenticate with 2FA\n  // before calling this endpoint. We validate the session is fresh, not a TOTP token.\n  let body;\n  try {\n    body = await request.json();\n  } catch (error) {\n    body = {};\n  }\n\n  // Verify 2FA via step-up authentication approach\n  // For sensitive operations like account deletion, Clerk recommends requiring\n  // \"step-up authentication\" where the user must re-authenticate recently with 2FA.\n  // The frontend should handle prompting for re-authentication if the session is stale.\n  \n  if (!body.confirmed || body.confirmed !== true) {\n    return new Response(\n      JSON.stringify({\n        error: 'Confirmation required',\n        message: 'Account deletion requires explicit confirmation'\n      }),\n      {\n        status: 403,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  // Verify 2FA via step-up authentication approach\n  // For sensitive operations like account deletion, Clerk recommends requiring\n  // \"step-up authentication\" where the user must re-authenticate recently with 2FA.\n  // The frontend should handle prompting for re-authentication if the session is stale.\n  try {\n    const clerkClient = createClerkClient({ secretKey: env.CLERK_SECRET_KEY });\n    const clerkUser = await clerkClient.users.getUser(authResult.user.userId);\n    \n    // Check if user has TOTP (Time-based One-Time Password) 2FA enabled\n    // Note: Clerk may use different properties (totpEnabled, twoFactorEnabled)\n    // depending on the version. We check both for compatibility.\n    const hasTOTP = clerkUser.totpEnabled || clerkUser.twoFactorEnabled || false;\n    \n    if (hasTOTP) {\n      // If 2FA is enabled, verify the session is \"fresh\" (recently authenticated with 2FA)\n      // This implements the \"step-up authentication\" pattern recommended by Clerk\n      const sessionId = authResult.user.sessionId;\n      const session = await clerkClient.sessions.getSession(sessionId);\n      \n      // Check session status\n      if (session.status !== 'active') {\n        return new Response(\n          JSON.stringify({\n            error: 'Invalid session',\n            message: 'Session is not active. Please re-authenticate.',\n            totp_enabled: true\n          }),\n          {\n            status: 403,\n            headers: { 'content-type': 'application/json' }\n          }\n        );\n      }\n      \n      // Verify session freshness (within 5 minutes)\n      // A fresh session ensures the user has recently completed 2FA authentication\n      const lastActiveAt = new Date(session.lastActiveAt);\n      const now = new Date();\n      const fiveMinutesAgo = new Date(now.getTime() - 5 * 60 * 1000);\n      \n      if (lastActiveAt < fiveMinutesAgo) {\n        return new Response(\n          JSON.stringify({\n            error: 'Re-authentication required',\n            message: 'For security with 2FA enabled, please re-authenticate within the last 5 minutes before deleting your account. The frontend should prompt for re-authentication.',\n            totp_enabled: true,\n            requires_fresh_session: true\n          }),\n          {\n            status: 403,\n            headers: { 'content-type': 'application/json' }\n          }\n        );\n      }\n      \n      // Verify that the session was created with 2FA authentication\n      // Note: The exact mechanism to verify 2FA completion depends on Clerk's\n      // session object structure. In production, this should be validated against\n      // Clerk's actual API response. For now, we rely on session freshness and\n      // active status as indicators that 2FA was recently completed.\n      // \n      // Clerk sessions created with 2FA will have appropriate metadata.\n      // If the session is fresh and active, we can reasonably assume 2FA was used.\n      // \n      // Future improvement: Check specific Clerk session properties that\n      // explicitly indicate 2FA completion (e.g., factorVerificationStatus)\n    }\n  } catch (error) {\n    // If Clerk API call fails, log the error but allow deletion to proceed\n    // This prevents Clerk service outages from permanently blocking account deletion\n    // However, we only do this if CLERK_SECRET_KEY is not configured (development mode)\n    if (!env.CLERK_SECRET_KEY) {\n      console.warn('CLERK_SECRET_KEY not configured, skipping 2FA verification');\n    } else {\n      console.error('Error verifying 2FA status:', error.message);\n      return new Response(\n        JSON.stringify({\n          error: 'Verification failed',\n          message: 'Unable to verify 2FA status. Please try again.'\n        }),\n        {\n          status: 500,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n  }\n\n  // Delete profile in UserProfile DO (soft delete)\n  const userProfileId = env.USER_PROFILES.idFromName(authResult.user.userId);\n  const userProfileStub = env.USER_PROFILES.get(userProfileId);\n\n  const response = await userProfileStub.fetch(\n    new Request('http://internal/profile', {\n      method: 'DELETE'\n    })\n  );\n\n  if (!response.ok) {\n    const error = await response.json();\n    return new Response(\n      JSON.stringify(error),\n      {\n        status: response.status,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  const result = await response.json();\n\n  return new Response(JSON.stringify(result), {\n    status: 200,\n    headers: { 'content-type': 'application/json' }\n  });\n}\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/api/balance.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/api/content-deletion.js","messages":[{"ruleId":"complexity","severity":1,"message":"Async function 'handleDeleteContent' has a complexity of 22. Maximum allowed is 10.","line":10,"column":8,"nodeType":"FunctionDeclaration","messageId":"complex","endLine":228,"endColumn":2},{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 22 to the 15 allowed.","line":10,"column":23,"nodeType":null,"messageId":"refactorFunction","endLine":10,"endColumn":42},{"ruleId":"no-unused-vars","severity":1,"message":"'disputeId' is assigned a value but never used.","line":258,"column":11,"nodeType":"Identifier","messageId":"unusedVar","endLine":258,"endColumn":20}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":3,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Content Deletion API\n * Handles uploader and admin content deletion with dispute management\n */\n\n/**\n * DELETE /api/content/{cid}\n * Delete content (uploader or admin only)\n */\nexport async function handleDeleteContent(request, env, cid) {\n  try {\n    // Authentication required\n    if (!request.user?.userId) {\n      return new Response(JSON.stringify({ error: 'UNAUTHORIZED' }), {\n        status: 401,\n        headers: { 'Content-Type': 'application/json' }\n      });\n    }\n\n    const userId = request.user.userId;\n    const isAdmin = env.ADMIN_USER_ID && userId === env.ADMIN_USER_ID;\n\n    // Get content metadata\n    const contentId = env.CONTENT_METADATA.idFromName(cid);\n    const contentStub = env.CONTENT_METADATA.get(contentId);\n    const contentResponse = await contentStub.fetch(new Request('http://internal/content'));\n\n    if (!contentResponse.ok) {\n      return new Response(JSON.stringify({ error: 'NOT_FOUND' }), {\n        status: 404,\n        headers: { 'Content-Type': 'application/json' }\n      });\n    }\n\n    const metadata = await contentResponse.json();\n\n    // Check if already deleted\n    if (metadata.deleted_at) {\n      return new Response(JSON.stringify({ error: 'ALREADY_DELETED' }), {\n        status: 410,\n        headers: { 'Content-Type': 'application/json' }\n      });\n    }\n\n    // Authorization: Must be uploader or admin\n    if (!isAdmin && metadata.uploader_id !== userId) {\n      return new Response(JSON.stringify({ error: 'FORBIDDEN' }), {\n        status: 403,\n        headers: { 'Content-Type': 'application/json' }\n      });\n    }\n\n    // Parse optional request body\n    let reason = 'User request';\n    try {\n      const body = await request.json();\n      if (body.reason) {\n        reason = body.reason;\n      }\n    } catch (e) {\n      // No body or invalid JSON - use default reason\n    }\n\n    // Determine deleted_by\n    const deletedBy = isAdmin ? 'admin' : 'uploader';\n\n    // 1. Soft delete content in ContentMetadata\n    const deleteResponse = await contentStub.fetch(new Request('http://internal/soft-delete', {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify({\n        deleted_by: deletedBy,\n        deletion_reason: reason\n      })\n    }));\n\n    if (!deleteResponse.ok) {\n      const error = await deleteResponse.json();\n      return new Response(JSON.stringify(error), {\n        status: deleteResponse.status,\n        headers: { 'Content-Type': 'application/json' }\n      });\n    }\n\n    try {\n      await env.CONTENT_BUCKET.put(`${cid}.deleted`, JSON.stringify({\n        deleted_at: new Date().toISOString(),\n        deleted_by: deletedBy,\n        reason\n      }), {\n        httpMetadata: {\n          contentType: 'application/json'\n        }\n      });\n      await env.CONTENT_BUCKET.delete(`${cid}.disputed`);\n    } catch (markerError) {\n      console.error('Error writing deletion marker:', markerError);\n    }\n\n    // 2. Close any open disputes for this CID\n    const disputeId = env.DISPUTE_RECORD.idFromName(`dispute:${cid}`);\n    const disputeStub = env.DISPUTE_RECORD.get(disputeId);\n\n    // Get active dispute if any\n    const activeDisputeResponse = await disputeStub.fetch(new Request('http://internal/dispute'));\n    let disputeToClose = null;\n\n    if (activeDisputeResponse.ok) {\n      const disputeData = await activeDisputeResponse.json();\n      // Close dispute if it's open or under review\n      if (disputeData.dispute && (disputeData.dispute.status === 'open' || disputeData.dispute.status === 'under_review')) {\n        disputeToClose = disputeData.dispute;\n\n        // Update dispute status to closed_deleted\n        await disputeStub.fetch(new Request('http://internal/dispute', {\n          method: 'PATCH',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify({\n            status: 'closed_deleted',\n            resolution: 'deleted',\n            resolution_reason: `Content deleted by ${deletedBy}`,\n            resolved_by: deletedBy\n          })\n        }));\n\n        // Remove from DisputeIndex\n        const indexId = env.DISPUTE_INDEX.idFromName('dispute-index:global');\n        const indexStub = env.DISPUTE_INDEX.get(indexId);\n\n        await indexStub.fetch(new Request('http://internal/remove', {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify({\n            dispute_id: disputeToClose.dispute_id\n          })\n        }));\n      }\n    }\n\n    // 3. Create content_deletion transaction in PaymentRecord (amount: 0)\n    const paymentId = env.PAYMENT_RECORD.idFromName(userId);\n    const paymentStub = env.PAYMENT_RECORD.get(paymentId);\n\n    // Get current balance\n    const balanceResponse = await paymentStub.fetch(new Request('http://internal/transactions?limit=1'));\n    const balanceData = await balanceResponse.json();\n    const currentBalance = balanceData.transactions?.[0]?.balance_after_cents || 0;\n\n    await paymentStub.fetch(new Request('http://internal/transaction', {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify({\n        transaction_id: `txn_del_${crypto.randomUUID()}`,\n        type: 'content_deletion',\n        user_id: userId,\n        amount_cents: 0,\n        balance_before_cents: currentBalance,\n        balance_after_cents: currentBalance,\n        cid: cid,\n        content_size: metadata.size_bytes,\n        deletion_reason: reason,\n        dispute_id: disputeToClose?.dispute_id || null,\n        timestamp: new Date().toISOString()\n      })\n    }));\n\n    // 4. Create DeletionRecord entry\n    const deletionRecordId = env.DELETION_RECORD.idFromName('global');\n    const deletionRecordStub = env.DELETION_RECORD.get(deletionRecordId);\n\n    await deletionRecordStub.fetch(new Request('http://internal/record', {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify({\n        hash_256t: cid,\n        reason: deletedBy === 'admin' ? 'admin_action' : 'user_request',\n        uploader_id: metadata.uploader_id,\n        size_bytes: metadata.size_bytes,\n        content_type: metadata.content_type\n      })\n    }));\n\n    // 5. Log admin action if deleted by admin\n    if (isAdmin) {\n      const adminLogId = env.ADMIN_ACTION_LOG.idFromName('admin-action-log:global');\n      const adminLogStub = env.ADMIN_ACTION_LOG.get(adminLogId);\n\n      await adminLogStub.fetch(new Request('http://internal/log', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({\n          admin_user_id: userId,\n          action_type: 'content_deleted',\n          target_cid: cid,\n          target_dispute_id: disputeToClose?.dispute_id || null,\n          details: {\n            reason: reason\n          }\n        })\n      }));\n    }\n\n    const deletedAt = new Date().toISOString();\n\n    return new Response(JSON.stringify({\n      success: true,\n      deleted: {\n        cid: cid,\n        deleted_at: deletedAt,\n        deleted_by: deletedBy,\n        dispute_closed: !!disputeToClose\n      }\n    }), {\n      status: 200,\n      headers: { 'Content-Type': 'application/json' }\n    });\n\n  } catch (error) {\n    console.error('Error deleting content:', error);\n    return new Response(JSON.stringify({ \n      error: 'INTERNAL_ERROR', \n      message: error.message \n    }), {\n      status: 500,\n      headers: { 'Content-Type': 'application/json' }\n    });\n  }\n}\n\n/**\n * POST /api/admin/content/{cid}/delete\n * Admin-only deletion with additional options\n */\nexport async function handleAdminDeleteContent(request, env, cid) {\n  try {\n    // Authentication required\n    if (!request.user?.userId) {\n      return new Response(JSON.stringify({ error: 'UNAUTHORIZED' }), {\n        status: 401,\n        headers: { 'Content-Type': 'application/json' }\n      });\n    }\n\n    const userId = request.user.userId;\n    const isAdmin = env.ADMIN_USER_ID && userId === env.ADMIN_USER_ID;\n\n    // Check if user is admin\n    if (!isAdmin) {\n      return new Response(JSON.stringify({ error: 'NOT_ADMIN' }), {\n        status: 403,\n        headers: { 'Content-Type': 'application/json' }\n      });\n    }\n\n    // Parse request body\n    const data = await request.json();\n    const reason = data.reason || 'Admin action';\n    const disputeId = data.dispute_id || null;\n\n    // Create a modified request with reason in body\n    const modifiedRequest = new Request(request.url, {\n      method: 'DELETE',\n      headers: request.headers,\n      body: JSON.stringify({ reason })\n    });\n    modifiedRequest.user = request.user;\n\n    // Call standard delete handler\n    return await handleDeleteContent(modifiedRequest, env, cid);\n\n  } catch (error) {\n    console.error('Error in admin delete:', error);\n    return new Response(JSON.stringify({ \n      error: 'INTERNAL_ERROR', \n      message: error.message \n    }), {\n      status: 500,\n      headers: { 'Content-Type': 'application/json' }\n    });\n  }\n}\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/api/content-deletion.test.js","messages":[{"ruleId":"no-unused-vars","severity":1,"message":"'storage' is assigned a value but never used.","line":11,"column":9,"nodeType":"Identifier","messageId":"unusedVar","endLine":11,"endColumn":16}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Content Deletion API Tests\n */\n\nimport { describe, it, expect, beforeEach, vi } from 'vitest';\n\n/**\n * Mock environment bindings\n */\nfunction createMockEnv() {\n  const storage = new Map();\n  \n  return {\n    ADMIN_USER_ID: 'admin-user-123',\n    CONTENT_METADATA: {\n      idFromName: vi.fn(() => 'mock-id'),\n      get: vi.fn(() => ({\n        fetch: vi.fn(async (request) => {\n          const url = new URL(request.url);\n          if (url.pathname === '/content') {\n            return new Response(JSON.stringify({\n              hash_256t: 'test-cid',\n              uploader_id: 'user-456',\n              size_bytes: 1024,\n              content_type: 'text/plain',\n              deleted_at: null\n            }), { status: 200 });\n          }\n          if (url.pathname === '/soft-delete') {\n            return new Response(JSON.stringify({ success: true }), { status: 200 });\n          }\n          return new Response('Not Found', { status: 404 });\n        })\n      }))\n    },\n    DISPUTE_RECORD: {\n      idFromName: vi.fn(() => 'mock-dispute-id'),\n      get: vi.fn(() => ({\n        fetch: vi.fn(async (request) => {\n          const url = new URL(request.url);\n          if (url.pathname === '/dispute') {\n            if (request.method === 'GET') {\n              return new Response(JSON.stringify({\n                dispute: {\n                  dispute_id: 'disp-123',\n                  cid: 'test-cid',\n                  status: 'open'\n                }\n              }), { status: 200 });\n            }\n            if (request.method === 'PATCH') {\n              return new Response(JSON.stringify({ success: true }), { status: 200 });\n            }\n          }\n          return new Response('Not Found', { status: 404 });\n        })\n      }))\n    },\n    DISPUTE_INDEX: {\n      idFromName: vi.fn(() => 'mock-index-id'),\n      get: vi.fn(() => ({\n        fetch: vi.fn(async () => {\n          return new Response(JSON.stringify({ success: true }), { status: 200 });\n        })\n      }))\n    },\n    PAYMENT_RECORD: {\n      idFromName: vi.fn(() => 'mock-payment-id'),\n      get: vi.fn(() => ({\n        fetch: vi.fn(async (request) => {\n          const url = new URL(request.url);\n          if (url.pathname === '/transactions') {\n            return new Response(JSON.stringify({\n              transactions: [{ balance_after_cents: 10000 }]\n            }), { status: 200 });\n          }\n          if (url.pathname === '/transaction') {\n            return new Response(JSON.stringify({ success: true }), { status: 201 });\n          }\n          return new Response('Not Found', { status: 404 });\n        })\n      }))\n    },\n    DELETION_RECORD: {\n      idFromName: vi.fn(() => 'mock-deletion-id'),\n      get: vi.fn(() => ({\n        fetch: vi.fn(async () => {\n          return new Response(JSON.stringify({ success: true }), { status: 200 });\n        })\n      }))\n    },\n    ADMIN_ACTION_LOG: {\n      idFromName: vi.fn(() => 'mock-log-id'),\n      get: vi.fn(() => ({\n        fetch: vi.fn(async () => {\n          return new Response(JSON.stringify({ success: true }), { status: 201 });\n        })\n      }))\n    },\n    CONTENT_BUCKET: {\n      put: vi.fn(async () => undefined),\n      delete: vi.fn(async () => undefined)\n    }\n  };\n}\n\n/**\n * Import handlers\n */\nimport { handleDeleteContent, handleAdminDeleteContent } from './content-deletion.js';\n\ndescribe('Content Deletion API', () => {\n  let mockEnv;\n\n  beforeEach(() => {\n    mockEnv = createMockEnv();\n  });\n\n  describe('DELETE /api/content/{cid}', () => {\n    it('should return 401 if user is not authenticated', async () => {\n      const request = new Request('http://localhost/api/content/test-cid', {\n        method: 'DELETE'\n      });\n\n      const response = await handleDeleteContent(request, mockEnv, 'test-cid');\n      expect(response.status).toBe(401);\n\n      const data = await response.json();\n      expect(data.error).toBe('UNAUTHORIZED');\n    });\n\n    it('should allow uploader to delete their content', async () => {\n      const request = new Request('http://localhost/api/content/test-cid', {\n        method: 'DELETE',\n        headers: { 'Content-Type': 'application/json' }\n      });\n      request.user = { userId: 'user-456' };\n\n      const response = await handleDeleteContent(request, mockEnv, 'test-cid');\n      expect(response.status).toBe(200);\n\n      const data = await response.json();\n      expect(data.success).toBe(true);\n      expect(data.deleted.deleted_by).toBe('uploader');\n    });\n\n    it('should allow admin to delete any content', async () => {\n      const request = new Request('http://localhost/api/content/test-cid', {\n        method: 'DELETE',\n        headers: { 'Content-Type': 'application/json' }\n      });\n      request.user = { userId: 'admin-user-123' };\n\n      const response = await handleDeleteContent(request, mockEnv, 'test-cid');\n      expect(response.status).toBe(200);\n\n      const data = await response.json();\n      expect(data.success).toBe(true);\n      expect(data.deleted.deleted_by).toBe('admin');\n    });\n\n    it('should return 403 if user is not uploader or admin', async () => {\n      const request = new Request('http://localhost/api/content/test-cid', {\n        method: 'DELETE'\n      });\n      request.user = { userId: 'other-user-789' };\n\n      const response = await handleDeleteContent(request, mockEnv, 'test-cid');\n      expect(response.status).toBe(403);\n\n      const data = await response.json();\n      expect(data.error).toBe('FORBIDDEN');\n    });\n\n    it('should return 404 if content does not exist', async () => {\n      const request = new Request('http://localhost/api/content/nonexistent', {\n        method: 'DELETE'\n      });\n      request.user = { userId: 'user-456' };\n\n      // Mock content not found\n      mockEnv.CONTENT_METADATA.get = vi.fn(() => ({\n        fetch: vi.fn(async () => {\n          return new Response('Not Found', { status: 404 });\n        })\n      }));\n\n      const response = await handleDeleteContent(request, mockEnv, 'nonexistent');\n      expect(response.status).toBe(404);\n\n      const data = await response.json();\n      expect(data.error).toBe('NOT_FOUND');\n    });\n\n    it('should return 410 if content is already deleted', async () => {\n      const request = new Request('http://localhost/api/content/test-cid', {\n        method: 'DELETE'\n      });\n      request.user = { userId: 'user-456' };\n\n      // Mock already deleted content\n      mockEnv.CONTENT_METADATA.get = vi.fn(() => ({\n        fetch: vi.fn(async () => {\n          return new Response(JSON.stringify({\n            hash_256t: 'test-cid',\n            uploader_id: 'user-456',\n            deleted_at: '2024-01-01T00:00:00Z'\n          }), { status: 200 });\n        })\n      }));\n\n      const response = await handleDeleteContent(request, mockEnv, 'test-cid');\n      expect(response.status).toBe(410);\n\n      const data = await response.json();\n      expect(data.error).toBe('ALREADY_DELETED');\n    });\n\n    it('should accept optional reason in request body', async () => {\n      const request = new Request('http://localhost/api/content/test-cid', {\n        method: 'DELETE',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({ reason: 'No longer needed' })\n      });\n      request.user = { userId: 'user-456' };\n\n      const response = await handleDeleteContent(request, mockEnv, 'test-cid');\n      expect(response.status).toBe(200);\n\n      const data = await response.json();\n      expect(data.success).toBe(true);\n    });\n  });\n\n  describe('POST /api/admin/content/{cid}/delete', () => {\n    it('should return 401 if user is not authenticated', async () => {\n      const request = new Request('http://localhost/api/admin/content/test-cid/delete', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({ reason: 'Violation of terms' })\n      });\n\n      const response = await handleAdminDeleteContent(request, mockEnv, 'test-cid');\n      expect(response.status).toBe(401);\n\n      const data = await response.json();\n      expect(data.error).toBe('UNAUTHORIZED');\n    });\n\n    it('should return 403 if user is not admin', async () => {\n      const request = new Request('http://localhost/api/admin/content/test-cid/delete', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({ reason: 'Violation of terms' })\n      });\n      request.user = { userId: 'regular-user-456' };\n\n      const response = await handleAdminDeleteContent(request, mockEnv, 'test-cid');\n      expect(response.status).toBe(403);\n\n      const data = await response.json();\n      expect(data.error).toBe('NOT_ADMIN');\n    });\n\n    it('should allow admin to delete with custom reason', async () => {\n      const request = new Request('http://localhost/api/admin/content/test-cid/delete', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({ \n          reason: 'Violation of terms',\n          dispute_id: 'disp-123'\n        })\n      });\n      request.user = { userId: 'admin-user-123' };\n\n      const response = await handleAdminDeleteContent(request, mockEnv, 'test-cid');\n      expect(response.status).toBe(200);\n\n      const data = await response.json();\n      expect(data.success).toBe(true);\n      expect(data.deleted.deleted_by).toBe('admin');\n    });\n  });\n});\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/api/content-oauth.test.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/api/content.js","messages":[{"ruleId":"complexity","severity":1,"message":"Async function 'handleUploadContent' has a complexity of 50. Maximum allowed is 10.","line":36,"column":8,"nodeType":"FunctionDeclaration","messageId":"complex","endLine":449,"endColumn":2},{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 65 to the 15 allowed.","line":36,"column":23,"nodeType":null,"messageId":"refactorFunction","endLine":36,"endColumn":42},{"ruleId":"complexity","severity":1,"message":"Async function 'handleExtendContent' has a complexity of 17. Maximum allowed is 10.","line":527,"column":8,"nodeType":"FunctionDeclaration","messageId":"complex","endLine":745,"endColumn":2},{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 18 to the 15 allowed.","line":527,"column":23,"nodeType":null,"messageId":"refactorFunction","endLine":527,"endColumn":42},{"ruleId":"complexity","severity":1,"message":"Async function 'handleDownloadContent' has a complexity of 37. Maximum allowed is 10.","line":751,"column":8,"nodeType":"FunctionDeclaration","messageId":"complex","endLine":1055,"endColumn":2},{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 56 to the 15 allowed.","line":751,"column":23,"nodeType":null,"messageId":"refactorFunction","endLine":751,"endColumn":44}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":6,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Content API Handlers\n * Endpoints for content upload, download, and metadata\n */\n\nimport { authenticate } from '../auth/middleware.js';\nimport { hasOAuthScope, insufficientScopeResponse, isOAuthAuth, oauthNotAllowedResponse } from '../auth/oauth-access.js';\nimport { \n  calculateRetentionCost, \n  generateInsufficientBalanceMessage,\n  checkBalanceSufficient\n} from '../utils/pricing.js';\nimport { \n  generate256tHash, \n  isInlineContent, \n  validate256tCID,\n  extractInlineContent\n} from '../utils/hash256t.js';\nimport { getMimeType } from '../utils/mime-types.js';\nimport { recordContentUpload, recordContentDownload, recordPayment } from '../utils/platform-stats.js';\nimport { tryAlternateSuppliers } from '../utils/supplier-fallback.js';\nimport { buildContentUrl, getContentDomain } from '../utils/content-domain.js';\n\nasync function writeContentMeta(env, cid, metadata = {}) {\n  await env.CONTENT_BUCKET.put(`${cid}.meta`, JSON.stringify(metadata), {\n    httpMetadata: {\n      contentType: 'application/json'\n    }\n  });\n}\n\n/**\n * POST /api/content\n * Upload content with payment from balance\n */\nexport async function handleUploadContent(request, env) {\n  const authResult = await authenticate(request, env);\n  \n  if (!authResult.authenticated) {\n    return new Response(\n      JSON.stringify({\n        error: 'Unauthorized',\n        message: 'Authentication required'\n      }),\n      {\n        status: 401,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  try {\n    const url = new URL(request.url);\n    const requestContentType = request.headers.get('content-type') || '';\n    const isOAuthPublish = isOAuthAuth(authResult);\n    if (isOAuthPublish && !hasOAuthScope(authResult, 'content:write')) {\n      return insufficientScopeResponse('content:write');\n    }\n\n    let retention_months = isOAuthPublish\n      ? parseInt(authResult.user.profile?.default_retention_months || '1')\n      : parseInt(url.searchParams.get('retention_months') || '1');\n    let contentData;\n    let size_bytes;\n    let contentType = requestContentType.split(';')[0] || 'application/octet-stream';\n\n    if (requestContentType.includes('multipart/form-data')) {\n      const formData = await request.formData();\n      const file = formData.get('content');\n      if (!isOAuthPublish) {\n        retention_months = parseInt(formData.get('retention_months') || retention_months.toString());\n      }\n\n      if (!file) {\n        return new Response(\n          JSON.stringify({\n            error: 'Missing content',\n            message: 'Content file is required'\n          }),\n          {\n            status: 400,\n            headers: { 'content-type': 'application/json' }\n          }\n        );\n      }\n\n      size_bytes = file.size;\n      contentData = await file.arrayBuffer();\n      contentType = file.type || contentType;\n    } else {\n      contentData = await request.arrayBuffer();\n      size_bytes = contentData.byteLength;\n    }\n\n    if (!contentData || size_bytes === 0) {\n      return new Response(\n        JSON.stringify({\n          error: 'Missing content',\n          message: 'Content file is required'\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    if (!Number.isInteger(retention_months) || retention_months < 1) {\n      return new Response(\n        JSON.stringify({\n          error: 'Invalid retention',\n          message: 'Minimum retention is 1 month (30 days)'\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    const userId = authResult.user.userId;\n\n    // Calculate actual 256t hash\n    const hash_256t = await generate256tHash(contentData);\n    \n    // Check if this is inline content (≤64 bytes)\n    const isInline = isInlineContent(hash_256t);\n    \n    // Calculate cost (inline content is free)\n    const cost_cents = isInline ? 0 : calculateRetentionCost(size_bytes, retention_months);\n\n    // Check balance (skip for inline content which is free)\n    const userProfileId = env.USER_PROFILES.idFromName(userId);\n    const userProfileStub = env.USER_PROFILES.get(userProfileId);\n    \n    const balanceResponse = await userProfileStub.fetch(\n      new Request('http://internal/balance')\n    );\n    const balanceData = await balanceResponse.json();\n    const balance_cents = balanceData.balance_cents;\n\n    // Check if balance is sufficient (inline content doesn't need balance)\n    if (!isInline) {\n      const balanceCheck = checkBalanceSufficient(balance_cents, size_bytes, retention_months);\n      \n      if (!balanceCheck.sufficient) {\n        return new Response(\n          JSON.stringify({\n            error: 'insufficient_balance',\n            message: generateInsufficientBalanceMessage(balance_cents, balanceCheck.required),\n            required_cents: balanceCheck.required,\n            balance_cents: balance_cents,\n            shortfall_cents: balanceCheck.shortfall,\n            deposit_url: '/balance/deposit'\n          }),\n          {\n            status: 400,\n            headers: { 'content-type': 'application/json' }\n          }\n        );\n      }\n    }\n\n    // Check if content already exists\n    const contentMetadataId = env.CONTENT_METADATA.idFromName(hash_256t);\n    const contentMetadataStub = env.CONTENT_METADATA.get(contentMetadataId);\n    \n    const existsResponse = await contentMetadataStub.fetch(\n      new Request('http://internal/exists')\n    );\n    const existsData = await existsResponse.json();\n\n    if (existsData.exists) {\n      // Content already exists - extend retention by minimum 30 days\n      const minRetention = Math.max(retention_months, 1);\n      const extensionCost = isInline ? 0 : calculateRetentionCost(size_bytes, minRetention);\n\n      // Check balance for extension (skip for inline content)\n      if (!isInline) {\n        const extensionCheck = checkBalanceSufficient(balance_cents, size_bytes, minRetention);\n        if (!extensionCheck.sufficient) {\n          return new Response(\n            JSON.stringify({\n              error: 'insufficient_balance',\n              message: generateInsufficientBalanceMessage(balance_cents, extensionCheck.required),\n              required_cents: extensionCheck.required,\n              balance_cents: balance_cents,\n              shortfall_cents: extensionCheck.shortfall,\n              deposit_url: '/balance/deposit'\n            }),\n            {\n              status: 400,\n              headers: { 'content-type': 'application/json' }\n            }\n          );\n        }\n      }\n\n      // Debit balance for extension (only if not inline content)\n      let debitData = { balance_after_cents: balance_cents, balance_before_cents: balance_cents };\n      \n      if (!isInline && extensionCost > 0) {\n        const debitResponse = await userProfileStub.fetch(\n          new Request('http://internal/balance/debit', {\n            method: 'POST',\n            headers: { 'content-type': 'application/json' },\n            body: JSON.stringify({ amount_cents: extensionCost })\n          })\n        );\n\n        if (!debitResponse.ok) {\n          const error = await debitResponse.json();\n          return new Response(JSON.stringify(error), {\n            status: debitResponse.status,\n            headers: { 'content-type': 'application/json' }\n          });\n        }\n\n        debitData = await debitResponse.json();\n      }\n\n      // Extend retention\n      const transactionId = crypto.randomUUID();\n      const extendResponse = await contentMetadataStub.fetch(\n        new Request('http://internal/extend', {\n          method: 'POST',\n          headers: { 'content-type': 'application/json' },\n          body: JSON.stringify({\n            months_to_add: minRetention,\n            amount_cents: extensionCost,\n            payer_id: userId,\n            payment_id: transactionId\n          })\n        })\n      );\n      const extendData = await extendResponse.json();\n\n      if (!isInline && extendData.expires_at) {\n        await writeContentMeta(env, hash_256t, {\n          expires_at: extendData.expires_at\n        });\n      }\n\n      // Record transaction (only if cost > 0)\n      if (!isInline && extensionCost > 0) {\n        const paymentRecordId = env.PAYMENT_RECORDS.idFromName(userId);\n        const paymentRecordStub = env.PAYMENT_RECORDS.get(paymentRecordId);\n\n        await paymentRecordStub.fetch(\n          new Request('http://internal/transaction', {\n            method: 'POST',\n            headers: { 'content-type': 'application/json' },\n            body: JSON.stringify({\n              transaction_id: transactionId,\n              type: 'cid_extension',\n              user_id: userId,\n              amount_cents: -extensionCost,\n              balance_before_cents: debitData.balance_before_cents,\n              balance_after_cents: debitData.balance_after_cents,\n              cid: hash_256t,\n              retention_months: minRetention\n            })\n          })\n        );\n      }\n\n      // Add to user's upload history\n      await userProfileStub.fetch(\n        new Request('http://internal/uploads', {\n          method: 'POST',\n          headers: { 'content-type': 'application/json' },\n          body: JSON.stringify({\n            content_hash: hash_256t,\n            size_bytes: size_bytes,\n            payment_id: transactionId\n          })\n        })\n      );\n\n      return new Response(\n        JSON.stringify({\n          cid: hash_256t,\n          size_bytes: size_bytes,\n          expires_at: extendData.expires_at || existsData.expires_at,\n          cost_cents: extensionCost,\n          new_balance_cents: debitData.balance_after_cents,\n          url: buildContentUrl(env, hash_256t, null, request),\n          message: `Retention extended for ${minRetention} month(s). You can add more at /content/${hash_256t}`\n        }),\n        {\n          status: 200,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Debit balance (only if not inline content)\n    let debitData = { balance_after_cents: balance_cents, balance_before_cents: balance_cents };\n    \n    if (!isInline && cost_cents > 0) {\n      const debitResponse = await userProfileStub.fetch(\n        new Request('http://internal/balance/debit', {\n          method: 'POST',\n          headers: { 'content-type': 'application/json' },\n          body: JSON.stringify({\n            amount_cents: cost_cents,\n            oauth_grant_id: isOAuthPublish ? authResult.user.oauth?.grantId : undefined\n          })\n        })\n      );\n\n      if (!debitResponse.ok) {\n        const error = await debitResponse.json();\n        return new Response(JSON.stringify(error), {\n          status: debitResponse.status,\n          headers: { 'content-type': 'application/json' }\n        });\n      }\n\n      debitData = await debitResponse.json();\n    }\n\n    // Store content in R2 (only if not inline content)\n    if (!isInline) {\n      await env.CONTENT_BUCKET.put(hash_256t, contentData, {\n        httpMetadata: {\n          contentType: contentType || 'application/octet-stream'\n        }\n      });\n    }\n    // For inline content, no R2 storage needed - content is in the CID itself\n\n    // Create content metadata\n    const transactionId = crypto.randomUUID();\n    const metadataResponse = await contentMetadataStub.fetch(\n      new Request('http://internal/content', {\n        method: 'POST',\n        headers: { 'content-type': 'application/json' },\n        body: JSON.stringify({\n          hash_256t: hash_256t,\n          size_bytes: size_bytes,\n          uploader_id: userId,\n          retention_months: retention_months,\n          amount_cents: cost_cents,\n          payment_id: transactionId,\n          content_type: contentType || 'application/octet-stream'\n        })\n      })\n    );\n\n    const metadata = await metadataResponse.json();\n\n    if (!isInline && metadata.expires_at) {\n      await writeContentMeta(env, hash_256t, {\n        expires_at: metadata.expires_at\n      });\n    }\n\n    // Record transaction (only if cost > 0)\n    if (!isInline && cost_cents > 0) {\n      const paymentRecordId = env.PAYMENT_RECORDS.idFromName(userId);\n      const paymentRecordStub = env.PAYMENT_RECORDS.get(paymentRecordId);\n\n      await paymentRecordStub.fetch(\n        new Request('http://internal/transaction', {\n          method: 'POST',\n          headers: { 'content-type': 'application/json' },\n          body: JSON.stringify({\n            transaction_id: transactionId,\n            type: 'upload_payment',\n            user_id: userId,\n            amount_cents: -cost_cents,\n            balance_before_cents: debitData.balance_before_cents,\n            balance_after_cents: debitData.balance_after_cents,\n            cid: hash_256t,\n            retention_months: retention_months\n          })\n        })\n      );\n    }\n\n    // Add to user's upload history\n    await userProfileStub.fetch(\n      new Request('http://internal/uploads', {\n        method: 'POST',\n        headers: { 'content-type': 'application/json' },\n        body: JSON.stringify({\n          content_hash: hash_256t,\n          size_bytes: size_bytes,\n          payment_id: transactionId\n        })\n      })\n    );\n\n    // Record platform statistics\n    await recordContentUpload(env, size_bytes, isInline);\n    if (!isInline && cost_cents > 0) {\n      await recordPayment(env, 'upload_payment', cost_cents);\n    }\n\n    // Register with ExpirationIndex for expiration tracking (skip inline content)\n    if (!isInline && metadata.expires_at) {\n      try {\n        const expirationIndexId = env.EXPIRATION_INDEX.idFromName('global');\n        const expirationIndexStub = env.EXPIRATION_INDEX.get(expirationIndexId);\n        \n        await expirationIndexStub.fetch(\n          new Request('http://internal/register', {\n            method: 'POST',\n            headers: { 'content-type': 'application/json' },\n            body: JSON.stringify({\n              hash_256t: hash_256t,\n              expires_at: metadata.expires_at\n            })\n          })\n        );\n      } catch (error) {\n        // Log error but don't fail the upload\n        console.error('Failed to register with ExpirationIndex:', error);\n      }\n    }\n\n    return new Response(\n      JSON.stringify({\n        cid: hash_256t,\n        size_bytes: size_bytes,\n        expires_at: metadata.expires_at,\n        cost_cents: cost_cents,\n        new_balance_cents: debitData.balance_after_cents,\n        url: buildContentUrl(env, hash_256t, null, request)\n      }),\n      {\n        status: 201,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  } catch (error) {\n    return new Response(\n      JSON.stringify({\n        error: 'Upload failed',\n        message: error.message\n      }),\n      {\n        status: 500,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n}\n\n/**\n * GET /api/content/:cid\n * Get content metadata\n */\nexport async function handleGetContent(request, env, cid) {\n  try {\n    const contentMetadataId = env.CONTENT_METADATA.idFromName(cid);\n    const contentMetadataStub = env.CONTENT_METADATA.get(contentMetadataId);\n    \n    const response = await contentMetadataStub.fetch(\n      new Request('http://internal/content')\n    );\n    \n    if (!response.ok) {\n      return response;\n    }\n\n    const data = await response.json();\n\n    return new Response(\n      JSON.stringify({\n        ...data,\n        url: buildContentUrl(env, cid, null, request),\n        download_domain: getContentDomain(env, request)\n      }),\n      {\n        status: response.status,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  } catch (error) {\n    return new Response(\n      JSON.stringify({\n        error: 'Failed to get content',\n        message: error.message\n      }),\n      {\n        status: 500,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n}\n\n/**\n * GET /api/content/:cid/exists\n * Check if content exists\n */\nexport async function handleCheckContentExists(request, env, cid) {\n  try {\n    const contentMetadataId = env.CONTENT_METADATA.idFromName(cid);\n    const contentMetadataStub = env.CONTENT_METADATA.get(contentMetadataId);\n    \n    const response = await contentMetadataStub.fetch(\n      new Request('http://internal/exists')\n    );\n    \n    return response;\n  } catch (error) {\n    return new Response(\n      JSON.stringify({\n        error: 'Failed to check content',\n        message: error.message\n      }),\n      {\n        status: 500,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n}\n\n/**\n * POST /api/content/:cid/extend\n * Extend content retention (self-donation)\n */\nexport async function handleExtendContent(request, env, cid) {\n  const authResult = await authenticate(request, env);\n  \n  if (!authResult.authenticated) {\n    return new Response(\n      JSON.stringify({\n        error: 'Unauthorized',\n        message: 'Authentication required'\n      }),\n      {\n        status: 401,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  if (isOAuthAuth(authResult)) {\n    return oauthNotAllowedResponse();\n  }\n\n  try {\n    const data = await request.json();\n    const rawMonths = data.months_to_add ?? data.additional_months;\n\n    if (rawMonths === undefined || rawMonths === null) {\n      return new Response(\n        JSON.stringify({\n          error: 'Invalid retention',\n          message: 'Extension requires additional_months'\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    const months_to_add = parseInt(rawMonths);\n\n    if (months_to_add < 1) {\n      return new Response(\n        JSON.stringify({\n          error: 'Invalid retention',\n          message: 'Minimum extension is 1 month'\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    const userId = authResult.user.userId;\n\n    // Get content metadata to find size\n    const contentMetadataId = env.CONTENT_METADATA.idFromName(cid);\n    const contentMetadataStub = env.CONTENT_METADATA.get(contentMetadataId);\n    \n    const contentResponse = await contentMetadataStub.fetch(\n      new Request('http://internal/content')\n    );\n\n    if (!contentResponse.ok) {\n      return contentResponse;\n    }\n\n    const content = await contentResponse.json();\n    const size_bytes = content.size_bytes;\n\n    // Calculate cost\n    const cost_cents = calculateRetentionCost(size_bytes, months_to_add);\n\n    // Check balance\n    const userProfileId = env.USER_PROFILES.idFromName(userId);\n    const userProfileStub = env.USER_PROFILES.get(userProfileId);\n    \n    const balanceResponse = await userProfileStub.fetch(\n      new Request('http://internal/balance')\n    );\n    const balanceData = await balanceResponse.json();\n    const balance_cents = balanceData.balance_cents;\n\n    // Check if balance is sufficient\n    const balanceCheck = checkBalanceSufficient(balance_cents, size_bytes, months_to_add);\n    \n    if (!balanceCheck.sufficient) {\n      return new Response(\n        JSON.stringify({\n          error: 'insufficient_balance',\n          message: generateInsufficientBalanceMessage(balance_cents, balanceCheck.required),\n          required_cents: balanceCheck.required,\n          balance_cents: balance_cents,\n          shortfall_cents: balanceCheck.shortfall,\n          deposit_url: '/balance/deposit'\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Debit balance if needed\n    let debitData = { balance_after_cents: balance_cents, balance_before_cents: balance_cents };\n\n    if (cost_cents > 0) {\n      const debitResponse = await userProfileStub.fetch(\n        new Request('http://internal/balance/debit', {\n          method: 'POST',\n          headers: { 'content-type': 'application/json' },\n          body: JSON.stringify({ amount_cents: cost_cents })\n        })\n      );\n\n      if (!debitResponse.ok) {\n        const error = await debitResponse.json();\n        return new Response(JSON.stringify(error), {\n          status: debitResponse.status,\n          headers: { 'content-type': 'application/json' }\n        });\n      }\n\n      debitData = await debitResponse.json();\n    }\n\n    // Extend retention\n    const transactionId = crypto.randomUUID();\n    const extendResponse = await contentMetadataStub.fetch(\n      new Request('http://internal/extend', {\n        method: 'POST',\n        headers: { 'content-type': 'application/json' },\n        body: JSON.stringify({\n          months_to_add: months_to_add,\n          amount_cents: cost_cents,\n          payer_id: userId,\n          payment_id: transactionId\n        })\n      })\n    );\n\n    const extendData = await extendResponse.json();\n\n    if (extendData.expires_at && content.size_bytes > 64) {\n      await writeContentMeta(env, cid, {\n        expires_at: extendData.expires_at\n      });\n    }\n\n    // Update ExpirationIndex with new expiration date\n    if (content.expires_at && extendData.expires_at) {\n      try {\n        const expirationIndexId = env.EXPIRATION_INDEX.idFromName('global');\n        const expirationIndexStub = env.EXPIRATION_INDEX.get(expirationIndexId);\n        \n        await expirationIndexStub.fetch(\n          new Request('http://internal/update', {\n            method: 'POST',\n            headers: { 'content-type': 'application/json' },\n            body: JSON.stringify({\n              hash_256t: cid,\n              old_expires_at: content.expires_at,\n              new_expires_at: extendData.expires_at\n            })\n          })\n        );\n      } catch (error) {\n        // Log error but don't fail the extension\n        console.error('Failed to update ExpirationIndex:', error);\n      }\n    }\n\n    // Record transaction\n    const paymentRecordId = env.PAYMENT_RECORDS.idFromName(userId);\n    const paymentRecordStub = env.PAYMENT_RECORDS.get(paymentRecordId);\n\n    await paymentRecordStub.fetch(\n      new Request('http://internal/transaction', {\n        method: 'POST',\n        headers: { 'content-type': 'application/json' },\n        body: JSON.stringify({\n          transaction_id: transactionId,\n          type: 'cid_extension',\n          user_id: userId,\n          amount_cents: -cost_cents,\n          balance_before_cents: debitData.balance_before_cents,\n          balance_after_cents: debitData.balance_after_cents,\n          cid: cid,\n          retention_months: months_to_add\n        })\n      })\n    );\n\n    return new Response(\n      JSON.stringify({\n        cid: cid,\n        expires_at: extendData.expires_at,\n        months_added: months_to_add,\n        cost_cents: cost_cents,\n        new_balance_cents: debitData.balance_after_cents,\n        url: buildContentUrl(env, cid, null, request)\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  } catch (error) {\n    return new Response(\n      JSON.stringify({\n        error: 'Extension failed',\n        message: error.message\n      }),\n      {\n        status: 500,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n}\n\n/**\n * GET /{cid} or /{cid}.{ext}\n * Download content (public, no authentication required)\n */\nexport async function handleDownloadContent(request, env, cid, extension = null) {\n  try {\n    const url = new URL(request.url);\n    const method = request.method;\n    \n    // Validate CID format\n    if (!validate256tCID(cid)) {\n      return new Response(\n        JSON.stringify({\n          error: 'invalid_cid',\n          message: 'The provided CID is not a valid 256t identifier'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    const isInline = isInlineContent(cid);\n\n    // Fetch metadata to validate existence/expiration and capture content type\n    const contentMetadataId = env.CONTENT_METADATA.idFromName(cid);\n    const contentMetadataStub = env.CONTENT_METADATA.get(contentMetadataId);\n    const metadataResponse = await contentMetadataStub.fetch(\n      new Request('http://internal/content')\n    );\n\n    if (!metadataResponse.ok) {\n      if (metadataResponse.status === 404) {\n        return new Response(\n          JSON.stringify({\n            error: 'not_found',\n            message: 'Content not found'\n          }),\n          {\n            status: 404,\n            headers: { 'content-type': 'application/json' }\n          }\n        );\n      }\n      return metadataResponse;\n    }\n\n    const metadata = await metadataResponse.json();\n\n    // Check if content is expired\n    const expiresAt = new Date(metadata.expires_at);\n    if (expiresAt < new Date()) {\n      // Content expired, try alternate suppliers\n      const alternateResponse = await tryAlternateSuppliers(env, cid, request, extension);\n      \n      if (alternateResponse) {\n        return alternateResponse;\n      }\n\n      // No alternates available\n      return new Response(\n        JSON.stringify({\n          error: 'not_found',\n          message: 'Content not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Determine MIME type from extension or stored metadata\n    const mimeType = extension ? getMimeType(extension) : (metadata.content_type || 'application/octet-stream');\n\n    // Build base headers\n    const headers = {\n      'Content-Type': mimeType,\n      'ETag': `\"${cid}\"`,\n      'Accept-Ranges': 'bytes',\n      'Access-Control-Allow-Origin': '*',\n      'X-Content-Type-Options': 'nosniff'\n    };\n\n    // Check If-None-Match header for conditional requests\n    const ifNoneMatch = request.headers.get('If-None-Match');\n    if (ifNoneMatch) {\n      // Normalize ETags by removing quotes and weak prefix for comparison\n      const etags = ifNoneMatch.split(',').map(tag => \n        tag.trim().replace(/^W\\//, '').replace(/^\"|\"$/g, '')\n      );\n      \n      // Check if any ETag matches our CID\n      if (etags.includes(cid)) {\n        return new Response(null, {\n          status: 304,\n          headers: headers\n        });\n      }\n    }\n\n    // Check if this is inline content\n    if (isInline) {\n      // Inline content has no rate limit - serve immediately\n      // Extract content from CID itself\n      const contentBytes = extractInlineContent(cid);\n\n      // Inline content is immutable and can be aggressively cached\n      headers['Cache-Control'] = 'public, max-age=31536000, immutable';\n      headers['Content-Length'] = contentBytes.length.toString();\n      \n      // Add Content-Disposition if ?download=true\n      const forceDownload = url.searchParams.get('download') === 'true';\n      if (forceDownload) {\n        const filename = extension ? `${cid}.${extension}` : cid;\n        headers['Content-Disposition'] = `attachment; filename=\"${filename}\"`;\n      }\n\n      // For HEAD requests, return headers only\n      if (method === 'HEAD') {\n        return new Response(null, {\n          status: 200,\n          headers: headers\n        });\n      }\n\n      // Return inline content immediately - no rate limit applies to inline content\n      return new Response(contentBytes, {\n        status: 200,\n        headers: headers\n      });\n    }\n\n    // Check rate limit before serving content\n    const rateLimitResponse = await contentMetadataStub.fetch(\n      new Request('http://internal/rate-limit/check', {\n        method: 'POST'\n      })\n    );\n\n    if (!rateLimitResponse.ok) {\n      // Rate limit exceeded (429) or other error\n      if (rateLimitResponse.status === 429) {\n        // Return the rate limit error response as-is\n        return rateLimitResponse;\n      }\n      // Other errors (404, 500, etc.)\n      return rateLimitResponse;\n    }\n\n    // Rate limit check passed, get the rate limit info for headers\n    const rateLimitData = await rateLimitResponse.json();\n\n    // Check if content is contested (HTTP 451 - Unavailable For Legal Reasons)\n    if (metadata.contested) {\n      return new Response(\n        JSON.stringify({\n          error: 'unavailable_for_legal_reasons',\n          message: metadata.contested_reason || 'This content is unavailable due to legal reasons',\n          details: 'This content has been removed or restricted due to legal process.'\n        }),\n        {\n          status: 451,\n          headers: { \n            'content-type': 'application/json',\n            'Link': '<https://hashbin.org/legal/contested>; rel=\"blocked-by\"'\n          }\n        }\n      );\n    }\n\n    // Fetch content from R2\n    const rangeHeader = request.headers.get('Range');\n    let r2Object;\n    \n    if (rangeHeader) {\n      // Parse Range header (format: \"bytes=start-end\" or \"bytes=start-\")\n      // Note: Only single ranges are supported. Multiple ranges (RFC 7233) are not implemented\n      // as they are rarely used and add significant complexity. Clients needing multiple ranges\n      // can make separate requests.\n      const rangeMatch = rangeHeader.match(/bytes=(\\d+)-(\\d*)/);\n      \n      if (rangeMatch) {\n        const start = parseInt(rangeMatch[1]);\n        const end = rangeMatch[2] ? parseInt(rangeMatch[2]) : undefined;\n        \n        // Validate range\n        if (end !== undefined && start > end) {\n          return new Response(\n            JSON.stringify({\n              error: 'invalid_range',\n              message: 'Invalid range: start must be less than or equal to end'\n            }),\n            {\n              status: 416, // Range Not Satisfiable\n              headers: { 'content-type': 'application/json' }\n            }\n          );\n        }\n        \n        // R2 expects a range object with offset and optional length\n        const rangeOptions = { offset: start };\n        if (end !== undefined) {\n          rangeOptions.length = end - start + 1;\n        }\n        \n        r2Object = await env.CONTENT_BUCKET.get(cid, { range: rangeOptions });\n      } else {\n        // Invalid range format, fetch full object\n        r2Object = await env.CONTENT_BUCKET.get(cid);\n      }\n    } else {\n      r2Object = await env.CONTENT_BUCKET.get(cid);\n    }\n\n    if (!r2Object) {\n      // Content not in R2, try alternate suppliers\n      const alternateResponse = await tryAlternateSuppliers(env, cid, request, extension);\n      \n      if (alternateResponse) {\n        return alternateResponse;\n      }\n\n      // No alternates available or all failed\n      return new Response(\n        JSON.stringify({\n          error: 'not_found',\n          message: 'Content not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Set Content-Length header\n    if (r2Object.size) {\n      headers['Content-Length'] = r2Object.size.toString();\n    }\n\n    // Add rate limit headers\n    if (rateLimitData.next_available_at) {\n      const nextAvailableTimestamp = Math.floor(new Date(rateLimitData.next_available_at).getTime() / 1000);\n      headers['X-RateLimit-Content-Reset'] = nextAvailableTimestamp.toString();\n    }\n    if (rateLimitData.effective_mtbr_ms) {\n      headers['X-RateLimit-Content-MTBR-Ms'] = rateLimitData.effective_mtbr_ms.toString();\n    }\n\n    // Handle range response\n    if (r2Object.range) {\n      const rangeStart = r2Object.range.offset;\n      const rangeLength = r2Object.range.length || (metadata.size_bytes - rangeStart);\n      const rangeEnd = rangeStart + rangeLength - 1;\n      \n      headers['Content-Range'] = `bytes ${rangeStart}-${rangeEnd}/${metadata.size_bytes}`;\n      headers['Content-Length'] = rangeLength.toString();\n    }\n\n    // Add Content-Disposition if ?download=true\n    const forceDownload = url.searchParams.get('download') === 'true';\n    if (forceDownload) {\n      const filename = extension ? `${cid}.${extension}` : cid;\n      headers['Content-Disposition'] = `attachment; filename=\"${filename}\"`;\n    }\n\n    // For HEAD requests, return headers only\n    if (method === 'HEAD') {\n      return new Response(null, {\n        status: rangeHeader ? 206 : 200,\n        headers: headers\n      });\n    }\n\n    // Increment download count (fire and forget - intentionally async for performance)\n    // Note: This is not awaited to avoid blocking the response. The download count\n    // is aggregate analytics only and small inconsistencies are acceptable.\n    // Race conditions are handled by the Durable Object's transactional storage.\n    // Errors are logged but don't cause memory leaks - failed increments are ignored.\n    incrementDownloadCount(env, cid).catch(err => {\n      console.error('Failed to increment download count:', err);\n    });\n    \n    // Record platform statistics\n    recordContentDownload(env).catch(err => {\n      console.error('Failed to record platform download stat:', err);\n    });\n\n    // Stream content\n    return new Response(r2Object.body, {\n      status: rangeHeader ? 206 : 200,\n      headers: headers\n    });\n  } catch (error) {\n    console.error('Download error:', error);\n    return new Response(\n      JSON.stringify({\n        error: 'Download failed',\n        message: error.message\n      }),\n      {\n        status: 500,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n}\n\n/**\n * Increment download count for content\n * @param {Object} env - Environment bindings\n * @param {string} cid - Content ID\n */\nasync function incrementDownloadCount(env, cid) {\n  try {\n    const contentMetadataId = env.CONTENT_METADATA.idFromName(cid);\n    const contentMetadataStub = env.CONTENT_METADATA.get(contentMetadataId);\n    \n    await contentMetadataStub.fetch(\n      new Request('http://internal/increment-download', {\n        method: 'POST'\n      })\n    );\n  } catch (error) {\n    // Ignore errors - download count is not critical\n    console.error('Error incrementing download count:', error);\n  }\n}\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/api/disputes.js","messages":[{"ruleId":"complexity","severity":1,"message":"Async function 'handleCreateDispute' has a complexity of 13. Maximum allowed is 10.","line":12,"column":8,"nodeType":"FunctionDeclaration","messageId":"complex","endLine":145,"endColumn":2},{"ruleId":"no-unused-vars","severity":1,"message":"'userId' is assigned a value but never used.","line":199,"column":65,"nodeType":"Identifier","messageId":"unusedVar","endLine":199,"endColumn":71}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Disputes API\n * Handles content dispute creation, listing, and management\n */\n\nimport { hashIP } from '../utils/ip-hash.js';\n\n/**\n * POST /api/disputes\n * Create a new dispute against content\n */\nexport async function handleCreateDispute(request, env) {\n  try {\n    const data = await request.json();\n\n    // Validate CID\n    if (!data.cid) {\n      return new Response(JSON.stringify({ error: 'CID_REQUIRED' }), {\n        status: 400,\n        headers: { 'Content-Type': 'application/json' }\n      });\n    }\n\n    // Check if content exists\n    const contentId = env.CONTENT_METADATA.idFromName(data.cid);\n    const contentStub = env.CONTENT_METADATA.get(contentId);\n    const contentResponse = await contentStub.fetch(new Request('http://internal/content'));\n    \n    if (!contentResponse.ok) {\n      return new Response(JSON.stringify({ error: 'CID_NOT_FOUND' }), {\n        status: 404,\n        headers: { 'Content-Type': 'application/json' }\n      });\n    }\n\n    const contentMetadata = await contentResponse.json();\n\n    // Check if content is already deleted\n    if (contentMetadata.deleted_at) {\n      return new Response(JSON.stringify({ error: 'CID_DELETED' }), {\n        status: 410,\n        headers: { 'Content-Type': 'application/json' }\n      });\n    }\n\n    // Get IP hash for rate limiting\n    const clientIP = request.headers.get('CF-Connecting-IP') || request.headers.get('X-Forwarded-For') || 'unknown';\n    const ipHash = await hashIP(clientIP);\n\n    // TODO: Implement IP-based rate limiting (10 disputes per hour)\n    // For now, rate limiting is handled by re-dispute cooldown (30 days) in DisputeRecord\n\n    // Get DisputeRecord for this CID\n    const disputeId = env.DISPUTE_RECORD.idFromName(`dispute:${data.cid}`);\n    const disputeStub = env.DISPUTE_RECORD.get(disputeId);\n\n    // Check if can create new dispute (re-dispute cooldown)\n    const canDisputeResponse = await disputeStub.fetch(new Request('http://internal/can-dispute'));\n    const canDisputeData = await canDisputeResponse.json();\n\n    if (!canDisputeData.can_dispute) {\n      if (canDisputeData.reason === 'DISPUTE_EXISTS') {\n        return new Response(JSON.stringify({ error: 'DISPUTE_EXISTS' }), {\n          status: 409,\n          headers: { 'Content-Type': 'application/json' }\n        });\n      } else if (canDisputeData.reason === 'REDISPUTE_TOO_SOON') {\n        return new Response(JSON.stringify({ \n          error: 'REDISPUTE_TOO_SOON',\n          days_remaining: canDisputeData.days_remaining\n        }), {\n          status: 429,\n          headers: { 'Content-Type': 'application/json' }\n        });\n      }\n    }\n\n    // Create dispute\n    const disputeRequest = new Request('http://internal/dispute', {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify({\n        cid: data.cid,\n        claim_type: data.claim_type,\n        evidence: data.evidence,\n        evidence_urls: data.evidence_urls || [],\n        contact: data.contact,\n        submitter_ip_hash: ipHash\n      })\n    });\n\n    const disputeResponse = await disputeStub.fetch(disputeRequest);\n\n    if (!disputeResponse.ok) {\n      const error = await disputeResponse.json();\n      return new Response(JSON.stringify(error), {\n        status: disputeResponse.status,\n        headers: { 'Content-Type': 'application/json' }\n      });\n    }\n\n    const disputeData = await disputeResponse.json();\n\n    // Add to DisputeIndex\n    const indexId = env.DISPUTE_INDEX.idFromName('dispute-index:global');\n    const indexStub = env.DISPUTE_INDEX.get(indexId);\n\n    await indexStub.fetch(new Request('http://internal/add', {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify({\n        cid: data.cid,\n        dispute_id: disputeData.dispute.dispute_id,\n        claim_type: data.claim_type,\n        created_at: disputeData.dispute.created_at,\n        expires_at: disputeData.dispute.expires_at\n      })\n    }));\n\n    try {\n      await env.CONTENT_BUCKET.put(`${data.cid}.disputed`, JSON.stringify({\n        dispute_id: disputeData.dispute.dispute_id,\n        created_at: disputeData.dispute.created_at\n      }), {\n        httpMetadata: {\n          contentType: 'application/json'\n        }\n      });\n    } catch (markerError) {\n      console.error('Error writing dispute marker:', markerError);\n    }\n\n    return new Response(JSON.stringify(disputeData), {\n      status: 201,\n      headers: { 'Content-Type': 'application/json' }\n    });\n\n  } catch (error) {\n    console.error('Error creating dispute:', error);\n    return new Response(JSON.stringify({ error: 'INTERNAL_ERROR', message: error.message }), {\n      status: 500,\n      headers: { 'Content-Type': 'application/json' }\n    });\n  }\n}\n\n/**\n * GET /api/disputes\n * List open disputes with caching\n */\nexport async function handleListDisputes(request, env) {\n  try {\n    const url = new URL(request.url);\n    \n    // Get DisputeIndex\n    const indexId = env.DISPUTE_INDEX.idFromName('dispute-index:global');\n    const indexStub = env.DISPUTE_INDEX.get(indexId);\n\n    // Build query string\n    const queryParams = new URLSearchParams();\n    const claimType = url.searchParams.get('claim_type');\n    const limit = url.searchParams.get('limit') || '50';\n    const offset = url.searchParams.get('offset') || '0';\n\n    if (claimType) queryParams.set('claim_type', claimType);\n    queryParams.set('limit', limit);\n    queryParams.set('offset', offset);\n\n    // Fetch from index\n    const listResponse = await indexStub.fetch(\n      new Request(`http://internal/list?${queryParams.toString()}`)\n    );\n\n    const listData = await listResponse.json();\n\n    // Return with caching headers\n    return new Response(JSON.stringify(listData), {\n      status: 200,\n      headers: { \n        'Content-Type': 'application/json',\n        'Last-Modified': listData.cache_info.last_modified,\n        'Cache-Control': 'public, max-age=300' // 5 minutes\n      }\n    });\n\n  } catch (error) {\n    console.error('Error listing disputes:', error);\n    return new Response(JSON.stringify({ error: 'INTERNAL_ERROR', message: error.message }), {\n      status: 500,\n      headers: { 'Content-Type': 'application/json' }\n    });\n  }\n}\n\n/**\n * GET /api/disputes/{dispute_id}\n * Get details of a specific dispute\n */\nexport async function handleGetDispute(request, env, disputeId, userId = null) {\n  try {\n    // Parse CID from dispute_id if it follows pattern dispute:cid\n    // For now, we'll need to find the dispute by scanning or using a different approach\n    // Since we can't easily reverse-lookup, we'll require passing the CID\n    \n    // This is a limitation - we need the CID to look up the dispute\n    // We'll return an error for now and recommend using GET /api/content/{cid}/disputes\n    return new Response(JSON.stringify({ \n      error: 'NOT_IMPLEMENTED',\n      message: 'Use GET /api/content/{cid}/disputes instead'\n    }), {\n      status: 501,\n      headers: { 'Content-Type': 'application/json' }\n    });\n\n  } catch (error) {\n    console.error('Error getting dispute:', error);\n    return new Response(JSON.stringify({ error: 'INTERNAL_ERROR', message: error.message }), {\n      status: 500,\n      headers: { 'Content-Type': 'application/json' }\n    });\n  }\n}\n\n/**\n * GET /api/content/{cid}/disputes\n * Get all disputes for a specific CID\n */\nexport async function handleGetContentDisputes(request, env, cid, userId = null) {\n  try {\n    // Get DisputeRecord for this CID\n    const disputeId = env.DISPUTE_RECORD.idFromName(`dispute:${cid}`);\n    const disputeStub = env.DISPUTE_RECORD.get(disputeId);\n\n    // Get dispute history\n    const historyResponse = await disputeStub.fetch(new Request('http://internal/history'));\n    const historyData = await historyResponse.json();\n\n    // Filter contact info based on authentication\n    const disputes = historyData.disputes.map(dispute => {\n      const filtered = { ...dispute };\n      \n      // Only show contact info to authenticated users\n      if (!userId) {\n        delete filtered.submitter_contact;\n      }\n      \n      return filtered;\n    });\n\n    return new Response(JSON.stringify({ \n      cid,\n      disputes,\n      total: disputes.length\n    }), {\n      status: 200,\n      headers: { 'Content-Type': 'application/json' }\n    });\n\n  } catch (error) {\n    console.error('Error getting content disputes:', error);\n    return new Response(JSON.stringify({ error: 'INTERNAL_ERROR', message: error.message }), {\n      status: 500,\n      headers: { 'Content-Type': 'application/json' }\n    });\n  }\n}\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/api/local-payments.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/api/oauth-authorize-profile-bootstrap.test.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/api/oauth-management.test.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/api/oauth-page.test.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/api/oauth-scope-enforcement.test.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/api/oauth.js","messages":[{"ruleId":"complexity","severity":1,"message":"Async function 'upsertGrant' has a complexity of 11. Maximum allowed is 10.","line":112,"column":1,"nodeType":"FunctionDeclaration","messageId":"complex","endLine":159,"endColumn":2},{"ruleId":"complexity","severity":1,"message":"Async function 'handleOAuthAuthorize' has a complexity of 13. Maximum allowed is 10.","line":221,"column":8,"nodeType":"FunctionDeclaration","messageId":"complex","endLine":295,"endColumn":2},{"ruleId":"complexity","severity":1,"message":"Async function 'handleOAuthToken' has a complexity of 13. Maximum allowed is 10.","line":639,"column":8,"nodeType":"FunctionDeclaration","messageId":"complex","endLine":702,"endColumn":2},{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 18 to the 15 allowed.","line":639,"column":23,"nodeType":null,"messageId":"refactorFunction","endLine":639,"endColumn":39}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":4,"fixableErrorCount":0,"fixableWarningCount":0,"source":"import { authenticate, requireAuth } from '../auth/middleware.js';\nimport {\n  createPkceChallenge,\n  generateOAuthSecret,\n  sha256Hex,\n  signOAuthJwt,\n  verifyOAuthJwt\n} from '../auth/oauth.js';\n\nconst ALLOWED_SCOPES = new Set(['content:write', 'content:read', 'balance:read']);\nconst ACCESS_TOKEN_TTL_SECONDS = 60 * 60;\nconst AUTHORIZATION_CODE_TTL_SECONDS = 10 * 60;\nconst REFRESH_TOKEN_TTL_DAYS = 30;\nconst SCOPE_DESCRIPTIONS = {\n  'content:write': 'Publish immutable content using your account balance and default retention.',\n  'content:read': 'Check whether content exists and inspect metadata.',\n  'balance:read': 'Read your current account balance before publishing.'\n};\n\nfunction createRefreshToken(userId) {\n  return `hbr_${userId}.${generateOAuthSecret('')}`;\n}\n\nfunction extractRefreshTokenUserId(refreshToken) {\n  if (typeof refreshToken !== 'string' || !refreshToken.startsWith('hbr_')) {\n    return null;\n  }\n\n  const separatorIndex = refreshToken.indexOf('.');\n  if (separatorIndex === -1) {\n    return null;\n  }\n\n  const userId = refreshToken.slice(4, separatorIndex);\n  return userId || null;\n}\n\nfunction jsonResponse(body, status = 200, extraHeaders = {}) {\n  return new Response(JSON.stringify(body), {\n    status,\n    headers: {\n      'content-type': 'application/json',\n      ...extraHeaders\n    }\n  });\n}\n\nfunction escapeHtml(value) {\n  return String(value)\n    .replace(/&/g, '&amp;')\n    .replace(/</g, '&lt;')\n    .replace(/>/g, '&gt;')\n    .replace(/\"/g, '&quot;')\n    .replace(/'/g, '&#39;');\n}\n\nfunction htmlResponse(html, status = 200) {\n  return new Response(html, {\n    status,\n    headers: {\n      'content-type': 'text/html; charset=utf-8'\n    }\n  });\n}\n\nasync function getApplication(env, clientId) {\n  const id = env.APPLICATION_REGISTRY.idFromName('global');\n  const stub = env.APPLICATION_REGISTRY.get(id);\n  const response = await stub.fetch(new Request(`http://internal/apps/${clientId}`));\n  if (!response.ok) {\n    return null;\n  }\n  return response.json();\n}\n\nfunction parseScopes(scopeString) {\n  const scopes = (scopeString || '').split(/\\s+/).filter(Boolean);\n  if (scopes.length === 0) {\n    return [];\n  }\n  if (scopes.some((scope) => !ALLOWED_SCOPES.has(scope))) {\n    return null;\n  }\n  return Array.from(new Set(scopes));\n}\n\nfunction validateAuthorizeRequest(data, app) {\n  if (!app || app.status !== 'active') {\n    return { valid: false, error: 'invalid_client', message: 'The requested application could not be found.' };\n  }\n\n  if (data.response_type !== 'code') {\n    return { valid: false, error: 'unsupported_response_type', message: 'Only authorization code flow is supported.' };\n  }\n\n  if (!app.redirect_uris.includes(data.redirect_uri)) {\n    return { valid: false, error: 'invalid_redirect_uri', message: 'The redirect URI is not registered for this application.' };\n  }\n\n  if (!data.code_challenge || data.code_challenge_method !== 'S256') {\n    return { valid: false, error: 'invalid_request', message: 'PKCE with S256 is required.' };\n  }\n\n  const scopes = parseScopes(data.scope);\n  if (scopes === null || scopes.length === 0) {\n    return { valid: false, error: 'invalid_scope', message: 'At least one supported scope is required.' };\n  }\n\n  return { valid: true, scopes };\n}\n\nasync function upsertGrant(env, userId, grantData) {\n  const profileId = env.USER_PROFILES.idFromName(userId);\n  const profileStub = env.USER_PROFILES.get(profileId);\n  const grantRequest = () => new Request('http://internal/oauth/grants', {\n    method: 'POST',\n    headers: { 'content-type': 'application/json' },\n    body: JSON.stringify(grantData)\n  });\n\n  let response = await profileStub.fetch(grantRequest());\n  if (response.status === 404) {\n    // Some Clerk-authenticated users can reach OAuth authorize before profile provisioning completes.\n    const createProfileResponse = await profileStub.fetch(new Request('http://internal/profile', {\n      method: 'POST',\n      headers: { 'content-type': 'application/json' },\n      body: JSON.stringify({\n        user_id: userId,\n        providers: []\n      })\n    }));\n\n    if (createProfileResponse.ok || createProfileResponse.status === 409) {\n      response = await profileStub.fetch(grantRequest());\n    } else {\n      let details = `status=${createProfileResponse.status}`;\n      try {\n        const data = await createProfileResponse.json();\n        details = `${details}, error=${data.error || 'unknown'}, message=${data.message || 'unknown'}`;\n      } catch (_error) {\n        // Ignore non-JSON bodies.\n      }\n      throw new Error(`Failed to create oauth profile: ${details}`);\n    }\n  }\n\n  if (!response.ok) {\n    let details = `status=${response.status}`;\n    try {\n      const data = await response.json();\n      details = `${details}, error=${data.error || 'unknown'}, message=${data.message || 'unknown'}`;\n    } catch (_error) {\n      // Ignore non-JSON bodies.\n    }\n    throw new Error(`Failed to persist oauth grant: ${details}`);\n  }\n\n  return response.json();\n}\n\nasync function storeRefreshToken(env, userId, tokenData) {\n  const profileId = env.USER_PROFILES.idFromName(userId);\n  const profileStub = env.USER_PROFILES.get(profileId);\n  const response = await profileStub.fetch(new Request('http://internal/oauth/refresh-tokens', {\n    method: 'POST',\n    headers: { 'content-type': 'application/json' },\n    body: JSON.stringify(tokenData)\n  }));\n\n  if (!response.ok) {\n    throw new Error('Failed to persist refresh token');\n  }\n\n  return response.json();\n}\n\nasync function rotateRefreshToken(env, userId, tokenData) {\n  const profileId = env.USER_PROFILES.idFromName(userId);\n  const profileStub = env.USER_PROFILES.get(profileId);\n  const response = await profileStub.fetch(new Request('http://internal/oauth/refresh-tokens/rotate', {\n    method: 'POST',\n    headers: { 'content-type': 'application/json' },\n    body: JSON.stringify(tokenData)\n  }));\n\n  if (!response.ok) {\n    return null;\n  }\n\n  return response.json();\n}\n\nexport async function handleCreateDeveloperApp(request, env) {\n  const authResult = await authenticate(request, env);\n  const authError = requireAuth(authResult);\n  if (authError) return authError;\n\n  const data = await request.json();\n  const registryId = env.APPLICATION_REGISTRY.idFromName('global');\n  const registryStub = env.APPLICATION_REGISTRY.get(registryId);\n  return registryStub.fetch(new Request('http://internal/apps', {\n    method: 'POST',\n    headers: { 'content-type': 'application/json' },\n    body: JSON.stringify({\n      ...data,\n      owner_user_id: authResult.user.userId\n    })\n  }));\n}\n\nexport async function handleListDeveloperApps(request, env) {\n  const authResult = await authenticate(request, env);\n  const authError = requireAuth(authResult);\n  if (authError) return authError;\n\n  const registryId = env.APPLICATION_REGISTRY.idFromName('global');\n  const registryStub = env.APPLICATION_REGISTRY.get(registryId);\n  return registryStub.fetch(new Request(`http://internal/apps?owner_user_id=${encodeURIComponent(authResult.user.userId)}`));\n}\n\nexport async function handleOAuthAuthorize(request, env) {\n  const requestId = crypto.randomUUID();\n\n  try {\n    const authResult = await authenticate(request, env);\n    const authError = requireAuth(authResult);\n    if (authError) return authError;\n\n    if (!env.OAUTH_SIGNING_KEY) {\n      return jsonResponse({\n        error: 'server_misconfigured',\n        message: 'OAuth signing key is not configured on the server.',\n        request_id: requestId\n      }, 500);\n    }\n\n    if (!env.USER_PROFILES || !env.APPLICATION_REGISTRY) {\n      return jsonResponse({\n        error: 'server_misconfigured',\n        message: 'Required OAuth bindings are not configured on the server.',\n        request_id: requestId\n      }, 500);\n    }\n\n    const data = await request.json();\n    const app = await getApplication(env, data.client_id);\n    const validation = validateAuthorizeRequest(data, app);\n    if (!validation.valid) {\n      return jsonResponse({ error: validation.error, message: validation.message }, 400);\n    }\n    const scopes = validation.scopes;\n\n    const grant = await upsertGrant(env, authResult.user.userId, {\n      app_id: app.app_id,\n      scopes,\n      spending_limit: data.spending_limit ?? null\n    });\n\n    const now = Math.floor(Date.now() / 1000);\n    const code = await signOAuthJwt({\n      token_type: 'authorization_code',\n      app_id: app.app_id,\n      grant_id: grant.grant_id,\n      user_id: authResult.user.userId,\n      redirect_uri: data.redirect_uri,\n      scopes,\n      code_challenge: data.code_challenge,\n      code_challenge_method: 'S256',\n      spending_limit: grant.spending_limit ?? null,\n      iat: now,\n      exp: now + AUTHORIZATION_CODE_TTL_SECONDS\n    }, env.OAUTH_SIGNING_KEY);\n\n    const redirectUrl = new URL(data.redirect_uri);\n    redirectUrl.searchParams.set('code', code);\n    if (data.state) {\n      redirectUrl.searchParams.set('state', data.state);\n    }\n\n    return Response.redirect(redirectUrl.toString(), 302);\n  } catch (error) {\n    console.error('OAuth authorize failed', {\n      requestId,\n      message: error?.message || 'Unknown error',\n      stack: error?.stack || null\n    });\n\n    return jsonResponse({\n      error: 'authorization_failed',\n      message: 'OAuth authorization failed on the server.',\n      details: error?.message || 'Unknown error',\n      request_id: requestId\n    }, 500);\n  }\n}\n\nexport async function handleGetOAuthAuthorizePage(request, env) {\n  const url = new URL(request.url);\n  const app = await getApplication(env, url.searchParams.get('client_id'));\n  const requestData = {\n    client_id: url.searchParams.get('client_id'),\n    redirect_uri: url.searchParams.get('redirect_uri'),\n    response_type: url.searchParams.get('response_type'),\n    scope: url.searchParams.get('scope') || '',\n    state: url.searchParams.get('state') || '',\n    code_challenge: url.searchParams.get('code_challenge'),\n    code_challenge_method: url.searchParams.get('code_challenge_method')\n  };\n  const validation = validateAuthorizeRequest(requestData, app);\n\n  if (!validation.valid) {\n    return htmlResponse(`<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  <title>Unable to Continue - HashBin.org</title>\n  <style>\n    body { font-family: system-ui, sans-serif; background: #f6f7fb; color: #1f2937; margin: 0; padding: 2rem; }\n    .shell { max-width: 720px; margin: 4rem auto; background: white; border-radius: 18px; padding: 2rem; box-shadow: 0 24px 60px rgba(15, 23, 42, 0.08); }\n    .eyebrow { color: #b91c1c; text-transform: uppercase; letter-spacing: 0.08em; font-size: 0.8rem; font-weight: 700; }\n    h1 { margin: 0.75rem 0 1rem; font-size: 2rem; }\n    p { color: #4b5563; line-height: 1.6; }\n    code { background: #f3f4f6; padding: 0.15rem 0.35rem; border-radius: 6px; }\n  </style>\n</head>\n<body>\n  <div class=\"shell\">\n    <div class=\"eyebrow\">Authorization Error</div>\n    <h1>Unable to Continue</h1>\n    <p>${escapeHtml(validation.message)}</p>\n    <p>Error code: <code>${escapeHtml(validation.error)}</code></p>\n  </div>\n</body>\n</html>`, 400);\n  }\n\n  const scopesHtml = validation.scopes.map((scope) => `\n      <li>\n        <strong>${escapeHtml(scope)}</strong>\n        <span>${escapeHtml(SCOPE_DESCRIPTIONS[scope] || 'Requested access.')}</span>\n      </li>`).join('');\n\n  return htmlResponse(`<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  <title>Authorize ${escapeHtml(app.app_name)} - HashBin.org</title>\n  <style>\n    :root {\n      color-scheme: light;\n      --ink: #13233a;\n      --muted: #5f6f87;\n      --paper: #ffffff;\n      --sky: #edf6ff;\n      --accent: #0f766e;\n      --accent-2: #f59e0b;\n      --line: #d8e4f2;\n    }\n    * { box-sizing: border-box; }\n    body {\n      margin: 0;\n      min-height: 100vh;\n      font-family: Georgia, \"Times New Roman\", serif;\n      color: var(--ink);\n      background:\n        radial-gradient(circle at top left, rgba(245,158,11,0.20), transparent 28%),\n        radial-gradient(circle at top right, rgba(15,118,110,0.18), transparent 30%),\n        linear-gradient(180deg, #f6fbff, #eef4fb 46%, #f8f4eb 100%);\n      padding: 2rem;\n    }\n    .sheet {\n      max-width: 880px;\n      margin: 2rem auto;\n      background: rgba(255,255,255,0.92);\n      backdrop-filter: blur(8px);\n      border: 1px solid rgba(216,228,242,0.9);\n      border-radius: 28px;\n      box-shadow: 0 30px 80px rgba(19,35,58,0.12);\n      overflow: hidden;\n    }\n    .hero {\n      padding: 2rem 2rem 1rem;\n      border-bottom: 1px solid var(--line);\n      background: linear-gradient(135deg, rgba(255,255,255,0.95), rgba(237,246,255,0.95));\n    }\n    .eyebrow {\n      text-transform: uppercase;\n      letter-spacing: 0.14em;\n      font-size: 0.78rem;\n      color: var(--accent);\n      font-weight: 700;\n      margin-bottom: 0.75rem;\n    }\n    h1 {\n      margin: 0;\n      font-size: clamp(2rem, 5vw, 3.25rem);\n      line-height: 1.05;\n    }\n    .hero p {\n      max-width: 44rem;\n      color: var(--muted);\n      font-size: 1.02rem;\n      line-height: 1.7;\n      margin: 1rem 0 0;\n    }\n    .grid {\n      display: grid;\n      grid-template-columns: 1.15fr 0.85fr;\n      gap: 0;\n    }\n    .panel {\n      padding: 2rem;\n    }\n    .panel + .panel {\n      border-left: 1px solid var(--line);\n      background: linear-gradient(180deg, rgba(248,250,252,0.7), rgba(237,246,255,0.55));\n    }\n    h2 {\n      margin: 0 0 1rem;\n      font-size: 1.15rem;\n      letter-spacing: 0.01em;\n    }\n    ul {\n      list-style: none;\n      padding: 0;\n      margin: 0;\n      display: grid;\n      gap: 0.9rem;\n    }\n    li {\n      padding: 1rem 1rem 0.95rem;\n      border-radius: 18px;\n      background: white;\n      border: 1px solid var(--line);\n      box-shadow: 0 10px 24px rgba(19,35,58,0.05);\n    }\n    li strong {\n      display: block;\n      font-size: 1rem;\n      margin-bottom: 0.35rem;\n    }\n    li span {\n      display: block;\n      color: var(--muted);\n      line-height: 1.55;\n      font-size: 0.95rem;\n    }\n    dl { margin: 0; display: grid; gap: 1rem; }\n    dt { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin-bottom: 0.25rem; }\n    dd { margin: 0; font-size: 1rem; line-height: 1.55; word-break: break-word; }\n    .actions {\n      display: flex;\n      flex-wrap: wrap;\n      gap: 0.9rem;\n      margin-top: 1.5rem;\n    }\n    button {\n      border: none;\n      border-radius: 999px;\n      padding: 0.95rem 1.4rem;\n      font-size: 0.98rem;\n      font-weight: 700;\n      cursor: pointer;\n    }\n    .approve { background: var(--ink); color: white; }\n    .deny { background: transparent; color: var(--ink); border: 1px solid var(--line); }\n    .status {\n      margin-top: 1rem;\n      min-height: 1.5rem;\n      color: var(--muted);\n      font-size: 0.95rem;\n    }\n    @media (max-width: 860px) {\n      body { padding: 1rem; }\n      .grid { grid-template-columns: 1fr; }\n      .panel + .panel { border-left: none; border-top: 1px solid var(--line); }\n    }\n  </style>\n</head>\n<body>\n  <div class=\"sheet\">\n    <section class=\"hero\">\n      <div class=\"eyebrow\">Third-Party Publishing</div>\n      <h1>${escapeHtml(app.app_name)} wants to use your HashBin account.</h1>\n      <p>\n        Review the requested access below. Approving will let this app publish content with your balance using your current default retention settings.\n      </p>\n    </section>\n    <div class=\"grid\">\n      <section class=\"panel\">\n        <h2>Requested permissions</h2>\n        <ul>${scopesHtml}</ul>\n      </section>\n      <aside class=\"panel\">\n        <h2>App details</h2>\n        <dl>\n          <div>\n            <dt>Application</dt>\n            <dd>${escapeHtml(app.app_name)}</dd>\n          </div>\n          <div>\n            <dt>Redirect URI</dt>\n            <dd>${escapeHtml(requestData.redirect_uri)}</dd>\n          </div>\n          <div>\n            <dt>State</dt>\n            <dd>${escapeHtml(requestData.state || '(none)')}</dd>\n          </div>\n        </dl>\n        <div class=\"actions\">\n          <button class=\"approve\" id=\"approve-button\">Approve access</button>\n          <button class=\"deny\" id=\"deny-button\">Deny</button>\n        </div>\n        <div class=\"status\" id=\"status-message\">Sign in to HashBin if prompted, then approve to continue.</div>\n      </aside>\n    </div>\n  </div>\n  <script type=\"module\">\n    import { initializeAuth, getAuthHeaders, signIn } from '/js/auth-loader.js';\n\n    const authorizePayload = ${JSON.stringify(requestData)};\n    const statusMessage = document.getElementById('status-message');\n    const approveButton = document.getElementById('approve-button');\n    const denyButton = document.getElementById('deny-button');\n\n    function redirectDenied() {\n      const deniedUrl = new URL(authorizePayload.redirect_uri);\n      deniedUrl.searchParams.set('error', 'access_denied');\n      if (authorizePayload.state) {\n        deniedUrl.searchParams.set('state', authorizePayload.state);\n      }\n      window.location.href = deniedUrl.toString();\n    }\n\n    denyButton.addEventListener('click', () => {\n      redirectDenied();\n    });\n\n    approveButton.addEventListener('click', async () => {\n      approveButton.disabled = true;\n      statusMessage.textContent = 'Checking your session...';\n\n      try {\n        await initializeAuth();\n        let authHeaders = await getAuthHeaders();\n\n        if (!authHeaders) {\n          statusMessage.textContent = 'Sign-in is required before you can approve this request.';\n          await signIn();\n          authHeaders = await getAuthHeaders();\n        }\n\n        if (!authHeaders) {\n          approveButton.disabled = false;\n          return;\n        }\n\n        statusMessage.textContent = 'Authorizing application...';\n        const response = await fetch('/oauth/authorize', {\n          method: 'POST',\n          headers: {\n            'content-type': 'application/json',\n            ...authHeaders\n          },\n          body: JSON.stringify(authorizePayload)\n        });\n\n        if (response.redirected) {\n          window.location.href = response.url;\n          return;\n        }\n\n        if (response.status === 302) {\n          const location = response.headers.get('location');\n          if (location) {\n            window.location.href = location;\n            return;\n          }\n        }\n\n        let errorMessage = 'Authorization failed.';\n        try {\n          const errorData = await response.json();\n          const statusLabel = response.status ? ('HTTP ' + response.status) : 'HTTP error';\n          const primary = errorData.message || errorData.error || errorMessage;\n          const details = errorData.details ? (' Details: ' + errorData.details) : '';\n          const requestId = errorData.request_id ? (' Request ID: ' + errorData.request_id) : '';\n          errorMessage = primary + ' (' + statusLabel + ').' + details + requestId;\n        } catch (_error) {\n          errorMessage = 'Authorization failed (HTTP ' + response.status + '). The server returned a non-JSON error response.';\n        }\n\n        statusMessage.textContent = errorMessage;\n        approveButton.disabled = false;\n      } catch (error) {\n        statusMessage.textContent = error.message || 'Authorization failed.';\n        approveButton.disabled = false;\n      }\n    });\n  </script>\n</body>\n</html>`);\n}\n\nasync function issueTokenPair(env, codePayload) {\n  const now = Math.floor(Date.now() / 1000);\n  const accessToken = await signOAuthJwt({\n    token_type: 'access',\n    grant_id: codePayload.grant_id,\n    app_id: codePayload.app_id,\n    user_id: codePayload.user_id,\n    scopes: codePayload.scopes,\n    iat: now,\n    exp: now + ACCESS_TOKEN_TTL_SECONDS\n  }, env.OAUTH_SIGNING_KEY);\n\n  const refreshToken = createRefreshToken(codePayload.user_id);\n  const refreshTokenHash = await sha256Hex(refreshToken);\n  const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000).toISOString();\n\n  await storeRefreshToken(env, codePayload.user_id, {\n    token_hash: refreshTokenHash,\n    grant_id: codePayload.grant_id,\n    app_id: codePayload.app_id,\n    expires_at: expiresAt\n  });\n\n  return {\n    access_token: accessToken,\n    refresh_token: refreshToken,\n    token_type: 'Bearer',\n    expires_in: ACCESS_TOKEN_TTL_SECONDS,\n    scope: codePayload.scopes.join(' ')\n  };\n}\n\nexport async function handleOAuthToken(request, env) {\n  const data = await request.json();\n\n  if (data.grant_type === 'authorization_code') {\n    let codePayload;\n    try {\n      codePayload = await verifyOAuthJwt(data.code, env.OAUTH_SIGNING_KEY);\n    } catch (error) {\n      return jsonResponse({\n        error: error.code === 'expired' ? 'invalid_grant' : 'invalid_request'\n      }, 400);\n    }\n\n    if (codePayload.token_type !== 'authorization_code' || codePayload.app_id !== data.client_id || codePayload.redirect_uri !== data.redirect_uri) {\n      return jsonResponse({ error: 'invalid_grant' }, 400);\n    }\n\n    const expectedChallenge = await createPkceChallenge(data.code_verifier || '');\n    if (expectedChallenge !== codePayload.code_challenge) {\n      return jsonResponse({ error: 'invalid_grant' }, 400);\n    }\n\n    return jsonResponse(await issueTokenPair(env, codePayload));\n  }\n\n  if (data.grant_type === 'refresh_token') {\n    const userId = extractRefreshTokenUserId(data.refresh_token);\n    if (!userId) {\n      return jsonResponse({ error: 'invalid_grant' }, 400);\n    }\n\n    const refreshTokenHash = await sha256Hex(data.refresh_token || '');\n    const newRefreshToken = createRefreshToken(userId);\n    const rotated = await rotateRefreshToken(env, userId, {\n      current_token_hash: refreshTokenHash,\n      new_token_hash: await sha256Hex(newRefreshToken),\n      new_expires_at: new Date(Date.now() + REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000).toISOString()\n    });\n\n    if (!rotated) {\n      return jsonResponse({ error: 'invalid_grant' }, 400);\n    }\n\n    const accessToken = await signOAuthJwt({\n      token_type: 'access',\n      grant_id: rotated.grant_id,\n      app_id: rotated.app_id,\n      user_id: rotated.user_id,\n      scopes: rotated.scopes,\n      iat: Math.floor(Date.now() / 1000),\n      exp: Math.floor(Date.now() / 1000) + ACCESS_TOKEN_TTL_SECONDS\n    }, env.OAUTH_SIGNING_KEY);\n\n    return jsonResponse({\n      access_token: accessToken,\n      refresh_token: newRefreshToken,\n      token_type: 'Bearer',\n      expires_in: ACCESS_TOKEN_TTL_SECONDS,\n      scope: rotated.scopes.join(' ')\n    });\n  }\n\n  return jsonResponse({ error: 'unsupported_grant_type' }, 400);\n}\n\nexport async function handleGetAccountSettings(request, env) {\n  const authResult = await authenticate(request, env);\n  const authError = requireAuth(authResult);\n  if (authError) return authError;\n\n  const profileId = env.USER_PROFILES.idFromName(authResult.user.userId);\n  const profileStub = env.USER_PROFILES.get(profileId);\n  return profileStub.fetch(new Request('http://internal/settings'));\n}\n\nexport async function handleUpdateAccountSettings(request, env) {\n  const authResult = await authenticate(request, env);\n  const authError = requireAuth(authResult);\n  if (authError) return authError;\n\n  const profileId = env.USER_PROFILES.idFromName(authResult.user.userId);\n  const profileStub = env.USER_PROFILES.get(profileId);\n  return profileStub.fetch(new Request('http://internal/settings', {\n    method: 'PATCH',\n    headers: { 'content-type': 'application/json' },\n    body: await request.text()\n  }));\n}\n\nexport async function handleListAuthorizations(request, env) {\n  const authResult = await authenticate(request, env);\n  const authError = requireAuth(authResult);\n  if (authError) return authError;\n\n  const profileId = env.USER_PROFILES.idFromName(authResult.user.userId);\n  const profileStub = env.USER_PROFILES.get(profileId);\n  const grantsResponse = await profileStub.fetch(new Request('http://internal/oauth/grants'));\n  if (!grantsResponse.ok) {\n    return grantsResponse;\n  }\n\n  const grantsData = await grantsResponse.json();\n  const authorizations = await Promise.all((grantsData.authorizations || []).map(async (authorization) => {\n    const app = await getApplication(env, authorization.app_id);\n    return {\n      ...authorization,\n      app_name: app?.app_name || authorization.app_id,\n      redirect_uris: app?.redirect_uris || [],\n      website_url: app?.website_url || null,\n      logo_url: app?.logo_url || null\n    };\n  }));\n\n  return jsonResponse({ authorizations });\n}\n\nexport async function handleRevokeAuthorization(request, env, appId) {\n  const authResult = await authenticate(request, env);\n  const authError = requireAuth(authResult);\n  if (authError) return authError;\n\n  const profileId = env.USER_PROFILES.idFromName(authResult.user.userId);\n  const profileStub = env.USER_PROFILES.get(profileId);\n  return profileStub.fetch(new Request(`http://internal/oauth/grants/${appId}`, {\n    method: 'DELETE'\n  }));\n}\n\nexport async function handleOAuthRevoke(request, env) {\n  const data = await request.json();\n  const token = data.token;\n\n  if (data.token_type_hint === 'refresh_token') {\n    const userId = extractRefreshTokenUserId(token);\n    if (!userId) {\n      return new Response(null, { status: 200 });\n    }\n\n    const profileId = env.USER_PROFILES.idFromName(userId);\n    const profileStub = env.USER_PROFILES.get(profileId);\n    await profileStub.fetch(new Request('http://internal/oauth/refresh-tokens/revoke', {\n      method: 'POST',\n      headers: { 'content-type': 'application/json' },\n      body: JSON.stringify({\n        token_hash: await sha256Hex(token)\n      })\n    }));\n    return new Response(null, { status: 200 });\n  }\n\n  try {\n    const payload = await verifyOAuthJwt(token, env.OAUTH_SIGNING_KEY);\n    const profileId = env.USER_PROFILES.idFromName(payload.user_id);\n    const profileStub = env.USER_PROFILES.get(profileId);\n    await profileStub.fetch(new Request(`http://internal/oauth/grants-by-id/${payload.grant_id}`, {\n      method: 'POST'\n    }));\n  } catch (_error) {\n    // OAuth revocation is intentionally idempotent and quiet.\n  }\n\n  return new Response(null, { status: 200 });\n}\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/api/oauth.test.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/api/payments.js","messages":[{"ruleId":"complexity","severity":1,"message":"Async function 'handleCheckoutSessionCompleted' has a complexity of 14. Maximum allowed is 10.","line":271,"column":1,"nodeType":"FunctionDeclaration","messageId":"complex","endLine":453,"endColumn":2},{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 24 to the 15 allowed.","line":271,"column":16,"nodeType":null,"messageId":"refactorFunction","endLine":271,"endColumn":46},{"ruleId":"no-unused-vars","severity":1,"message":"'env' is defined but never used. Allowed unused args must match /^_/u.","line":586,"column":57,"nodeType":"Identifier","messageId":"unusedVar","endLine":586,"endColumn":60}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":3,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Payments API Handlers\n * Endpoints for Stripe payments, deposits, and donations\n */\n\nimport Stripe from 'stripe';\nimport { authenticate } from '../auth/middleware.js';\nimport { handleLocalDepositUnavailable, handleLocalDonationUnavailable } from './local-payments.js';\nimport { \n  calculateTotalWithFees,\n  formatCents, \n  calculateRetentionCost,\n  BASE_RATE_PER_GB_PER_MONTH\n} from '../utils/pricing.js';\nimport { recordDeposit, recordDispute } from '../utils/platform-stats.js';\n\n/**\n * POST /api/balance/deposit\n * Create a Stripe checkout session for depositing funds\n */\nexport async function handleCreateDeposit(request, env) {\n  if (env.ENVIRONMENT === 'local') {\n    return handleLocalDepositUnavailable();\n  }\n\n  const authResult = await authenticate(request, env);\n  \n  if (!authResult.authenticated) {\n    return new Response(\n      JSON.stringify({\n        error: 'Unauthorized',\n        message: 'Authentication required'\n      }),\n      {\n        status: 401,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  try {\n    const data = await request.json();\n    const amount_cents = data.amount_cents;\n\n    // Validate amount\n    if (!amount_cents || amount_cents < 100) {\n      return new Response(\n        JSON.stringify({\n          error: 'Invalid amount',\n          message: 'Minimum deposit is $1.00 (100 cents)'\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Validate amount is an integer\n    if (!Number.isInteger(amount_cents)) {\n      return new Response(\n        JSON.stringify({\n          error: 'Invalid amount',\n          message: 'Amount must be a whole number of cents'\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Validate amount is positive\n    if (amount_cents <= 0) {\n      return new Response(\n        JSON.stringify({\n          error: 'Invalid amount',\n          message: 'Amount must be greater than zero'\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Calculate total charge (credit amount + fees)\n    // User wants amount_cents credit, so we calculate how much to charge them\n    const feeBreakdown = calculateTotalWithFees(amount_cents);\n\n    // Initialize Stripe\n    const stripe = new Stripe(env.STRIPE_SECRET_KEY, {\n      apiVersion: '2024-11-20.acacia'\n    });\n\n    // Determine URLs based on environment\n    const baseUrl = env.ENVIRONMENT === 'production' \n      ? 'https://hashbin.org' \n      : 'https://hashbin-worker-dev.curtcox.workers.dev';\n\n    // Create Stripe Checkout session\n    const session = await stripe.checkout.sessions.create({\n      payment_method_types: ['card'],\n      line_items: [\n        {\n          price_data: {\n            currency: 'usd',\n            product_data: {\n              name: 'HashBin.org Account Deposit',\n              description: `Deposit ${formatCents(amount_cents)} to your HashBin account`\n            },\n            unit_amount: feeBreakdown.totalChargeCents\n          },\n          quantity: 1\n        }\n      ],\n      mode: 'payment',\n      success_url: `${baseUrl}/deposit?status=success&session_id={CHECKOUT_SESSION_ID}`,\n      cancel_url: `${baseUrl}/deposit?status=cancel`,\n      client_reference_id: authResult.user.userId,\n      metadata: {\n        user_id: authResult.user.userId,\n        type: 'deposit',\n        amount_cents: amount_cents.toString()\n      },\n      customer_email: authResult.user.email,\n      automatic_tax: {\n        enabled: true\n      }\n    });\n\n    return new Response(\n      JSON.stringify({\n        checkout_url: session.url,\n        session_id: session.id,\n        amount_breakdown: {\n          credit_cents: feeBreakdown.creditCents,\n          fee_cents: feeBreakdown.feeCents,\n          total_charge_cents: feeBreakdown.totalChargeCents\n        }\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  } catch (error) {\n    return new Response(\n      JSON.stringify({\n        error: 'Internal error',\n        message: error.message\n      }),\n      {\n        status: 500,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n}\n\n/**\n * POST /api/payments/webhook\n * Handle Stripe webhook events\n */\nexport async function handleStripeWebhook(request, env) {\n  if (env.ENVIRONMENT === 'local') {\n    return new Response(\n      JSON.stringify({\n        error: 'Stripe disabled in local mode',\n        message: 'Stripe webhooks are not processed in local mode.'\n      }),\n      {\n        status: 400,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  try {\n    const stripe = new Stripe(env.STRIPE_SECRET_KEY, {\n      apiVersion: '2024-11-20.acacia'\n    });\n\n    // Get the signature from headers\n    const signature = request.headers.get('stripe-signature');\n    if (!signature) {\n      return new Response(\n        JSON.stringify({\n          error: 'Missing signature'\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Get raw body\n    const body = await request.text();\n\n    // Verify webhook signature\n    let event;\n    try {\n      event = await stripe.webhooks.constructEventAsync(\n        body,\n        signature,\n        env.STRIPE_WEBHOOK_SECRET\n      );\n    } catch (err) {\n      return new Response(\n        JSON.stringify({\n          error: 'Invalid signature',\n          message: err.message\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Handle the event\n    switch (event.type) {\n      case 'checkout.session.completed':\n        await handleCheckoutSessionCompleted(event.data.object, env);\n        break;\n      \n      case 'checkout.session.expired':\n        // Log for debugging - no action needed\n        console.log('Checkout session expired:', event.data.object.id);\n        break;\n      \n      case 'charge.dispute.created':\n        // Log dispute for admin review\n        console.error('Dispute created:', event.data.object);\n        // Record dispute in platform stats\n        await recordDispute(env);\n        // Create alert for admin\n        await createDisputeAlert(env, event.data.object);\n        break;\n      \n      default:\n        console.log(`Unhandled event type: ${event.type}`);\n    }\n\n    return new Response(\n      JSON.stringify({ received: true }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  } catch (error) {\n    console.error('Webhook error:', error);\n    return new Response(\n      JSON.stringify({\n        error: 'Webhook processing failed',\n        message: error.message\n      }),\n      {\n        status: 500,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n}\n\n/**\n * Handle successful checkout session\n */\nasync function handleCheckoutSessionCompleted(session, env) {\n  const userId = session.client_reference_id || session.metadata.user_id;\n  const type = session.metadata.type;\n  const amount_cents = parseInt(session.metadata.amount_cents);\n\n  if (!userId) {\n    console.error('No user_id in checkout session:', session.id);\n    return;\n  }\n\n  // Check if this session has already been processed (idempotency check)\n  const paymentRecordId = env.PAYMENT_RECORDS.idFromName(userId);\n  const paymentRecordStub = env.PAYMENT_RECORDS.get(paymentRecordId);\n  \n  const existsResponse = await paymentRecordStub.fetch(\n    new Request(`http://internal/session/exists?session_id=${session.id}`)\n  );\n  const existsData = await existsResponse.json();\n  \n  if (existsData.exists) {\n    console.log(`Session ${session.id} already processed. Skipping duplicate webhook.`);\n    return;\n  }\n\n  if (type === 'deposit') {\n    // Credit user balance\n    const userProfileId = env.USER_PROFILES.idFromName(userId);\n    const userProfileStub = env.USER_PROFILES.get(userProfileId);\n\n    // Get current balance before deposit\n    const balanceResponse = await userProfileStub.fetch(\n      new Request('http://internal/balance')\n    );\n    const balanceData = await balanceResponse.json();\n    const balance_before = balanceData.balance_cents;\n\n    // Deposit to balance\n    const depositResponse = await userProfileStub.fetch(\n      new Request('http://internal/balance/deposit', {\n        method: 'POST',\n        headers: { 'content-type': 'application/json' },\n        body: JSON.stringify({ amount_cents })\n      })\n    );\n\n    if (depositResponse.ok) {\n      const depositData = await depositResponse.json();\n      \n      // Record transaction\n      const transactionId = crypto.randomUUID();\n      const paymentRecordId = env.PAYMENT_RECORDS.idFromName(userId);\n      const paymentRecordStub = env.PAYMENT_RECORDS.get(paymentRecordId);\n\n      // Calculate Stripe fee from the total charged amount\n      // The session metadata contains the credit amount the user receives\n      // We need to calculate the fee based on the actual charge\n      const totalCharged = session.amount_total; // in cents\n      const stripeFee = totalCharged - amount_cents; // fee = total - credit\n\n      await paymentRecordStub.fetch(\n        new Request('http://internal/transaction', {\n          method: 'POST',\n          headers: { 'content-type': 'application/json' },\n          body: JSON.stringify({\n            transaction_id: transactionId,\n            type: 'deposit',\n            user_id: userId,\n            amount_cents: amount_cents,\n            balance_before_cents: balance_before,\n            balance_after_cents: depositData.balance_after_cents,\n            stripe_session_id: session.id,\n            stripe_payment_intent: session.payment_intent,\n            stripe_fee_cents: stripeFee\n          })\n        })\n      );\n\n      console.log(`Deposit completed: ${amount_cents} cents for user ${userId}`);\n      \n      // Record platform statistics\n      await recordDeposit(env, amount_cents);\n      \n      // TODO: Send receipt email\n    }\n  } else if (type === 'donation') {\n    // Handle CID donation\n    const cid = session.metadata.cid;\n    const donorId = session.metadata.donor_id === 'anonymous' ? null : session.metadata.donor_id;\n\n    // Get content metadata to find size\n    const contentMetadataId = env.CONTENT_METADATA.idFromName(cid);\n    const contentMetadataStub = env.CONTENT_METADATA.get(contentMetadataId);\n    \n    const contentResponse = await contentMetadataStub.fetch(\n      new Request('http://internal/content')\n    );\n\n    if (contentResponse.ok) {\n      const content = await contentResponse.json();\n      const size_bytes = content.size_bytes;\n\n      // Calculate months to add\n      const donationDollars = amount_cents / 100;\n      const sizeGB = size_bytes / (1024 * 1024 * 1024);\n      const monthsToAdd = donationDollars / (sizeGB * BASE_RATE_PER_GB_PER_MONTH);\n\n      // Extend retention\n      const transactionId = crypto.randomUUID();\n      const extendResponse = await contentMetadataStub.fetch(\n        new Request('http://internal/extend', {\n          method: 'POST',\n          headers: { 'content-type': 'application/json' },\n          body: JSON.stringify({\n            months_to_add: monthsToAdd,\n            amount_cents: amount_cents,\n            payer_id: donorId,\n            payment_id: transactionId\n          })\n        })\n      );\n\n      const extendData = await extendResponse.json();\n\n      // Update ExpirationIndex with new expiration date\n      if (content.expires_at && extendData.expires_at) {\n        try {\n          const expirationIndexId = env.EXPIRATION_INDEX.idFromName('global');\n          const expirationIndexStub = env.EXPIRATION_INDEX.get(expirationIndexId);\n          \n          await expirationIndexStub.fetch(\n            new Request('http://internal/update', {\n              method: 'POST',\n              headers: { 'content-type': 'application/json' },\n              body: JSON.stringify({\n                hash_256t: cid,\n                old_expires_at: content.expires_at,\n                new_expires_at: extendData.expires_at\n              })\n            })\n          );\n        } catch (error) {\n          // Log error but don't fail the donation\n          console.error('Failed to update ExpirationIndex:', error);\n        }\n      }\n\n      // If donor is authenticated, record transaction\n      if (donorId) {\n        const paymentRecordId = env.PAYMENT_RECORDS.idFromName(donorId);\n        const paymentRecordStub = env.PAYMENT_RECORDS.get(paymentRecordId);\n\n        // Calculate Stripe fee\n        const totalCharged = session.amount_total; // in cents\n        const stripeFee = totalCharged - amount_cents;\n\n        await paymentRecordStub.fetch(\n          new Request('http://internal/transaction', {\n            method: 'POST',\n            headers: { 'content-type': 'application/json' },\n            body: JSON.stringify({\n              transaction_id: transactionId,\n              type: 'donation_received',\n              user_id: donorId,\n              amount_cents: amount_cents,\n              balance_before_cents: 0,\n              balance_after_cents: 0,\n              stripe_session_id: session.id,\n              stripe_payment_intent: session.payment_intent,\n              stripe_fee_cents: stripeFee,\n              cid: cid,\n              retention_months: monthsToAdd\n            })\n          })\n        );\n      }\n\n      console.log(`Donation completed: ${amount_cents} cents for CID ${cid} by ${donorId || 'anonymous'}`);\n      // Note: No email notification for donations per requirements\n    } else {\n      console.error(`Failed to process donation for CID ${cid}: content not found`);\n    }\n  }\n}\n\n/**\n * POST /api/donate/cid/:cid\n * Create a Stripe checkout session for donating to a CID (anonymous allowed)\n */\nexport async function handleCreateDonation(request, env, cid) {\n  if (env.ENVIRONMENT === 'local') {\n    return handleLocalDonationUnavailable();\n  }\n\n  try {\n    const data = await request.json();\n    const amount_cents = data.amount_cents;\n\n    // Validate amount\n    if (!amount_cents || amount_cents < 100) {\n      return new Response(\n        JSON.stringify({\n          error: 'Invalid amount',\n          message: 'Minimum donation is $1.00 (100 cents)'\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Check if content exists\n    const contentMetadataId = env.CONTENT_METADATA.idFromName(cid);\n    const contentMetadataStub = env.CONTENT_METADATA.get(contentMetadataId);\n    \n    const existsResponse = await contentMetadataStub.fetch(\n      new Request('http://internal/exists')\n    );\n    const existsData = await existsResponse.json();\n\n    if (!existsData.exists) {\n      return new Response(\n        JSON.stringify({\n          error: 'Content not found',\n          message: 'The specified CID does not exist'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Calculate how many months this donation provides\n    const donationDollars = amount_cents / 100;\n    const sizeGB = existsData.size_bytes / (1024 * 1024 * 1024);\n    const monthsAdded = donationDollars / (sizeGB * BASE_RATE_PER_GB_PER_MONTH);\n\n    // Calculate new expiration\n    const currentExpiration = new Date(existsData.expires_at);\n    const newExpiration = new Date(currentExpiration);\n    newExpiration.setMonth(newExpiration.getMonth() + Math.floor(monthsAdded));\n\n    // Initialize Stripe\n    const stripe = new Stripe(env.STRIPE_SECRET_KEY, {\n      apiVersion: '2024-11-20.acacia'\n    });\n\n    // Determine URLs based on environment\n    const baseUrl = env.ENVIRONMENT === 'production' \n      ? 'https://hashbin.org' \n      : 'https://hashbin-worker-dev.curtcox.workers.dev';\n\n    // Get optional donor user ID\n    const authResult = await authenticate(request, env);\n    const donorId = authResult.authenticated ? authResult.user.userId : null;\n\n    // Create Stripe Checkout session\n    const session = await stripe.checkout.sessions.create({\n      payment_method_types: ['card'],\n      line_items: [\n        {\n          price_data: {\n            currency: 'usd',\n            product_data: {\n              name: `Extend HashBin Content (${cid.substring(0, 12)}...)`,\n              description: `Donate ${formatCents(amount_cents)} to extend retention by ~${Math.floor(monthsAdded)} months`\n            },\n            unit_amount: amount_cents\n          },\n          quantity: 1\n        }\n      ],\n      mode: 'payment',\n      success_url: `${baseUrl}/content/${cid}?donation=success&session_id={CHECKOUT_SESSION_ID}`,\n      cancel_url: `${baseUrl}/content/${cid}?donation=cancel`,\n      client_reference_id: donorId,\n      metadata: {\n        type: 'donation',\n        cid: cid,\n        amount_cents: amount_cents.toString(),\n        donor_id: donorId || 'anonymous'\n      },\n      automatic_tax: {\n        enabled: true\n      }\n    });\n\n    return new Response(\n      JSON.stringify({\n        checkout_url: session.url,\n        session_id: session.id,\n        estimated_months_added: Math.floor(monthsAdded),\n        estimated_new_expiration: newExpiration.toISOString(),\n        current_expiration: existsData.expires_at,\n        amount_cents: amount_cents\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  } catch (error) {\n    return new Response(\n      JSON.stringify({\n        error: 'Donation creation failed',\n        message: error.message\n      }),\n      {\n        status: 500,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n}\nexport async function handleCalculateRetention(request, env) {\n  try {\n    const data = await request.json();\n    const size_bytes = data.size_bytes;\n    const retention_months = data.retention_months;\n\n    if (!size_bytes || size_bytes < 0) {\n      return new Response(\n        JSON.stringify({\n          error: 'Invalid size',\n          message: 'Size must be a positive number'\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    if (!retention_months || retention_months < 1) {\n      return new Response(\n        JSON.stringify({\n          error: 'Invalid retention',\n          message: 'Minimum retention is 1 month (30 days)'\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    const cost_cents = calculateRetentionCost(size_bytes, retention_months);\n\n    return new Response(\n      JSON.stringify({\n        size_bytes: size_bytes,\n        retention_months: retention_months,\n        cost_cents: cost_cents,\n        cost_formatted: formatCents(cost_cents)\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  } catch (error) {\n    return new Response(\n      JSON.stringify({\n        error: 'Calculation failed',\n        message: error.message\n      }),\n      {\n        status: 400,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n}\n\n/**\n * Create an alert for a Stripe dispute\n */\nasync function createDisputeAlert(env, dispute) {\n  try {\n    const alertId = env.ALERT_STORE.idFromName('global');\n    const alertStub = env.ALERT_STORE.get(alertId);\n    \n    await alertStub.fetch(\n      new Request('https://dummy/create', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({\n          type: 'dispute_created',\n          severity: 'critical',\n          title: 'New Stripe Dispute Received',\n          message: `Dispute for payment ${dispute.payment_intent} - Amount: $${(dispute.amount / 100).toFixed(2)}`,\n          metadata: {\n            dispute_id: dispute.id,\n            payment_intent_id: dispute.payment_intent,\n            amount_cents: dispute.amount,\n            reason: dispute.reason,\n            status: dispute.status\n          }\n        })\n      })\n    );\n  } catch (error) {\n    console.error('Failed to create dispute alert:', error);\n  }\n}\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/api/payments.test.js","messages":[{"ruleId":"no-unused-vars","severity":1,"message":"'apiKey' is defined but never used. Allowed unused args must match /^_/u.","line":16,"column":42,"nodeType":"Identifier","messageId":"unusedVar","endLine":16,"endColumn":48},{"ruleId":"no-unused-vars","severity":1,"message":"'options' is defined but never used. Allowed unused args must match /^_/u.","line":16,"column":50,"nodeType":"Identifier","messageId":"unusedVar","endLine":16,"endColumn":57}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Payments API Security Tests (P0 Priority)\n * Tests for src/api/payments.js webhook security\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { handleStripeWebhook } from './payments.js';\nimport { \n  mockConstructEvent,\n  createCheckoutSessionCompletedEvent \n} from '../../test-utils/stripe-mock.js';\n\n// Mock Stripe module\nvi.mock('stripe', () => {\n  return {\n    default: vi.fn().mockImplementation((apiKey, options) => {\n      return {\n        webhooks: {\n          constructEventAsync: async (body, signature, secret) => {\n            return mockConstructEvent(body, signature, secret);\n          }\n        }\n      };\n    })\n  };\n});\n\ndescribe('Payments API - P0 Security Tests', () => {\n  let mockEnv;\n  let processedEvents;\n\n  beforeEach(() => {\n    processedEvents = new Set();\n    \n    mockEnv = {\n      STRIPE_SECRET_KEY: 'sk_test_123',\n      STRIPE_WEBHOOK_SECRET: 'test_webhook_secret',\n      ENVIRONMENT: 'test',\n      USER_PROFILES: createMockUserProfiles(),\n      PAYMENT_RECORDS: createMockPaymentRecords(),\n      AUDIT_LOG: createMockAuditLog(),\n      PLATFORM_STATS: createMockPlatformStats(),\n    };\n  });\n\n  // SEC-10: Webhook signature validation\n  describe('SEC-10: Webhook signature validation', () => {\n    it('should validate webhook signature before processing', async () => {\n      const event = createCheckoutSessionCompletedEvent();\n      const body = JSON.stringify(event);\n      \n      const request = new Request('http://test.com/webhooks/stripe', {\n        method: 'POST',\n        headers: {\n          'stripe-signature': 'valid_signature',\n          'content-type': 'application/json',\n        },\n        body,\n      });\n\n      const response = await handleStripeWebhook(request, mockEnv);\n      const result = await response.json();\n\n      expect(response.status).toBe(200);\n      expect(result.received).toBe(true);\n    });\n\n    it('should reject webhooks with invalid signatures', async () => {\n      const event = createCheckoutSessionCompletedEvent();\n      const body = JSON.stringify(event);\n      \n      const request = new Request('http://test.com/webhooks/stripe', {\n        method: 'POST',\n        headers: {\n          'stripe-signature': 'invalid_signature',\n          'content-type': 'application/json',\n        },\n        body,\n      });\n\n      const response = await handleStripeWebhook(request, mockEnv);\n      const result = await response.json();\n\n      expect(response.status).toBe(400);\n      expect(result.error).toBe('Invalid signature');\n    });\n  });\n\n  // STRIPE-01: Accept valid Stripe signature\n  describe('STRIPE-01: Accept valid signature', () => {\n    it('should accept webhooks with valid signature', async () => {\n      const event = createCheckoutSessionCompletedEvent({\n        data: {\n          object: {\n            client_reference_id: 'user_123',\n            metadata: {\n              user_id: 'user_123',\n              type: 'deposit',\n              amount_cents: '10000',\n            },\n          },\n        },\n      });\n      const body = JSON.stringify(event);\n      \n      const request = new Request('http://test.com/webhooks/stripe', {\n        method: 'POST',\n        headers: {\n          'stripe-signature': 'valid_signature',\n          'content-type': 'application/json',\n        },\n        body,\n      });\n\n      const response = await handleStripeWebhook(request, mockEnv);\n\n      expect(response.status).toBe(200);\n    });\n\n    it('should process valid webhook events', async () => {\n      const event = createCheckoutSessionCompletedEvent({\n        data: {\n          object: {\n            client_reference_id: 'user_456',\n            metadata: {\n              user_id: 'user_456',\n              type: 'deposit',\n              amount_cents: '5000',\n            },\n          },\n        },\n      });\n      const body = JSON.stringify(event);\n      \n      const request = new Request('http://test.com/webhooks/stripe', {\n        method: 'POST',\n        headers: {\n          'stripe-signature': 'valid_signature',\n          'content-type': 'application/json',\n        },\n        body,\n      });\n\n      const response = await handleStripeWebhook(request, mockEnv);\n      const result = await response.json();\n\n      expect(response.status).toBe(200);\n      expect(result.received).toBe(true);\n    });\n  });\n\n  // STRIPE-02: Reject missing signature\n  describe('STRIPE-02: Reject missing signature', () => {\n    it('should reject webhooks without signature header', async () => {\n      const event = createCheckoutSessionCompletedEvent();\n      const body = JSON.stringify(event);\n      \n      const request = new Request('http://test.com/webhooks/stripe', {\n        method: 'POST',\n        headers: {\n          'content-type': 'application/json',\n        },\n        body,\n      });\n\n      const response = await handleStripeWebhook(request, mockEnv);\n      const result = await response.json();\n\n      expect(response.status).toBe(400);\n      expect(result.error).toBe('Missing signature');\n    });\n\n    it('should not process webhooks without signature', async () => {\n      const event = createCheckoutSessionCompletedEvent();\n      const body = JSON.stringify(event);\n      \n      const request = new Request('http://test.com/webhooks/stripe', {\n        method: 'POST',\n        headers: {\n          'content-type': 'application/json',\n        },\n        body,\n      });\n\n      await handleStripeWebhook(request, mockEnv);\n\n      // Verify no events were processed\n      expect(processedEvents.size).toBe(0);\n    });\n  });\n\n  // STRIPE-03: Reject invalid signature\n  describe('STRIPE-03: Reject invalid signature', () => {\n    it('should reject webhooks with wrong signature', async () => {\n      const event = createCheckoutSessionCompletedEvent();\n      const body = JSON.stringify(event);\n      \n      const request = new Request('http://test.com/webhooks/stripe', {\n        method: 'POST',\n        headers: {\n          'stripe-signature': 'invalid_signature',\n          'content-type': 'application/json',\n        },\n        body,\n      });\n\n      const response = await handleStripeWebhook(request, mockEnv);\n      const result = await response.json();\n\n      expect(response.status).toBe(400);\n      expect(result.error).toBe('Invalid signature');\n    });\n\n    it('should reject webhooks with expired signature', async () => {\n      const event = createCheckoutSessionCompletedEvent();\n      const body = JSON.stringify(event);\n      \n      const request = new Request('http://test.com/webhooks/stripe', {\n        method: 'POST',\n        headers: {\n          'stripe-signature': 'expired_signature',\n          'content-type': 'application/json',\n        },\n        body,\n      });\n\n      const response = await handleStripeWebhook(request, mockEnv);\n      const result = await response.json();\n\n      expect(response.status).toBe(400);\n      expect(result.error).toBe('Invalid signature');\n    });\n\n    it('should not process events with invalid signatures', async () => {\n      const event = createCheckoutSessionCompletedEvent();\n      const body = JSON.stringify(event);\n      \n      const request = new Request('http://test.com/webhooks/stripe', {\n        method: 'POST',\n        headers: {\n          'stripe-signature': 'tampered_signature',\n          'content-type': 'application/json',\n        },\n        body,\n      });\n\n      await handleStripeWebhook(request, mockEnv);\n\n      // Verify no events were processed\n      expect(processedEvents.size).toBe(0);\n    });\n  });\n\n  // STRIPE-05: Reject replayed webhook (duplicate event ID)\n  describe('STRIPE-05: Reject replayed webhook', () => {\n    it('should process event only once with same event ID', async () => {\n      const event = createCheckoutSessionCompletedEvent({\n        id: 'evt_unique_123',\n        data: {\n          object: {\n            id: 'cs_unique_123', // Session ID must be unique\n            client_reference_id: 'user_idempotency_test',\n            metadata: {\n              user_id: 'user_idempotency_test',\n              type: 'deposit',\n              amount_cents: '10000',\n            },\n            amount_total: 10000,\n          },\n        },\n      });\n      const body = JSON.stringify(event);\n      \n      const request1 = new Request('http://test.com/webhooks/stripe', {\n        method: 'POST',\n        headers: {\n          'stripe-signature': 'valid_signature',\n          'content-type': 'application/json',\n        },\n        body,\n      });\n      \n      const request2 = new Request('http://test.com/webhooks/stripe', {\n        method: 'POST',\n        headers: {\n          'stripe-signature': 'valid_signature',\n          'content-type': 'application/json',\n        },\n        body,\n      });\n\n      // First request should succeed and process\n      const response1 = await handleStripeWebhook(request1, mockEnv);\n      expect(response1.status).toBe(200);\n\n      // Get user profile to check balance after first processing\n      const userProfileId = mockEnv.USER_PROFILES.idFromName('user_idempotency_test');\n      const userProfileStub = mockEnv.USER_PROFILES.get(userProfileId);\n      const balanceResponse1 = await userProfileStub.fetch(\n        new Request('http://internal/balance')\n      );\n      const balance1 = await balanceResponse1.json();\n      expect(balance1.balance_cents).toBe(10000);\n\n      // Second request with same event ID should be idempotent\n      // The implementation checks if session was already processed\n      const response2 = await handleStripeWebhook(request2, mockEnv);\n      expect(response2.status).toBe(200);\n      \n      // Balance should still be 10000, not 20000 (no double-processing)\n      const balanceResponse2 = await userProfileStub.fetch(\n        new Request('http://internal/balance')\n      );\n      const balance2 = await balanceResponse2.json();\n      expect(balance2.balance_cents).toBe(10000); // Not doubled!\n    });\n\n    it('should handle replay attacks gracefully', async () => {\n      const event = createCheckoutSessionCompletedEvent({\n        id: 'evt_replay_attack',\n        data: {\n          object: {\n            client_reference_id: 'user_999',\n            metadata: {\n              user_id: 'user_999',\n              type: 'deposit',\n              amount_cents: '100000', // Large amount\n            },\n          },\n        },\n      });\n      const body = JSON.stringify(event);\n      \n      const request = new Request('http://test.com/webhooks/stripe', {\n        method: 'POST',\n        headers: {\n          'stripe-signature': 'valid_signature',\n          'content-type': 'application/json',\n        },\n        body,\n      });\n\n      // First webhook\n      await handleStripeWebhook(request, mockEnv);\n      \n      // Attacker tries to replay the same webhook\n      // Should be detected and rejected\n      const replayRequest = new Request('http://test.com/webhooks/stripe', {\n        method: 'POST',\n        headers: {\n          'stripe-signature': 'valid_signature',\n          'content-type': 'application/json',\n        },\n        body,\n      });\n      \n      const response = await handleStripeWebhook(replayRequest, mockEnv);\n      \n      // Should succeed but not double-process\n      expect(response.status).toBe(200);\n      \n      // Note: Implementation should track event IDs to prevent double-processing\n    });\n  });\n});\n\n/**\n * Helper functions to create mock Durable Objects\n */\n\nfunction createMockUserProfiles() {\n  const profiles = new Map();\n  \n  return {\n    idFromName: (userId) => ({ toString: () => userId }),\n    get: (id) => ({\n      fetch: async (request) => {\n        const url = new URL(request.url);\n        const userId = id.toString();\n        \n        // Initialize profile if not exists\n        if (!profiles.has(userId)) {\n          profiles.set(userId, { \n            user_id: userId, \n            balance_cents: 0,\n            deleted_at: null\n          });\n        }\n        \n        const profile = profiles.get(userId);\n        \n        // Handle GET /balance\n        if (request.method === 'GET' && url.pathname === '/balance') {\n          return new Response(JSON.stringify({ \n            balance_cents: profile.balance_cents \n          }), {\n            status: 200,\n            headers: { 'content-type': 'application/json' }\n          });\n        }\n        \n        // Handle POST /balance/deposit\n        if (request.method === 'POST' && url.pathname === '/balance/deposit') {\n          const body = await request.json();\n          profile.balance_cents += body.amount_cents;\n          profiles.set(userId, profile);\n          \n          return new Response(JSON.stringify({ \n            balance_cents: profile.balance_cents \n          }), {\n            status: 200,\n            headers: { 'content-type': 'application/json' }\n          });\n        }\n        \n        // Handle POST /credit (legacy)\n        if (request.method === 'POST' && url.pathname.includes('/credit')) {\n          const body = await request.json();\n          profile.balance_cents += body.amount_cents;\n          profiles.set(userId, profile);\n          \n          return new Response(JSON.stringify(profile), {\n            status: 200,\n            headers: { 'content-type': 'application/json' }\n          });\n        }\n        \n        // Handle GET /profile\n        if (url.pathname === '/profile') {\n          return new Response(JSON.stringify(profile), {\n            status: 200,\n            headers: { 'content-type': 'application/json' }\n          });\n        }\n        \n        return new Response('Not found', { status: 404 });\n      }\n    })\n  };\n}\n\nfunction createMockPaymentRecords() {\n  const processedSessions = new Set();\n  const transactions = [];\n  \n  return {\n    idFromName: (userId) => ({ toString: () => userId }),\n    get: () => ({\n      fetch: async (request) => {\n        const url = new URL(request.url);\n        \n        // Handle session exists check\n        if (url.pathname === '/session/exists') {\n          const sessionId = url.searchParams.get('session_id');\n          return new Response(JSON.stringify({ \n            exists: processedSessions.has(sessionId) \n          }), {\n            status: 200,\n            headers: { 'content-type': 'application/json' }\n          });\n        }\n        \n        // Handle record transaction\n        if (request.method === 'POST' && url.pathname === '/transaction') {\n          const body = await request.json();\n          transactions.push(body);\n          \n          // Mark session as processed\n          if (body.stripe_session_id) {\n            processedSessions.add(body.stripe_session_id);\n          }\n          \n          return new Response(JSON.stringify({ success: true }), {\n            status: 200,\n            headers: { 'content-type': 'application/json' }\n          });\n        }\n        \n        // Handle legacy /record endpoint\n        if (request.method === 'POST' && url.pathname === '/record') {\n          const body = await request.json();\n          transactions.push(body);\n          \n          if (body.session_id) {\n            processedSessions.add(body.session_id);\n          }\n          \n          return new Response(JSON.stringify({ success: true }), {\n            status: 200,\n            headers: { 'content-type': 'application/json' }\n          });\n        }\n        \n        return new Response(JSON.stringify({ success: true }), {\n          status: 200,\n          headers: { 'content-type': 'application/json' }\n        });\n      }\n    })\n  };\n}\n\nfunction createMockAuditLog() {\n  return {\n    idFromName: () => ({ toString: () => 'global' }),\n    get: () => ({\n      fetch: async () => {\n        return new Response(JSON.stringify({ success: true }), {\n          status: 200,\n          headers: { 'content-type': 'application/json' }\n        });\n      }\n    })\n  };\n}\n\nfunction createMockPlatformStats() {\n  return {\n    idFromName: () => ({ toString: () => 'global' }),\n    get: () => ({\n      fetch: async () => {\n        return new Response(JSON.stringify({ success: true }), {\n          status: 200,\n          headers: { 'content-type': 'application/json' }\n        });\n      }\n    })\n  };\n}\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/api/public-deletions.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/api/rate-limit.js","messages":[{"ruleId":"complexity","severity":1,"message":"Async function 'handlePurchaseRateLimit' has a complexity of 18. Maximum allowed is 10.","line":17,"column":8,"nodeType":"FunctionDeclaration","messageId":"complex","endLine":301,"endColumn":2},{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 19 to the 15 allowed.","line":17,"column":23,"nodeType":null,"messageId":"refactorFunction","endLine":17,"endColumn":46}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Rate Limit API Handlers\n * Endpoints for purchasing and querying rate limits\n */\n\nimport { authenticate } from '../auth/middleware.js';\nimport { \n  calculateRateLimitPrice, \n  validateRateLimitPurchase,\n  formatCents \n} from '../utils/rate-limit-pricing.js';\n\n/**\n * POST /api/content/rate-limit/purchase\n * Purchase rate limit bandwidth for content\n */\nexport async function handlePurchaseRateLimit(request, env) {\n  const authResult = await authenticate(request, env);\n  \n  if (!authResult.authenticated) {\n    return new Response(\n      JSON.stringify({\n        error: 'Unauthorized',\n        message: 'Authentication required'\n      }),\n      {\n        status: 401,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  try {\n    const data = await request.json();\n    const cid = data.cid;\n    const minTimeBetweenRequestsMs = data.min_time_between_requests_ms ?? data.mtbr_ms;\n    const durationSeconds = data.duration_seconds ?? (data.duration_months ? data.duration_months * 30 * 24 * 60 * 60 : undefined);\n\n    if (!cid) {\n      return new Response(\n        JSON.stringify({\n          error: 'Missing parameter',\n          message: 'cid is required'\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    if (!minTimeBetweenRequestsMs) {\n      return new Response(\n        JSON.stringify({\n          error: 'Missing parameter',\n          message: 'min_time_between_requests_ms is required'\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    if (!durationSeconds) {\n      return new Response(\n        JSON.stringify({\n          error: 'Missing parameter',\n          message: 'duration_seconds is required'\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    const userId = authResult.user.userId;\n\n    // Get content metadata\n    const contentMetadataId = env.CONTENT_METADATA.idFromName(cid);\n    const contentMetadataStub = env.CONTENT_METADATA.get(contentMetadataId);\n    \n    const contentResponse = await contentMetadataStub.fetch(\n      new Request('http://internal/content')\n    );\n\n    if (!contentResponse.ok) {\n      if (contentResponse.status === 404) {\n        return new Response(\n          JSON.stringify({\n            error: 'Content not found',\n            message: 'The specified CID does not exist'\n          }),\n          {\n            status: 404,\n            headers: { 'content-type': 'application/json' }\n          }\n        );\n      }\n      return contentResponse;\n    }\n\n    const content = await contentResponse.json();\n\n    // Check if content is inline\n    if (content.size_bytes <= 64) {\n      return new Response(\n        JSON.stringify({\n          error: 'Invalid content',\n          message: 'Cannot purchase rate limits for inline content (≤64 bytes). Inline content has no rate limits.'\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Validate purchase parameters\n    const validation = validateRateLimitPurchase(\n      minTimeBetweenRequestsMs,\n      durationSeconds,\n      content.expires_at\n    );\n\n    if (!validation.valid) {\n      if (validation.error === 'duration_exceeds_retention') {\n        const remainingSeconds = Math.floor(\n          (new Date(validation.content_expires_at).getTime() - Date.now()) / 1000\n        );\n        const remainingDays = Math.floor(remainingSeconds / 86400);\n        \n        return new Response(\n          JSON.stringify({\n            error: 'duration_exceeds_retention',\n            message: `Cannot purchase rate limit beyond content retention. Content expires in ${remainingDays} days.`,\n            cid: cid,\n            content_expires_at: validation.content_expires_at,\n            max_duration_seconds: validation.max_duration_seconds\n          }),\n          {\n            status: 400,\n            headers: { 'content-type': 'application/json' }\n          }\n        );\n      }\n\n      return new Response(\n        JSON.stringify({\n          error: 'Invalid parameters',\n          message: validation.error\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Calculate price\n    const pricing = calculateRateLimitPrice(\n      content.size_bytes,\n      minTimeBetweenRequestsMs,\n      durationSeconds\n    );\n\n    // Check user balance\n    const userProfileId = env.USER_PROFILES.idFromName(userId);\n    const userProfileStub = env.USER_PROFILES.get(userProfileId);\n    \n    const balanceResponse = await userProfileStub.fetch(\n      new Request('http://internal/balance')\n    );\n    const balanceData = await balanceResponse.json();\n    const balance_cents = balanceData.balance_cents;\n\n    if (balance_cents < pricing.priceCents) {\n      return new Response(\n        JSON.stringify({\n          error: 'insufficient_balance',\n          message: `Insufficient balance. Required: ${formatCents(pricing.priceCents)}, Available: ${formatCents(balance_cents)}`,\n          required_cents: pricing.priceCents,\n          available_cents: balance_cents\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Debit balance\n    const debitResponse = await userProfileStub.fetch(\n      new Request('http://internal/balance/debit', {\n        method: 'POST',\n        headers: { 'content-type': 'application/json' },\n        body: JSON.stringify({ amount_cents: pricing.priceCents })\n      })\n    );\n\n    if (!debitResponse.ok) {\n      const error = await debitResponse.json();\n      return new Response(JSON.stringify(error), {\n        status: debitResponse.status,\n        headers: { 'content-type': 'application/json' }\n      });\n    }\n\n    const debitData = await debitResponse.json();\n\n    // Create rate limit record\n    const recordId = crypto.randomUUID();\n    const purchaseResponse = await contentMetadataStub.fetch(\n      new Request('http://internal/rate-limit/purchase', {\n        method: 'POST',\n        headers: { 'content-type': 'application/json' },\n        body: JSON.stringify({\n          record_id: recordId,\n          payer_id: userId,\n          min_time_between_requests_ms: minTimeBetweenRequestsMs,\n          duration_seconds: durationSeconds,\n          max_requests: pricing.maxRequests,\n          max_bytes: pricing.maxBytes,\n          price_cents: pricing.priceCents\n        })\n      })\n    );\n\n    if (!purchaseResponse.ok) {\n      // Rollback balance debit (credit it back)\n      await userProfileStub.fetch(\n        new Request('http://internal/balance/credit', {\n          method: 'POST',\n          headers: { 'content-type': 'application/json' },\n          body: JSON.stringify({ amount_cents: pricing.priceCents })\n        })\n      );\n\n      return purchaseResponse;\n    }\n\n    const purchaseData = await purchaseResponse.json();\n\n    // Record transaction\n    const paymentRecordId = env.PAYMENT_RECORDS.idFromName(userId);\n    const paymentRecordStub = env.PAYMENT_RECORDS.get(paymentRecordId);\n\n    await paymentRecordStub.fetch(\n      new Request('http://internal/transaction', {\n        method: 'POST',\n        headers: { 'content-type': 'application/json' },\n        body: JSON.stringify({\n          transaction_id: recordId,\n          type: 'rate_limit_purchase',\n          user_id: userId,\n          amount_cents: -pricing.priceCents,\n          balance_before_cents: debitData.balance_before_cents,\n          balance_after_cents: debitData.balance_after_cents,\n          cid: cid,\n          min_time_between_requests_ms: minTimeBetweenRequestsMs,\n          duration_seconds: durationSeconds,\n          max_requests: pricing.maxRequests,\n          max_bytes: pricing.maxBytes\n        })\n      })\n    );\n\n    return new Response(\n      JSON.stringify({\n        purchase_id: recordId,\n        cid: cid,\n        size_bytes: content.size_bytes,\n        min_time_between_requests_ms: minTimeBetweenRequestsMs,\n        duration_seconds: durationSeconds,\n        mtbr_ms: minTimeBetweenRequestsMs,\n        duration_months: data.duration_months || null,\n        max_requests: pricing.maxRequests,\n        max_bytes: pricing.maxBytes,\n        price_cents: pricing.priceCents,\n        starts_at: purchaseData.starts_at,\n        expires_at: purchaseData.expires_at\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  } catch (error) {\n    return new Response(\n      JSON.stringify({\n        error: 'Purchase failed',\n        message: error.message\n      }),\n      {\n        status: 500,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n}\n\n/**\n * GET /api/content/:cid/rate-limit\n * Get rate limit status for content\n */\nexport async function handleGetRateLimit(request, env, cid) {\n  try {\n    const contentMetadataId = env.CONTENT_METADATA.idFromName(cid);\n    const contentMetadataStub = env.CONTENT_METADATA.get(contentMetadataId);\n    \n    const response = await contentMetadataStub.fetch(\n      new Request('http://internal/rate-limit')\n    );\n    \n    return response;\n  } catch (error) {\n    return new Response(\n      JSON.stringify({\n        error: 'Failed to get rate limit',\n        message: error.message\n      }),\n      {\n        status: 500,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n}\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/api/suppliers.js","messages":[{"ruleId":"complexity","severity":1,"message":"Async function 'handleCreateSupplier' has a complexity of 12. Maximum allowed is 10.","line":23,"column":8,"nodeType":"FunctionDeclaration","messageId":"complex","endLine":204,"endColumn":2}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Supplier API Handlers\n * Endpoints for managing alternate content suppliers\n */\n\nimport { authenticate } from '../auth/middleware.js';\nimport { \n  validateSupplierURL, \n  validateSupplierType, \n  validateSupplierName,\n  sanitizeSupplierName\n} from '../utils/supplier-validation.js';\nimport { validate256tCID } from '../utils/hash256t.js';\nimport { scanSupplier } from '../utils/supplier-scanning.js';\n\nconst MAX_SUPPLIERS_PER_USER = 20;\nconst SCAN_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour\n\n/**\n * POST /api/suppliers\n * Register a new alternate supplier\n */\nexport async function handleCreateSupplier(request, env) {\n  const authResult = await authenticate(request, env);\n  \n  if (!authResult.authenticated) {\n    return new Response(\n      JSON.stringify({\n        error: 'Unauthorized',\n        message: 'Authentication required'\n      }),\n      {\n        status: 401,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  try {\n    const data = await request.json();\n    const userId = authResult.user.userId;\n\n    // Validate required fields\n    const nameValidation = validateSupplierName(data.name);\n    if (!nameValidation.valid) {\n      return new Response(\n        JSON.stringify({\n          error: 'Validation error',\n          message: nameValidation.error\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    const typeValidation = validateSupplierType(data.supplier_type);\n    if (!typeValidation.valid) {\n      return new Response(\n        JSON.stringify({\n          error: 'Validation error',\n          message: typeValidation.error\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    const urlValidation = validateSupplierURL(data.base_url);\n    if (!urlValidation.valid) {\n      return new Response(\n        JSON.stringify({\n          error: 'Validation error',\n          message: urlValidation.error\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Validate single_cid if SINGLE_CID type\n    if (data.supplier_type === 'SINGLE_CID') {\n      if (!data.single_cid) {\n        return new Response(\n          JSON.stringify({\n            error: 'Validation error',\n            message: 'single_cid is required for SINGLE_CID supplier type'\n          }),\n          {\n            status: 400,\n            headers: { 'content-type': 'application/json' }\n          }\n        );\n      }\n\n      if (!validate256tCID(data.single_cid)) {\n        return new Response(\n          JSON.stringify({\n            error: 'Validation error',\n            message: 'Invalid CID format'\n          }),\n          {\n            status: 400,\n            headers: { 'content-type': 'application/json' }\n          }\n        );\n      }\n    }\n\n    // Check user's supplier limit\n    const userProfileId = env.USER_PROFILES.idFromName(userId);\n    const userProfileStub = env.USER_PROFILES.get(userProfileId);\n    \n    const profileResponse = await userProfileStub.fetch(\n      new Request('http://internal/profile')\n    );\n    const profileData = await profileResponse.json();\n    \n    const supplierCount = profileData.supplier_count || 0;\n    if (supplierCount >= MAX_SUPPLIERS_PER_USER) {\n      return new Response(\n        JSON.stringify({\n          error: 'Limit exceeded',\n          message: `Maximum ${MAX_SUPPLIERS_PER_USER} suppliers allowed per user`\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Create supplier\n    const supplierId = crypto.randomUUID();\n    const supplierRegistryId = env.SUPPLIER_REGISTRY.idFromName(supplierId);\n    const supplierRegistryStub = env.SUPPLIER_REGISTRY.get(supplierRegistryId);\n\n    const supplier = {\n      supplier_id: supplierId,\n      owner_user_id: userId,\n      name: sanitizeSupplierName(data.name),\n      supplier_type: data.supplier_type,\n      base_url: data.base_url,\n      single_cid: data.single_cid || null,\n      scan_status: 'pending',\n      is_active: true\n    };\n\n    await supplierRegistryStub.fetch(\n      new Request('http://internal/supplier', {\n        method: 'POST',\n        headers: { 'content-type': 'application/json' },\n        body: JSON.stringify(supplier)\n      })\n    );\n\n    // Update user profile with new supplier\n    await userProfileStub.fetch(\n      new Request('http://internal/suppliers/add', {\n        method: 'POST',\n        headers: { 'content-type': 'application/json' },\n        body: JSON.stringify({ supplier_id: supplierId })\n      })\n    );\n\n    // Trigger scan asynchronously\n    // Note: We don't await this so the user gets a quick response\n    performSupplierScan(env, supplierId, supplier).catch(err => {\n      console.error(`Scan failed for supplier ${supplierId}:`, err);\n    });\n\n    return new Response(\n      JSON.stringify({\n        supplier_id: supplierId,\n        name: supplier.name,\n        supplier_type: supplier.supplier_type,\n        base_url: supplier.base_url,\n        scan_status: 'pending',\n        cid_count: 0,\n        created_at: new Date().toISOString()\n      }),\n      {\n        status: 201,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  } catch (error) {\n    return new Response(\n      JSON.stringify({\n        error: 'Failed to create supplier',\n        message: error.message\n      }),\n      {\n        status: 500,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n}\n\n/**\n * GET /api/suppliers\n * List user's registered suppliers\n */\nexport async function handleListSuppliers(request, env) {\n  const authResult = await authenticate(request, env);\n  \n  if (!authResult.authenticated) {\n    return new Response(\n      JSON.stringify({\n        error: 'Unauthorized',\n        message: 'Authentication required'\n      }),\n      {\n        status: 401,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  try {\n    const userId = authResult.user.userId;\n    const url = new URL(request.url);\n    const page = parseInt(url.searchParams.get('page') || '1');\n    const limit = parseInt(url.searchParams.get('limit') || '20');\n\n    // Get user's supplier IDs\n    const userProfileId = env.USER_PROFILES.idFromName(userId);\n    const userProfileStub = env.USER_PROFILES.get(userProfileId);\n    \n    const profileResponse = await userProfileStub.fetch(\n      new Request('http://internal/profile')\n    );\n    const profileData = await profileResponse.json();\n    \n    const supplierIds = profileData.supplier_ids || [];\n\n    // Paginate supplier IDs\n    const start = (page - 1) * limit;\n    const end = start + limit;\n    const paginatedIds = supplierIds.slice(start, end);\n\n    // Fetch supplier details\n    const suppliers = [];\n    for (const supplierId of paginatedIds) {\n      const supplierRegistryId = env.SUPPLIER_REGISTRY.idFromName(supplierId);\n      const supplierRegistryStub = env.SUPPLIER_REGISTRY.get(supplierRegistryId);\n      \n      try {\n        const supplierResponse = await supplierRegistryStub.fetch(\n          new Request('http://internal/supplier')\n        );\n        \n        if (supplierResponse.ok) {\n          const supplier = await supplierResponse.json();\n          suppliers.push(supplier);\n        }\n      } catch (error) {\n        console.error(`Error fetching supplier ${supplierId}:`, error);\n      }\n    }\n\n    return new Response(\n      JSON.stringify({\n        suppliers,\n        page,\n        limit,\n        total: supplierIds.length,\n        has_more: end < supplierIds.length\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  } catch (error) {\n    return new Response(\n      JSON.stringify({\n        error: 'Failed to list suppliers',\n        message: error.message\n      }),\n      {\n        status: 500,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n}\n\n/**\n * GET /api/suppliers/:id\n * Get supplier details\n */\nexport async function handleGetSupplier(request, env, supplierId) {\n  try {\n    const supplierRegistryId = env.SUPPLIER_REGISTRY.idFromName(supplierId);\n    const supplierRegistryStub = env.SUPPLIER_REGISTRY.get(supplierRegistryId);\n    \n    const response = await supplierRegistryStub.fetch(\n      new Request('http://internal/supplier')\n    );\n\n    if (!response.ok) {\n      return response;\n    }\n\n    const supplier = await response.json();\n\n    return new Response(JSON.stringify(supplier), {\n      status: 200,\n      headers: { 'content-type': 'application/json' }\n    });\n  } catch (error) {\n    return new Response(\n      JSON.stringify({\n        error: 'Failed to get supplier',\n        message: error.message\n      }),\n      {\n        status: 500,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n}\n\n/**\n * DELETE /api/suppliers/:id\n * Remove a supplier\n */\nexport async function handleDeleteSupplier(request, env, supplierId) {\n  const authResult = await authenticate(request, env);\n  \n  if (!authResult.authenticated) {\n    return new Response(\n      JSON.stringify({\n        error: 'Unauthorized',\n        message: 'Authentication required'\n      }),\n      {\n        status: 401,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  try {\n    const userId = authResult.user.userId;\n\n    // Get supplier to verify ownership\n    const supplierRegistryId = env.SUPPLIER_REGISTRY.idFromName(supplierId);\n    const supplierRegistryStub = env.SUPPLIER_REGISTRY.get(supplierRegistryId);\n    \n    const supplierResponse = await supplierRegistryStub.fetch(\n      new Request('http://internal/supplier')\n    );\n\n    if (!supplierResponse.ok) {\n      return supplierResponse;\n    }\n\n    const supplier = await supplierResponse.json();\n\n    // Verify ownership\n    if (supplier.owner_user_id !== userId) {\n      return new Response(\n        JSON.stringify({\n          error: 'Forbidden',\n          message: 'You do not own this supplier'\n        }),\n        {\n          status: 403,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Delete supplier\n    await supplierRegistryStub.fetch(\n      new Request('http://internal/supplier', {\n        method: 'DELETE'\n      })\n    );\n\n    // Remove from user profile\n    const userProfileId = env.USER_PROFILES.idFromName(userId);\n    const userProfileStub = env.USER_PROFILES.get(userProfileId);\n    \n    await userProfileStub.fetch(\n      new Request('http://internal/suppliers/remove', {\n        method: 'POST',\n        headers: { 'content-type': 'application/json' },\n        body: JSON.stringify({ supplier_id: supplierId })\n      })\n    );\n\n    return new Response(\n      JSON.stringify({ success: true }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  } catch (error) {\n    return new Response(\n      JSON.stringify({\n        error: 'Failed to delete supplier',\n        message: error.message\n      }),\n      {\n        status: 500,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n}\n\n/**\n * PATCH /api/suppliers/:id\n * Update supplier (name, active status)\n */\nexport async function handleUpdateSupplier(request, env, supplierId) {\n  const authResult = await authenticate(request, env);\n  \n  if (!authResult.authenticated) {\n    return new Response(\n      JSON.stringify({\n        error: 'Unauthorized',\n        message: 'Authentication required'\n      }),\n      {\n        status: 401,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  try {\n    const userId = authResult.user.userId;\n    const data = await request.json();\n\n    // Get supplier to verify ownership\n    const supplierRegistryId = env.SUPPLIER_REGISTRY.idFromName(supplierId);\n    const supplierRegistryStub = env.SUPPLIER_REGISTRY.get(supplierRegistryId);\n    \n    const supplierResponse = await supplierRegistryStub.fetch(\n      new Request('http://internal/supplier')\n    );\n\n    if (!supplierResponse.ok) {\n      return supplierResponse;\n    }\n\n    const supplier = await supplierResponse.json();\n\n    // Verify ownership\n    if (supplier.owner_user_id !== userId) {\n      return new Response(\n        JSON.stringify({\n          error: 'Forbidden',\n          message: 'You do not own this supplier'\n        }),\n        {\n          status: 403,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Validate updates\n    if (data.name !== undefined) {\n      const nameValidation = validateSupplierName(data.name);\n      if (!nameValidation.valid) {\n        return new Response(\n          JSON.stringify({\n            error: 'Validation error',\n            message: nameValidation.error\n          }),\n          {\n            status: 400,\n            headers: { 'content-type': 'application/json' }\n          }\n        );\n      }\n      supplier.name = sanitizeSupplierName(data.name);\n    }\n\n    if (data.is_active !== undefined) {\n      supplier.is_active = Boolean(data.is_active);\n    }\n\n    // Update supplier\n    await supplierRegistryStub.fetch(\n      new Request('http://internal/supplier', {\n        method: 'POST',\n        headers: { 'content-type': 'application/json' },\n        body: JSON.stringify(supplier)\n      })\n    );\n\n    return new Response(JSON.stringify(supplier), {\n      status: 200,\n      headers: { 'content-type': 'application/json' }\n    });\n  } catch (error) {\n    return new Response(\n      JSON.stringify({\n        error: 'Failed to update supplier',\n        message: error.message\n      }),\n      {\n        status: 500,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n}\n\n/**\n * POST /api/suppliers/:id/scan\n * Request rescan of supplier\n */\nexport async function handleRescanSupplier(request, env, supplierId) {\n  const authResult = await authenticate(request, env);\n  \n  if (!authResult.authenticated) {\n    return new Response(\n      JSON.stringify({\n        error: 'Unauthorized',\n        message: 'Authentication required'\n      }),\n      {\n        status: 401,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  try {\n    const userId = authResult.user.userId;\n\n    // Get supplier to verify ownership and check cooldown\n    const supplierRegistryId = env.SUPPLIER_REGISTRY.idFromName(supplierId);\n    const supplierRegistryStub = env.SUPPLIER_REGISTRY.get(supplierRegistryId);\n    \n    const supplierResponse = await supplierRegistryStub.fetch(\n      new Request('http://internal/supplier')\n    );\n\n    if (!supplierResponse.ok) {\n      return supplierResponse;\n    }\n\n    const supplier = await supplierResponse.json();\n\n    // Verify ownership\n    if (supplier.owner_user_id !== userId) {\n      return new Response(\n        JSON.stringify({\n          error: 'Forbidden',\n          message: 'You do not own this supplier'\n        }),\n        {\n          status: 403,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Check cooldown\n    if (supplier.last_scanned_at) {\n      const lastScan = new Date(supplier.last_scanned_at);\n      const now = new Date();\n      const timeSinceLastScan = now - lastScan;\n      \n      if (timeSinceLastScan < SCAN_COOLDOWN_MS) {\n        const remainingMs = SCAN_COOLDOWN_MS - timeSinceLastScan;\n        const remainingMinutes = Math.ceil(remainingMs / 60000);\n        \n        return new Response(\n          JSON.stringify({\n            error: 'Too soon',\n            message: `Please wait ${remainingMinutes} minutes before rescanning`\n          }),\n          {\n            status: 429,\n            headers: { 'content-type': 'application/json' }\n          }\n        );\n      }\n    }\n\n    // Update scan status\n    supplier.scan_status = 'pending';\n    await supplierRegistryStub.fetch(\n      new Request('http://internal/supplier', {\n        method: 'POST',\n        headers: { 'content-type': 'application/json' },\n        body: JSON.stringify(supplier)\n      })\n    );\n\n    // Trigger scan asynchronously\n    performSupplierScan(env, supplierId, supplier).catch(err => {\n      console.error(`Rescan failed for supplier ${supplierId}:`, err);\n    });\n\n    return new Response(\n      JSON.stringify({\n        success: true,\n        message: 'Scan initiated'\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  } catch (error) {\n    return new Response(\n      JSON.stringify({\n        error: 'Failed to rescan supplier',\n        message: error.message\n      }),\n      {\n        status: 500,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n}\n\n/**\n * GET /api/content/:cid/suppliers\n * Get alternate suppliers for a CID\n */\nexport async function handleGetCIDSuppliers(request, env, cid) {\n  try {\n    // Query ContentMetadata for alternate suppliers\n    const contentMetadataId = env.CONTENT_METADATA.idFromName(cid);\n    const contentMetadataStub = env.CONTENT_METADATA.get(contentMetadataId);\n    \n    const response = await contentMetadataStub.fetch(\n      new Request('http://internal/suppliers')\n    );\n\n    if (!response.ok) {\n      // If content not found, return empty suppliers list\n      if (response.status === 404) {\n        return new Response(\n          JSON.stringify({\n            cid,\n            suppliers: []\n          }),\n          {\n            status: 200,\n            headers: { 'content-type': 'application/json' }\n          }\n        );\n      }\n      return response;\n    }\n\n    const data = await response.json();\n\n    return new Response(\n      JSON.stringify({\n        cid,\n        suppliers: data.alternate_suppliers || []\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  } catch (error) {\n    return new Response(\n      JSON.stringify({\n        error: 'Failed to get suppliers',\n        message: error.message\n      }),\n      {\n        status: 500,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n}\n\n/**\n * Perform supplier scan (async helper)\n * @param {Object} env - Environment bindings\n * @param {string} supplierId - Supplier ID\n * @param {Object} supplier - Supplier object\n */\nasync function performSupplierScan(env, supplierId, supplier) {\n  const supplierRegistryId = env.SUPPLIER_REGISTRY.idFromName(supplierId);\n  const supplierRegistryStub = env.SUPPLIER_REGISTRY.get(supplierRegistryId);\n\n  try {\n    // Update status to scanning\n    supplier.scan_status = 'scanning';\n    await supplierRegistryStub.fetch(\n      new Request('http://internal/supplier', {\n        method: 'POST',\n        headers: { 'content-type': 'application/json' },\n        body: JSON.stringify(supplier)\n      })\n    );\n\n    // Perform scan\n    const githubToken = env.GITHUB_TOKEN || null;\n    const scanResult = await scanSupplier(supplier, githubToken);\n\n    if (!scanResult.success) {\n      // Mark as failed\n      supplier.scan_status = 'failed';\n      supplier.scan_error = scanResult.error;\n      supplier.last_scanned_at = new Date().toISOString();\n      \n      await supplierRegistryStub.fetch(\n        new Request('http://internal/supplier', {\n          method: 'POST',\n          headers: { 'content-type': 'application/json' },\n          body: JSON.stringify(supplier)\n        })\n      );\n      \n      return;\n    }\n\n    // Clear existing CIDs and add new ones\n    await supplierRegistryStub.fetch(\n      new Request('http://internal/cids', {\n        method: 'DELETE'\n      })\n    );\n\n    await supplierRegistryStub.fetch(\n      new Request('http://internal/cids', {\n        method: 'POST',\n        headers: { 'content-type': 'application/json' },\n        body: JSON.stringify({ cids: scanResult.cids })\n      })\n    );\n\n    // Update ContentMetadata for each CID\n    for (const cid of scanResult.cids) {\n      try {\n        const contentMetadataId = env.CONTENT_METADATA.idFromName(cid);\n        const contentMetadataStub = env.CONTENT_METADATA.get(contentMetadataId);\n        \n        // Construct URL properly (avoid double slashes)\n        const baseUrl = supplier.base_url.replace(/\\/$/, ''); // Remove trailing slash\n        const supplierUrl = `${baseUrl}/${cid}`;\n        \n        await contentMetadataStub.fetch(\n          new Request('http://internal/suppliers/add', {\n            method: 'POST',\n            headers: { 'content-type': 'application/json' },\n            body: JSON.stringify({\n              supplier_id: supplierId,\n              supplier_name: supplier.name,\n              supplier_url: supplierUrl\n            })\n          })\n        );\n      } catch (error) {\n        console.error(`Failed to update ContentMetadata for ${cid}:`, error);\n      }\n    }\n\n    // Mark as completed\n    supplier.scan_status = 'completed';\n    supplier.scan_error = null;\n    supplier.last_scanned_at = new Date().toISOString();\n    supplier.cid_count = scanResult.cids.length;\n    \n    await supplierRegistryStub.fetch(\n      new Request('http://internal/supplier', {\n        method: 'POST',\n        headers: { 'content-type': 'application/json' },\n        body: JSON.stringify(supplier)\n      })\n    );\n  } catch (error) {\n    console.error(`Scan error for supplier ${supplierId}:`, error);\n    \n    // Mark as failed\n    supplier.scan_status = 'failed';\n    supplier.scan_error = error.message;\n    supplier.last_scanned_at = new Date().toISOString();\n    \n    await supplierRegistryStub.fetch(\n      new Request('http://internal/supplier', {\n        method: 'POST',\n        headers: { 'content-type': 'application/json' },\n        body: JSON.stringify(supplier)\n      })\n    );\n  }\n}\n\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/api/user.js","messages":[{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 19 to the 15 allowed.","line":14,"column":23,"nodeType":null,"messageId":"refactorFunction","endLine":14,"endColumn":43}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * User API Handlers\n * Endpoints for user-specific data (uploads, profile, etc.)\n */\n\nimport { authenticate } from '../auth/middleware.js';\nimport { isOAuthAuth, oauthNotAllowedResponse } from '../auth/oauth-access.js';\nimport { isInlineContent } from '../utils/hash256t.js';\n\n/**\n * GET /api/user/uploads\n * Get user's upload history with full metadata\n */\nexport async function handleGetUserUploads(request, env) {\n  const authResult = await authenticate(request, env);\n  \n  if (!authResult.authenticated) {\n    return new Response(\n      JSON.stringify({\n        error: 'Unauthorized',\n        message: 'Authentication required'\n      }),\n      {\n        status: 401,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  if (isOAuthAuth(authResult)) {\n    return oauthNotAllowedResponse();\n  }\n\n  try {\n    const url = new URL(request.url);\n    const limit = Math.min(parseInt(url.searchParams.get('limit') || '50'), 100);\n    const cursor = url.searchParams.get('cursor');\n    const sort = url.searchParams.get('sort') || 'uploaded_at_desc';\n\n    const userId = authResult.user.userId;\n\n    // Get upload history from UserProfile DO\n    const userProfileId = env.USER_PROFILES.idFromName(userId);\n    const userProfileStub = env.USER_PROFILES.get(userProfileId);\n    \n    const uploadsResponse = await userProfileStub.fetch(\n      new Request('http://internal/uploads')\n    );\n\n    if (!uploadsResponse.ok) {\n      return uploadsResponse;\n    }\n\n    let uploads = await uploadsResponse.json();\n\n    // Enrich each upload with full metadata\n    const enrichedUploads = await Promise.all(\n      uploads.map(async (upload) => {\n        try {\n          const cid = upload.content_hash;\n          \n          // Get content metadata\n          const contentMetadataId = env.CONTENT_METADATA.idFromName(cid);\n          const contentMetadataStub = env.CONTENT_METADATA.get(contentMetadataId);\n          \n          const contentResponse = await contentMetadataStub.fetch(\n            new Request('http://internal/content')\n          );\n\n          if (!contentResponse.ok) {\n            // Content may have been deleted/expired\n            return {\n              cid: cid,\n              size_bytes: upload.size_bytes,\n              uploaded_at: upload.uploaded_at,\n              created_at: upload.uploaded_at,\n              expires_at: null,\n              is_inline: isInlineContent(cid),\n              rate_limit_status: null,\n              download_count: 0,\n              deleted: true\n            };\n          }\n\n          const content = await contentResponse.json();\n\n          // Get rate limit status (only for non-inline content)\n          let rateLimitStatus = null;\n          if (!isInlineContent(cid)) {\n            const rateLimitResponse = await contentMetadataStub.fetch(\n              new Request('http://internal/rate-limit')\n            );\n\n            if (rateLimitResponse.ok) {\n              rateLimitStatus = await rateLimitResponse.json();\n            }\n          }\n\n          return {\n            cid: cid,\n            size_bytes: content.size_bytes,\n            uploaded_at: upload.uploaded_at,\n            created_at: upload.uploaded_at,\n            expires_at: content.expires_at,\n            is_inline: isInlineContent(cid),\n            rate_limit_status: rateLimitStatus,\n            download_count: content.download_count || 0\n          };\n        } catch (error) {\n          console.error(`Error enriching upload ${upload.content_hash}:`, error);\n          return {\n            cid: upload.content_hash,\n            size_bytes: upload.size_bytes,\n            uploaded_at: upload.uploaded_at,\n            created_at: upload.uploaded_at,\n            expires_at: null,\n            is_inline: isInlineContent(upload.content_hash),\n            rate_limit_status: null,\n            download_count: 0,\n            error: true\n          };\n        }\n      })\n    );\n\n    // Filter out deleted/error uploads if desired\n    const validUploads = enrichedUploads.filter(u => !u.deleted && !u.error);\n\n    // Apply sorting\n    const sortedUploads = sortUploads(validUploads, sort);\n\n    // Apply pagination\n    const startIndex = cursor ? parseInt(cursor) : 0;\n    const endIndex = startIndex + limit;\n    const paginatedUploads = sortedUploads.slice(startIndex, endIndex);\n    const hasMore = endIndex < sortedUploads.length;\n    const nextCursor = hasMore ? endIndex.toString() : null;\n\n    return new Response(\n      JSON.stringify({\n        uploads: paginatedUploads,\n        total_count: sortedUploads.length,\n        has_more: hasMore,\n        cursor: nextCursor\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  } catch (error) {\n    console.error('Failed to get user uploads:', error);\n    return new Response(\n      JSON.stringify({\n        error: 'Failed to get uploads',\n        message: error.message\n      }),\n      {\n        status: 500,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n}\n\n/**\n * Sort uploads based on specified criteria\n */\nfunction sortUploads(uploads, sortType) {\n  const sorted = [...uploads];\n  \n  switch (sortType) {\n    case 'uploaded_at_desc':\n      sorted.sort((a, b) => new Date(b.uploaded_at) - new Date(a.uploaded_at));\n      break;\n    case 'uploaded_at_asc':\n      sorted.sort((a, b) => new Date(a.uploaded_at) - new Date(b.uploaded_at));\n      break;\n    case 'expires_at_asc':\n      sorted.sort((a, b) => {\n        if (!a.expires_at) return 1;\n        if (!b.expires_at) return -1;\n        return new Date(a.expires_at) - new Date(b.expires_at);\n      });\n      break;\n    case 'size_desc':\n      sorted.sort((a, b) => b.size_bytes - a.size_bytes);\n      break;\n    default:\n      // Default to uploaded_at_desc\n      sorted.sort((a, b) => new Date(b.uploaded_at) - new Date(a.uploaded_at));\n  }\n  \n  return sorted;\n}\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/auth/admin.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/auth/admin.test.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/auth/local-auth.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/auth/middleware.js","messages":[{"ruleId":"complexity","severity":1,"message":"Async function 'validateOAuthAccessToken' has a complexity of 15. Maximum allowed is 10.","line":247,"column":1,"nodeType":"FunctionDeclaration","messageId":"complex","endLine":324,"endColumn":2},{"ruleId":"complexity","severity":1,"message":"Async function 'authenticate' has a complexity of 19. Maximum allowed is 10.","line":366,"column":8,"nodeType":"FunctionDeclaration","messageId":"complex","endLine":566,"endColumn":2},{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 42 to the 15 allowed.","line":366,"column":23,"nodeType":null,"messageId":"refactorFunction","endLine":366,"endColumn":35}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":3,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Authentication and Authorization Middleware\n * Handles Clerk session validation and API key authentication\n */\n\nimport { verifyToken } from '@clerk/backend';\nimport { hashApiKey, validateApiKeyFormat } from './utils.js';\nimport { getOrCreateLocalProfile, validateLocalUserId } from './local-auth.js';\nimport { verifyOAuthJwt } from './oauth.js';\n\n// Error codes for authentication failures\nexport const AUTH_ERROR_CODES = {\n  AUTH_MISSING: 'AUTH_MISSING',\n  AUTH_INVALID_FORMAT: 'AUTH_INVALID_FORMAT',\n  AUTH_EXPIRED: 'AUTH_EXPIRED',\n  AUTH_KEY_REVOKED: 'AUTH_KEY_REVOKED',\n  AUTH_USER_DELETED: 'AUTH_USER_DELETED',\n  AUTH_RATE_LIMITED: 'AUTH_RATE_LIMITED',\n  AUTH_KEY_LIMIT: 'AUTH_KEY_LIMIT'\n};\n\n// Rate limiting windows (in milliseconds)\nconst RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute\nconst ANONYMOUS_RATE_LIMIT = 100;\nconst USER_RATE_LIMIT = 1000;\nconst KEY_RATE_LIMIT = 500;\n\n// In-memory rate limiting cache (simple implementation)\n// NOTE: This is not production-ready for distributed Workers\n// In production, rate limiting should use:\n// - Durable Objects for strong consistency\n// - KV with per-key limits and TTLs\n// - Cloudflare Rate Limiting API\n// This implementation works for local development and testing only\nconst rateLimitCache = new Map();\n\n/**\n * Extract authentication from request headers\n * Supports:\n * - Authorization: Bearer <clerk-jwt>\n * - Authorization: ApiKey <api-key>\n * - X-API-Key: <api-key>\n */\nfunction extractAuth(request) {\n  const authHeader = request.headers.get('authorization');\n  const apiKeyHeader = request.headers.get('x-api-key');\n\n  if (authHeader) {\n    const parts = authHeader.split(' ');\n    if (parts.length !== 2) {\n      return { type: 'invalid', value: null };\n    }\n\n    const [scheme, value] = parts;\n    const normalizedScheme = scheme.toLowerCase();\n\n    if (normalizedScheme === 'bearer') {\n      return { type: 'clerk', value };\n    } else if (normalizedScheme === 'apikey') {\n      return { type: 'apikey', value };\n    } else if (normalizedScheme === 'localdev') {\n      return { type: 'local', value };\n    } else {\n      return { type: 'invalid', value: null };\n    }\n  }\n\n  if (apiKeyHeader) {\n    return { type: 'apikey', value: apiKeyHeader };\n  }\n\n  return { type: 'none', value: null };\n}\n\n/**\n * Validate Clerk JWT token\n */\nasync function validateClerkToken(token, env) {\n  try {\n    if (!env.CLERK_SECRET_KEY) {\n      throw new Error('CLERK_SECRET_KEY not configured');\n    }\n\n    const payload = await verifyToken(token, {\n      secretKey: env.CLERK_SECRET_KEY,\n      // Clerk automatically handles expiration checking\n    });\n\n    return {\n      valid: true,\n      userId: payload.sub,\n      sessionId: payload.sid,\n      error: null\n    };\n  } catch (error) {\n    // Check for specific error types\n    if (error.message.includes('expired')) {\n      return {\n        valid: false,\n        error: AUTH_ERROR_CODES.AUTH_EXPIRED,\n        message: 'Token has expired'\n      };\n    }\n\n    return {\n      valid: false,\n      error: AUTH_ERROR_CODES.AUTH_INVALID_FORMAT,\n      message: 'Invalid token format'\n    };\n  }\n}\n\n/**\n * Validate API key against UserProfile Durable Object\n */\nasync function validateApiKey(apiKey, env) {\n  // Validate format first\n  const formatValidation = validateApiKeyFormat(apiKey);\n  if (!formatValidation.valid) {\n    return {\n      valid: false,\n      error: formatValidation.error,\n      message: formatValidation.message\n    };\n  }\n\n  // Hash the key for lookup\n  const keyHash = await hashApiKey(apiKey);\n\n  // Use KeyRegistry DO to find the user\n  const registryId = env.KEY_REGISTRY.idFromName('global');\n  const registryStub = env.KEY_REGISTRY.get(registryId);\n\n  try {\n    const lookupResponse = await registryStub.fetch(\n      new Request('http://internal/lookup', {\n        method: 'POST',\n        headers: { 'content-type': 'application/json' },\n        body: JSON.stringify({ key_hash: keyHash })\n      })\n    );\n\n    if (!lookupResponse.ok) {\n      return {\n        valid: false,\n        error: AUTH_ERROR_CODES.AUTH_INVALID_FORMAT,\n        message: 'API key not found'\n      };\n    }\n\n    const keyInfo = await lookupResponse.json();\n    const userId = keyInfo.user_id;\n    const keyId = keyInfo.key_id;\n\n    // Get the full key details from UserProfile DO\n    const userProfileId = env.USER_PROFILES.idFromName(userId);\n    const userProfileStub = env.USER_PROFILES.get(userProfileId);\n\n    const keyResponse = await userProfileStub.fetch(\n      new Request(`http://internal/apikeys/${keyId}`, {\n        method: 'GET'\n      })\n    );\n\n    if (!keyResponse.ok) {\n      return {\n        valid: false,\n        error: AUTH_ERROR_CODES.AUTH_INVALID_FORMAT,\n        message: 'API key not found in user profile'\n      };\n    }\n\n    const apiKeyData = await keyResponse.json();\n\n    // Check if key is revoked\n    if (apiKeyData.revoked_at) {\n      return {\n        valid: false,\n        error: AUTH_ERROR_CODES.AUTH_KEY_REVOKED,\n        message: 'API key has been revoked'\n      };\n    }\n\n    // Check if key is expired\n    const expiresAt = new Date(apiKeyData.expires_at);\n    if (expiresAt <= new Date()) {\n      return {\n        valid: false,\n        error: AUTH_ERROR_CODES.AUTH_EXPIRED,\n        message: 'API key has expired'\n      };\n    }\n\n    // Update last_used_at timestamp\n    // Note: This is fire-and-forget to avoid blocking the request\n    // In production, consider using ExecutionContext.waitUntil() if available\n    // or implementing this as a post-request hook\n    userProfileStub.fetch(\n      new Request(`http://internal/apikeys/${keyId}/use`, {\n        method: 'POST'\n      })\n    ).catch(() => {\n      // Ignore errors updating last_used_at\n    });\n\n    // Get user profile\n    const profileResponse = await userProfileStub.fetch(\n      new Request('http://internal/profile', {\n        method: 'GET'\n      })\n    );\n\n    if (!profileResponse.ok) {\n      return {\n        valid: false,\n        error: AUTH_ERROR_CODES.AUTH_INVALID_FORMAT,\n        message: 'User profile not found'\n      };\n    }\n\n    const profile = await profileResponse.json();\n\n    // Check if user is deleted\n    if (profile.deleted_at) {\n      return {\n        valid: false,\n        error: AUTH_ERROR_CODES.AUTH_USER_DELETED,\n        message: 'User account has been deleted'\n      };\n    }\n\n    return {\n      valid: true,\n      userId,\n      keyId,\n      profile\n    };\n  } catch (error) {\n    return {\n      valid: false,\n      error: AUTH_ERROR_CODES.AUTH_INVALID_FORMAT,\n      message: 'Error validating API key'\n    };\n  }\n}\n\nasync function validateOAuthAccessToken(token, env) {\n  if (!env.OAUTH_SIGNING_KEY) {\n    return {\n      valid: false,\n      error: AUTH_ERROR_CODES.AUTH_INVALID_FORMAT,\n      message: 'OAuth signing key not configured'\n    };\n  }\n\n  try {\n    const payload = await verifyOAuthJwt(token, env.OAUTH_SIGNING_KEY);\n    if (payload.token_type !== 'access' || !payload.user_id || !payload.app_id || !payload.grant_id) {\n      return {\n        valid: false,\n        error: AUTH_ERROR_CODES.AUTH_INVALID_FORMAT,\n        message: 'Invalid oauth token'\n      };\n    }\n\n    const userProfileId = env.USER_PROFILES.idFromName(payload.user_id);\n    const userProfileStub = env.USER_PROFILES.get(userProfileId);\n    const response = await userProfileStub.fetch(new Request('http://internal/profile', {\n      method: 'GET'\n    }));\n\n    if (!response.ok) {\n      return {\n        valid: false,\n        error: AUTH_ERROR_CODES.AUTH_INVALID_FORMAT,\n        message: 'User profile not found'\n      };\n    }\n\n    const profile = await response.json();\n    if (profile.deleted_at) {\n      return {\n        valid: false,\n        error: AUTH_ERROR_CODES.AUTH_USER_DELETED,\n        message: 'User account has been deleted'\n      };\n    }\n\n    const grantResponse = await userProfileStub.fetch(new Request(`http://internal/oauth/grants/${payload.grant_id}`, {\n      method: 'GET'\n    }));\n    if (!grantResponse.ok) {\n      return {\n        valid: false,\n        error: AUTH_ERROR_CODES.AUTH_INVALID_FORMAT,\n        message: 'OAuth grant not found'\n      };\n    }\n\n    const grant = await grantResponse.json();\n    if (grant.revoked_at || grant.app_id !== payload.app_id) {\n      return {\n        valid: false,\n        error: AUTH_ERROR_CODES.AUTH_INVALID_FORMAT,\n        message: 'OAuth grant revoked'\n      };\n    }\n\n    return {\n      valid: true,\n      userId: payload.user_id,\n      appId: payload.app_id,\n      grantId: payload.grant_id,\n      scopes: grant.scopes || payload.scopes || [],\n      profile\n    };\n  } catch (error) {\n    return {\n      valid: false,\n      error: error.code === 'expired' ? AUTH_ERROR_CODES.AUTH_EXPIRED : AUTH_ERROR_CODES.AUTH_INVALID_FORMAT,\n      message: error.message\n    };\n  }\n}\n\n/**\n * Check rate limits for a given identifier\n */\nfunction checkRateLimit(identifier, limit) {\n  const now = Date.now();\n  const windowStart = now - RATE_LIMIT_WINDOW;\n\n  // Get or create rate limit entry\n  let entry = rateLimitCache.get(identifier);\n  if (!entry) {\n    entry = { requests: [], windowStart: now };\n    rateLimitCache.set(identifier, entry);\n  }\n\n  // Remove old requests outside the window\n  entry.requests = entry.requests.filter(timestamp => timestamp > windowStart);\n\n  // Check if limit exceeded\n  if (entry.requests.length >= limit) {\n    return {\n      allowed: false,\n      remaining: 0,\n      resetAt: entry.requests[0] + RATE_LIMIT_WINDOW\n    };\n  }\n\n  // Add current request\n  entry.requests.push(now);\n\n  return {\n    allowed: true,\n    remaining: limit - entry.requests.length,\n    resetAt: now + RATE_LIMIT_WINDOW\n  };\n}\n\n/**\n * Main authentication middleware\n * Returns user context or null for anonymous requests\n */\nexport async function authenticate(request, env) {\n  const auth = extractAuth(request);\n  const isLocalMode = env.ENVIRONMENT === 'local';\n\n  // No authentication provided\n  if (auth.type === 'none') {\n    return {\n      authenticated: false,\n      user: null,\n      error: null\n    };\n  }\n\n  // Invalid authentication format\n  if (auth.type === 'invalid') {\n    return {\n      authenticated: false,\n      user: null,\n      error: AUTH_ERROR_CODES.AUTH_INVALID_FORMAT\n    };\n  }\n\n  // Local development authentication\n  if (auth.type === 'local') {\n    if (!isLocalMode) {\n      return {\n        authenticated: false,\n        user: null,\n        error: AUTH_ERROR_CODES.AUTH_INVALID_FORMAT\n      };\n    }\n\n    const validation = validateLocalUserId(auth.value);\n    if (!validation.valid) {\n      return {\n        authenticated: false,\n        user: null,\n        error: AUTH_ERROR_CODES.AUTH_INVALID_FORMAT\n      };\n    }\n\n    try {\n      const { profile } = await getOrCreateLocalProfile(env, validation.userId);\n\n      if (profile.deleted_at) {\n        return {\n          authenticated: false,\n          user: null,\n          error: AUTH_ERROR_CODES.AUTH_USER_DELETED\n        };\n      }\n\n      return {\n        authenticated: true,\n        user: {\n          userId: profile.user_id,\n          sessionId: null,\n          authMethod: 'local',\n          profile\n        },\n        error: null\n      };\n    } catch (error) {\n      return {\n        authenticated: false,\n        user: null,\n        error: AUTH_ERROR_CODES.AUTH_INVALID_FORMAT\n      };\n    }\n  }\n\n  // Clerk JWT token\n  if (auth.type === 'clerk') {\n    if (!isLocalMode) {\n      const validation = await validateClerkToken(auth.value, env);\n      if (validation.valid) {\n        // Fetch user profile from UserProfile DO\n        const userId = validation.userId;\n        const userProfileId = env.USER_PROFILES.idFromName(userId);\n        const userProfileStub = env.USER_PROFILES.get(userProfileId);\n\n        try {\n          const response = await userProfileStub.fetch(\n            new Request('http://internal/profile', {\n              method: 'GET'\n            })\n          );\n\n          if (!response.ok) {\n            if (response.status === 404) {\n              return {\n                authenticated: true,\n                user: {\n                  userId,\n                  sessionId: validation.sessionId,\n                  authMethod: 'clerk',\n                  profileExists: false\n                },\n                error: null\n              };\n            }\n\n            return {\n              authenticated: false,\n              user: null,\n              error: AUTH_ERROR_CODES.AUTH_INVALID_FORMAT\n            };\n          }\n\n          const profile = await response.json();\n\n          if (profile.deleted_at) {\n            return {\n              authenticated: false,\n              user: null,\n              error: AUTH_ERROR_CODES.AUTH_USER_DELETED\n            };\n          }\n\n          return {\n            authenticated: true,\n            user: {\n              userId: profile.user_id,\n              sessionId: validation.sessionId,\n              authMethod: 'clerk',\n              profile\n            },\n            error: null\n          };\n        } catch (error) {\n          return {\n            authenticated: false,\n            user: null,\n            error: AUTH_ERROR_CODES.AUTH_INVALID_FORMAT\n          };\n        }\n      }\n\n      if (validation.error === AUTH_ERROR_CODES.AUTH_EXPIRED) {\n        return {\n          authenticated: false,\n          user: null,\n          error: validation.error\n        };\n      }\n    }\n\n    const oauthValidation = await validateOAuthAccessToken(auth.value, env);\n    if (!oauthValidation.valid) {\n      return {\n        authenticated: false,\n        user: null,\n        error: oauthValidation.error\n      };\n    }\n\n    return {\n      authenticated: true,\n      user: {\n        userId: oauthValidation.userId,\n        authMethod: 'oauth',\n        profile: oauthValidation.profile,\n        oauth: {\n          appId: oauthValidation.appId,\n          grantId: oauthValidation.grantId,\n          scopes: oauthValidation.scopes\n        }\n      },\n      error: null\n    };\n  }\n\n  // API Key authentication\n  if (auth.type === 'apikey') {\n    const validation = await validateApiKey(auth.value, env);\n    if (!validation.valid) {\n      return {\n        authenticated: false,\n        user: null,\n        error: validation.error\n      };\n    }\n\n    return {\n      authenticated: true,\n      user: {\n        userId: validation.userId,\n        keyId: validation.keyId,\n        authMethod: 'apikey',\n        profile: validation.profile\n      },\n      error: null\n    };\n  }\n\n  return {\n    authenticated: false,\n    user: null,\n    error: AUTH_ERROR_CODES.AUTH_INVALID_FORMAT\n  };\n}\n\n/**\n * Enforce authentication requirement\n * Returns 401 response if not authenticated\n */\nexport function requireAuth(authResult) {\n  if (!authResult.authenticated) {\n    const errorCode = authResult.error || AUTH_ERROR_CODES.AUTH_MISSING;\n    return new Response(\n      JSON.stringify({\n        error: errorCode,\n        message: getErrorMessage(errorCode)\n      }),\n      {\n        status: 401,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n  return null;\n}\n\n/**\n * Apply rate limiting based on user context\n */\nexport function applyRateLimit(request, authResult) {\n  let identifier, limit;\n\n  if (authResult.authenticated) {\n    if (authResult.user.authMethod === 'apikey') {\n      // Per-key rate limit\n      identifier = `key:${authResult.user.keyId}`;\n      limit = KEY_RATE_LIMIT;\n    } else {\n      // Per-user rate limit\n      identifier = `user:${authResult.user.userId}`;\n      limit = USER_RATE_LIMIT;\n    }\n  } else {\n    // Anonymous rate limit by IP\n    const ip = request.headers.get('cf-connecting-ip') || 'unknown';\n    identifier = `anon:${ip}`;\n    limit = ANONYMOUS_RATE_LIMIT;\n  }\n\n  const result = checkRateLimit(identifier, limit);\n\n  if (!result.allowed) {\n    const retryAfter = Math.ceil((result.resetAt - Date.now()) / 1000);\n    return new Response(\n      JSON.stringify({\n        error: AUTH_ERROR_CODES.AUTH_RATE_LIMITED,\n        message: 'Rate limit exceeded',\n        retry_after: retryAfter\n      }),\n      {\n        status: 429,\n        headers: {\n          'content-type': 'application/json',\n          'retry-after': retryAfter.toString(),\n          'x-ratelimit-limit': limit.toString(),\n          'x-ratelimit-remaining': '0',\n          'x-ratelimit-reset': Math.floor(result.resetAt / 1000).toString()\n        }\n      }\n    );\n  }\n\n  return null;\n}\n\n/**\n * Get human-readable error message for error code\n */\nfunction getErrorMessage(errorCode) {\n  const messages = {\n    [AUTH_ERROR_CODES.AUTH_MISSING]: 'Authentication required',\n    [AUTH_ERROR_CODES.AUTH_INVALID_FORMAT]: 'Invalid authentication format',\n    [AUTH_ERROR_CODES.AUTH_EXPIRED]: 'Authentication token expired',\n    [AUTH_ERROR_CODES.AUTH_KEY_REVOKED]: 'API key has been revoked',\n    [AUTH_ERROR_CODES.AUTH_USER_DELETED]: 'User account has been deleted',\n    [AUTH_ERROR_CODES.AUTH_RATE_LIMITED]: 'Rate limit exceeded',\n    [AUTH_ERROR_CODES.AUTH_KEY_LIMIT]: 'Maximum API keys limit reached'\n  };\n  return messages[errorCode] || 'Authentication failed';\n}\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/auth/middleware.test.js","messages":[{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 25 to the 15 allowed.","line":596,"column":13,"nodeType":null,"messageId":"refactorFunction","endLine":596,"endColumn":15},{"ruleId":"complexity","severity":1,"message":"Async method 'fetch' has a complexity of 12. Maximum allowed is 10.","line":597,"column":14,"nodeType":"ArrowFunctionExpression","messageId":"complex","endLine":651,"endColumn":8}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Authentication Middleware Security Tests (P0 Priority)\n * Tests for src/auth/middleware.js\n */\n\nimport { describe, it, expect, vi, beforeEach } from 'vitest';\nimport { authenticate, AUTH_ERROR_CODES } from './middleware.js';\nimport { generateApiKey, hashApiKey } from './utils.js';\n\n// Mock Clerk verifyToken\nvi.mock('@clerk/backend', () => ({\n  verifyToken: vi.fn(async (token) => {\n    if (token === 'valid_token') {\n      return {\n        sub: 'user_123',\n        sid: 'sess_456',\n        iat: Math.floor(Date.now() / 1000),\n        exp: Math.floor(Date.now() / 1000) + 3600,\n      };\n    }\n    \n    if (token === 'expired_token') {\n      throw new Error('Token expired');\n    }\n    \n    if (token === 'alg_none_token') {\n      throw new Error('Invalid token - algorithm not allowed');\n    }\n    \n    throw new Error('Invalid token');\n  }),\n}));\n\ndescribe('Authentication Middleware - P0 Security Tests', () => {\n  let mockEnv;\n\n  beforeEach(() => {\n    // Create mock environment with Durable Object bindings\n    mockEnv = {\n      CLERK_SECRET_KEY: 'test_clerk_secret',\n      ENVIRONMENT: 'test',\n      KEY_REGISTRY: createMockDurableObject('KEY_REGISTRY'),\n      USER_PROFILES: createMockDurableObject('USER_PROFILES'),\n    };\n  });\n\n  // SEC-01: Timing attack on key validation\n  describe('SEC-01: Timing attack protection', () => {\n    it('should take consistent time for valid and invalid keys', async () => {\n      const validKey = generateApiKey();\n      const invalidKey = 'hb_' + 'a'.repeat(32);\n      \n      // Mock the registry to return valid for one key\n      mockEnv.KEY_REGISTRY = createMockDurableObject('KEY_REGISTRY', {\n        lookup: async (keyHash) => {\n          const validKeyHash = await hashApiKey(validKey);\n          if (keyHash === validKeyHash) {\n            return { user_id: 'user_123', key_id: 'key_456' };\n          }\n          return null;\n        }\n      });\n      \n      mockEnv.USER_PROFILES = createMockDurableObject('USER_PROFILES', {\n        getApiKey: () => ({\n          id: 'key_456',\n          key_hash: 'hash123',\n          name: 'Test Key',\n          created_at: new Date().toISOString(),\n          expires_at: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),\n          revoked_at: null,\n        }),\n        getProfile: () => ({\n          user_id: 'user_123',\n          balance_cents: 1000,\n          deleted_at: null,\n        }),\n      });\n\n      const validRequest = new Request('http://test.com', {\n        headers: { 'Authorization': `ApiKey ${validKey}` }\n      });\n      \n      const invalidRequest = new Request('http://test.com', {\n        headers: { 'Authorization': `ApiKey ${invalidKey}` }\n      });\n\n      // Measure timing for valid key\n      const validStart = performance.now();\n      await authenticate(validRequest, mockEnv);\n      const validTime = performance.now() - validStart;\n\n      // Measure timing for invalid key\n      const invalidStart = performance.now();\n      await authenticate(invalidRequest, mockEnv);\n      const invalidTime = performance.now() - invalidStart;\n\n      // Time difference should be minimal (less than 100ms)\n      // This is a basic check - proper timing attack testing requires\n      // statistical analysis over many iterations\n      const timeDifference = Math.abs(validTime - invalidTime);\n      expect(timeDifference).toBeLessThan(100);\n    });\n\n    it('should use constant-time comparison for key hashes', async () => {\n      // Generate keys with similar prefixes\n      const key1 = generateApiKey();\n      const key2 = 'hb_' + key1.substring(3, 5) + 'z'.repeat(30);\n      \n      const request1 = new Request('http://test.com', {\n        headers: { 'Authorization': `ApiKey ${key1}` }\n      });\n      \n      const request2 = new Request('http://test.com', {\n        headers: { 'Authorization': `ApiKey ${key2}` }\n      });\n\n      // Both should fail but take similar time\n      const start1 = performance.now();\n      const result1 = await authenticate(request1, mockEnv);\n      const time1 = performance.now() - start1;\n\n      const start2 = performance.now();\n      const result2 = await authenticate(request2, mockEnv);\n      const time2 = performance.now() - start2;\n\n      expect(result1.authenticated).toBe(false);\n      expect(result2.authenticated).toBe(false);\n      \n      // Timing should be consistent\n      const timeDifference = Math.abs(time1 - time2);\n      expect(timeDifference).toBeLessThan(50);\n    });\n  });\n\n  // SEC-02: Key enumeration prevention\n  describe('SEC-02: Key enumeration prevention', () => {\n    it('should return same error for non-existent and invalid keys', async () => {\n      const nonExistentKey = generateApiKey();\n      const malformedKey = 'invalid_key_format';\n      \n      const request1 = new Request('http://test.com', {\n        headers: { 'Authorization': `ApiKey ${nonExistentKey}` }\n      });\n      \n      const request2 = new Request('http://test.com', {\n        headers: { 'Authorization': `ApiKey ${malformedKey}` }\n      });\n\n      const result1 = await authenticate(request1, mockEnv);\n      const result2 = await authenticate(request2, mockEnv);\n\n      expect(result1.authenticated).toBe(false);\n      expect(result2.authenticated).toBe(false);\n      \n      // Both should have generic error messages that don't reveal\n      // whether the key exists or is just malformed\n      expect(result1.error).toBe(AUTH_ERROR_CODES.AUTH_INVALID_FORMAT);\n      expect(result2.error).toBe(AUTH_ERROR_CODES.AUTH_INVALID_FORMAT);\n    });\n\n    it('should not reveal if a key exists but is revoked', async () => {\n      const validKey = generateApiKey();\n      \n      mockEnv.KEY_REGISTRY = createMockDurableObject('KEY_REGISTRY', {\n        lookup: async () => ({ user_id: 'user_123', key_id: 'key_456' })\n      });\n      \n      mockEnv.USER_PROFILES = createMockDurableObject('USER_PROFILES', {\n        getApiKey: () => ({\n          id: 'key_456',\n          key_hash: 'hash123',\n          name: 'Test Key',\n          created_at: new Date().toISOString(),\n          expires_at: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),\n          revoked_at: new Date().toISOString(), // Key is revoked\n        }),\n        getProfile: () => ({\n          user_id: 'user_123',\n          balance_cents: 1000,\n          deleted_at: null,\n        }),\n      });\n\n      const request = new Request('http://test.com', {\n        headers: { 'Authorization': `ApiKey ${validKey}` }\n      });\n\n      const result = await authenticate(request, mockEnv);\n\n      expect(result.authenticated).toBe(false);\n      expect(result.error).toBe(AUTH_ERROR_CODES.AUTH_KEY_REVOKED);\n      // Error message should be generic enough to not help attackers\n    });\n\n    it('should not distinguish between expired and non-existent keys via timing', async () => {\n      const nonExistentKey = generateApiKey();\n      const expiredKey = generateApiKey();\n      \n      mockEnv.KEY_REGISTRY = createMockDurableObject('KEY_REGISTRY', {\n        lookup: async (keyHash) => {\n          const expiredKeyHash = await hashApiKey(expiredKey);\n          if (keyHash === expiredKeyHash) {\n            return { user_id: 'user_123', key_id: 'key_456' };\n          }\n          return null;\n        }\n      });\n      \n      mockEnv.USER_PROFILES = createMockDurableObject('USER_PROFILES', {\n        getApiKey: () => ({\n          id: 'key_456',\n          key_hash: 'hash123',\n          name: 'Test Key',\n          created_at: new Date().toISOString(),\n          expires_at: new Date(Date.now() - 1000).toISOString(), // Expired\n          revoked_at: null,\n        }),\n        getProfile: () => ({\n          user_id: 'user_123',\n          balance_cents: 1000,\n          deleted_at: null,\n        }),\n      });\n\n      const request1 = new Request('http://test.com', {\n        headers: { 'Authorization': `ApiKey ${nonExistentKey}` }\n      });\n      \n      const request2 = new Request('http://test.com', {\n        headers: { 'Authorization': `ApiKey ${expiredKey}` }\n      });\n\n      const start1 = performance.now();\n      const result1 = await authenticate(request1, mockEnv);\n      const time1 = performance.now() - start1;\n\n      const start2 = performance.now();\n      const result2 = await authenticate(request2, mockEnv);\n      const time2 = performance.now() - start2;\n\n      expect(result1.authenticated).toBe(false);\n      expect(result2.authenticated).toBe(false);\n      \n      // Timing should be similar\n      const timeDifference = Math.abs(time1 - time2);\n      expect(timeDifference).toBeLessThan(100);\n    });\n  });\n\n  // SEC-03: JWT signature bypass (alg:none)\n  describe('SEC-03: JWT signature bypass protection', () => {\n    it('should reject JWT with alg:none', async () => {\n      const request = new Request('http://test.com', {\n        headers: { 'Authorization': 'Bearer alg_none_token' }\n      });\n\n      const result = await authenticate(request, mockEnv);\n\n      expect(result.authenticated).toBe(false);\n      expect(result.error).toBe(AUTH_ERROR_CODES.AUTH_INVALID_FORMAT);\n    });\n\n    it('should only accept properly signed JWTs', async () => {\n      const validRequest = new Request('http://test.com', {\n        headers: { 'Authorization': 'Bearer valid_token' }\n      });\n      \n      const invalidRequest = new Request('http://test.com', {\n        headers: { 'Authorization': 'Bearer invalid_token' }\n      });\n\n      const validResult = await authenticate(validRequest, mockEnv);\n      const invalidResult = await authenticate(invalidRequest, mockEnv);\n\n      expect(validResult.authenticated).toBe(true);\n      expect(invalidResult.authenticated).toBe(false);\n      expect(invalidResult.error).toBe(AUTH_ERROR_CODES.AUTH_INVALID_FORMAT);\n    });\n\n    it('should reject unsigned JWTs', async () => {\n      // Create a JWT-like token without signature\n      const unsignedToken = 'eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyXzEyMyJ9.';\n      \n      const request = new Request('http://test.com', {\n        headers: { 'Authorization': `Bearer ${unsignedToken}` }\n      });\n\n      const result = await authenticate(request, mockEnv);\n\n      expect(result.authenticated).toBe(false);\n    });\n  });\n\n  // AUTHMW-01: Anonymous public access\n  describe('AUTHMW-01: Anonymous access', () => {\n    it('should allow anonymous requests without authentication', async () => {\n      const request = new Request('http://test.com');\n\n      const result = await authenticate(request, mockEnv);\n\n      expect(result.authenticated).toBe(false);\n      expect(result.user).toBeNull();\n      expect(result.error).toBeNull();\n    });\n\n    it('should not require authentication for public endpoints', async () => {\n      const request = new Request('http://test.com/health');\n\n      const result = await authenticate(request, mockEnv);\n\n      expect(result.authenticated).toBe(false);\n      expect(result.error).toBeNull();\n    });\n  });\n\n  // CLERK-01: Valid Clerk JWT is accepted\n  describe('CLERK-01: Valid JWT acceptance', () => {\n    it('should accept valid Clerk JWT', async () => {\n      const request = new Request('http://test.com', {\n        headers: { 'Authorization': 'Bearer valid_token' }\n      });\n\n      const result = await authenticate(request, mockEnv);\n\n      expect(result.authenticated).toBe(true);\n      expect(result.user).not.toBeNull();\n      expect(result.user.userId).toBe('user_123');\n      expect(result.error).toBeNull();\n    });\n\n    it('should extract user ID from valid JWT', async () => {\n      const request = new Request('http://test.com', {\n        headers: { 'Authorization': 'Bearer valid_token' }\n      });\n\n      const result = await authenticate(request, mockEnv);\n\n      expect(result.user.userId).toBe('user_123');\n      expect(result.user.sessionId).toBe('sess_456');\n    });\n\n    it('should provide user context from Clerk JWT', async () => {\n      const request = new Request('http://test.com', {\n        headers: { 'Authorization': 'Bearer valid_token' }\n      });\n\n      const result = await authenticate(request, mockEnv);\n\n      expect(result.authenticated).toBe(true);\n      expect(result.user).toBeDefined();\n      expect(result.user.userId).toBeTruthy();\n    });\n  });\n\n  // CLERK-02: Expired Clerk JWT is rejected\n  describe('CLERK-02: Expired JWT rejection', () => {\n    it('should reject expired Clerk JWT', async () => {\n      const request = new Request('http://test.com', {\n        headers: { 'Authorization': 'Bearer expired_token' }\n      });\n\n      const result = await authenticate(request, mockEnv);\n\n      expect(result.authenticated).toBe(false);\n      expect(result.error).toBe(AUTH_ERROR_CODES.AUTH_EXPIRED);\n    });\n\n    it('should not provide user context for expired JWT', async () => {\n      const request = new Request('http://test.com', {\n        headers: { 'Authorization': 'Bearer expired_token' }\n      });\n\n      const result = await authenticate(request, mockEnv);\n\n      expect(result.authenticated).toBe(false);\n      expect(result.user).toBeNull();\n    });\n\n    it('should return appropriate error message for expired token', async () => {\n      const request = new Request('http://test.com', {\n        headers: { 'Authorization': 'Bearer expired_token' }\n      });\n\n      const result = await authenticate(request, mockEnv);\n\n      expect(result.error).toBe(AUTH_ERROR_CODES.AUTH_EXPIRED);\n      expect(result.authenticated).toBe(false);\n    });\n  });\n\n  // CLERK-03: Malformed Clerk JWT is rejected\n  describe('CLERK-03: Malformed JWT rejection', () => {\n    it('should reject malformed JWT', async () => {\n      const request = new Request('http://test.com', {\n        headers: { 'Authorization': 'Bearer invalid_token' }\n      });\n\n      const result = await authenticate(request, mockEnv);\n\n      expect(result.authenticated).toBe(false);\n      expect(result.error).toBe(AUTH_ERROR_CODES.AUTH_INVALID_FORMAT);\n    });\n\n    it('should reject JWT with invalid format', async () => {\n      const request = new Request('http://test.com', {\n        headers: { 'Authorization': 'Bearer not.a.valid.jwt' }\n      });\n\n      const result = await authenticate(request, mockEnv);\n\n      expect(result.authenticated).toBe(false);\n    });\n\n    it('should reject non-JWT bearer tokens', async () => {\n      const request = new Request('http://test.com', {\n        headers: { 'Authorization': 'Bearer plaintext_token' }\n      });\n\n      const result = await authenticate(request, mockEnv);\n\n      expect(result.authenticated).toBe(false);\n      expect(result.error).toBe(AUTH_ERROR_CODES.AUTH_INVALID_FORMAT);\n    });\n  });\n\n  // KEYVAL-03: Revoked API key is rejected\n  describe('KEYVAL-03: Revoked key rejection', () => {\n    it('should reject revoked API key', async () => {\n      const validKey = generateApiKey();\n      \n      mockEnv.KEY_REGISTRY = createMockDurableObject('KEY_REGISTRY', {\n        lookup: async () => ({ user_id: 'user_789', key_id: 'key_revoked' })\n      });\n      \n      mockEnv.USER_PROFILES = createMockDurableObject('USER_PROFILES', {\n        getApiKey: () => ({\n          id: 'key_revoked',\n          key_hash: 'hash_revoked',\n          name: 'Revoked Key',\n          created_at: new Date().toISOString(),\n          expires_at: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),\n          revoked_at: new Date().toISOString(), // Key is revoked\n        }),\n        getProfile: () => ({\n          user_id: 'user_789',\n          balance_cents: 5000,\n          deleted_at: null,\n        }),\n      });\n\n      const request = new Request('http://test.com', {\n        headers: { 'Authorization': `ApiKey ${validKey}` }\n      });\n\n      const result = await authenticate(request, mockEnv);\n\n      expect(result.authenticated).toBe(false);\n      expect(result.error).toBe(AUTH_ERROR_CODES.AUTH_KEY_REVOKED);\n    });\n\n    it('should not provide user context for revoked key', async () => {\n      const validKey = generateApiKey();\n      \n      mockEnv.KEY_REGISTRY = createMockDurableObject('KEY_REGISTRY', {\n        lookup: async () => ({ user_id: 'user_789', key_id: 'key_revoked' })\n      });\n      \n      mockEnv.USER_PROFILES = createMockDurableObject('USER_PROFILES', {\n        getApiKey: () => ({\n          id: 'key_revoked',\n          revoked_at: new Date().toISOString(),\n          expires_at: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),\n        }),\n        getProfile: () => ({\n          user_id: 'user_789',\n          balance_cents: 5000,\n          deleted_at: null,\n        }),\n      });\n\n      const request = new Request('http://test.com', {\n        headers: { 'Authorization': `ApiKey ${validKey}` }\n      });\n\n      const result = await authenticate(request, mockEnv);\n\n      expect(result.authenticated).toBe(false);\n      expect(result.user).toBeNull();\n    });\n  });\n\n  // KEYVAL-04: Expired API key is rejected\n  describe('KEYVAL-04: Expired key rejection', () => {\n    it('should reject expired API key', async () => {\n      const validKey = generateApiKey();\n      \n      mockEnv.KEY_REGISTRY = createMockDurableObject('KEY_REGISTRY', {\n        lookup: async () => ({ user_id: 'user_999', key_id: 'key_expired' })\n      });\n      \n      mockEnv.USER_PROFILES = createMockDurableObject('USER_PROFILES', {\n        getApiKey: () => ({\n          id: 'key_expired',\n          key_hash: 'hash_expired',\n          name: 'Expired Key',\n          created_at: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString(),\n          expires_at: new Date(Date.now() - 1000).toISOString(), // Expired\n          revoked_at: null,\n        }),\n        getProfile: () => ({\n          user_id: 'user_999',\n          balance_cents: 2000,\n          deleted_at: null,\n        }),\n      });\n\n      const request = new Request('http://test.com', {\n        headers: { 'Authorization': `ApiKey ${validKey}` }\n      });\n\n      const result = await authenticate(request, mockEnv);\n\n      expect(result.authenticated).toBe(false);\n      expect(result.error).toBe(AUTH_ERROR_CODES.AUTH_EXPIRED);\n    });\n\n    it('should not provide user context for expired key', async () => {\n      const validKey = generateApiKey();\n      \n      mockEnv.KEY_REGISTRY = createMockDurableObject('KEY_REGISTRY', {\n        lookup: async () => ({ user_id: 'user_999', key_id: 'key_expired' })\n      });\n      \n      mockEnv.USER_PROFILES = createMockDurableObject('USER_PROFILES', {\n        getApiKey: () => ({\n          id: 'key_expired',\n          expires_at: new Date(Date.now() - 86400000).toISOString(), // Expired yesterday\n          revoked_at: null,\n        }),\n        getProfile: () => ({\n          user_id: 'user_999',\n          balance_cents: 2000,\n          deleted_at: null,\n        }),\n      });\n\n      const request = new Request('http://test.com', {\n        headers: { 'Authorization': `ApiKey ${validKey}` }\n      });\n\n      const result = await authenticate(request, mockEnv);\n\n      expect(result.authenticated).toBe(false);\n      expect(result.user).toBeNull();\n    });\n\n    it('should reject key that expired exactly now', async () => {\n      const validKey = generateApiKey();\n      \n      mockEnv.KEY_REGISTRY = createMockDurableObject('KEY_REGISTRY', {\n        lookup: async () => ({ user_id: 'user_999', key_id: 'key_expired' })\n      });\n      \n      mockEnv.USER_PROFILES = createMockDurableObject('USER_PROFILES', {\n        getApiKey: () => ({\n          id: 'key_expired',\n          expires_at: new Date(Date.now()).toISOString(), // Expires right now\n          revoked_at: null,\n        }),\n        getProfile: () => ({\n          user_id: 'user_999',\n          balance_cents: 2000,\n          deleted_at: null,\n        }),\n      });\n\n      const request = new Request('http://test.com', {\n        headers: { 'Authorization': `ApiKey ${validKey}` }\n      });\n\n      const result = await authenticate(request, mockEnv);\n\n      // Key that expires \"now\" should be considered expired\n      expect(result.authenticated).toBe(false);\n    });\n  });\n});\n\n/**\n * Helper function to create mock Durable Object bindings\n */\nfunction createMockDurableObject(name, handlers = {}) {\n  return {\n    idFromName: (id) => ({ toString: () => id }),\n    get: () => ({\n      fetch: async (request) => {\n        const url = new URL(request.url);\n        const path = url.pathname;\n        \n        // Handle KEY_REGISTRY lookups\n        if (name === 'KEY_REGISTRY' && path === '/lookup') {\n          const body = await request.json();\n          const keyHash = body.key_hash;\n          \n          if (handlers.lookup) {\n            const result = await handlers.lookup(keyHash);\n            if (result) {\n              return new Response(JSON.stringify(result), {\n                status: 200,\n                headers: { 'content-type': 'application/json' }\n              });\n            }\n          }\n          \n          return new Response('Not found', { status: 404 });\n        }\n        \n        // Handle USER_PROFILES API key lookups\n        if (name === 'USER_PROFILES' && path.startsWith('/apikeys/')) {\n          if (path.endsWith('/use')) {\n            // Update last_used_at - just return success\n            return new Response('OK', { status: 200 });\n          }\n          \n          if (handlers.getApiKey) {\n            const apiKeyData = handlers.getApiKey();\n            return new Response(JSON.stringify(apiKeyData), {\n              status: 200,\n              headers: { 'content-type': 'application/json' }\n            });\n          }\n          \n          return new Response('Not found', { status: 404 });\n        }\n        \n        // Handle USER_PROFILES profile lookups\n        if (name === 'USER_PROFILES' && path === '/profile') {\n          if (handlers.getProfile) {\n            const profile = handlers.getProfile();\n            return new Response(JSON.stringify(profile), {\n              status: 200,\n              headers: { 'content-type': 'application/json' }\n            });\n          }\n          \n          return new Response('Not found', { status: 404 });\n        }\n        \n        return new Response('Not found', { status: 404 });\n      }\n    })\n  };\n}\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/auth/oauth-access.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/auth/oauth-cors.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/auth/oauth.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/auth/utils.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/auth/utils.test.js","messages":[{"ruleId":"no-unused-vars","severity":1,"message":"'result' is assigned a value but never used.","line":355,"column":15,"nodeType":"Identifier","messageId":"unusedVar","endLine":355,"endColumn":21}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Authentication Utilities Tests (P0 Priority)\n * Tests for src/auth/utils.js\n */\n\nimport { describe, it, expect } from 'vitest';\nimport {\n  generateApiKey,\n  hashApiKey,\n  validateApiKeyFormat,\n  validateKeyName,\n  validateExpiration,\n  generateKeyId,\n} from './utils.js';\n\ndescribe('Authentication Utilities - P0 Tests', () => {\n  // KEYGEN-01: Generate API key with correct production prefix\n  describe('KEYGEN-01: API key prefix', () => {\n    it('should generate key with hb_ prefix', () => {\n      const key = generateApiKey();\n      expect(key).toMatch(/^hb_/);\n    });\n\n    it('should consistently use hb_ prefix', () => {\n      const keys = Array.from({ length: 10 }, () => generateApiKey());\n      keys.forEach(key => {\n        expect(key).toMatch(/^hb_/);\n      });\n    });\n  });\n\n  // KEYGEN-02: Generated key has correct format (35 chars: hb_ + 32 chars)\n  describe('KEYGEN-02: API key format', () => {\n    it('should generate 35 character keys (hb_ + 32 chars)', () => {\n      const key = generateApiKey();\n      expect(key).toHaveLength(35);\n      expect(key.startsWith('hb_')).toBe(true);\n      expect(key.substring(3)).toHaveLength(32);\n    });\n\n    it('should only contain alphanumeric characters after prefix', () => {\n      const key = generateApiKey();\n      const keyPart = key.substring(3);\n      expect(keyPart).toMatch(/^[A-Za-z0-9]{32}$/);\n    });\n  });\n\n  // KEYGEN-03: Generated key is unique (1000 keys test)\n  describe('KEYGEN-03: API key uniqueness', () => {\n    it('should generate unique keys across 1000 generations', () => {\n      const keys = new Set();\n      const count = 1000;\n      \n      for (let i = 0; i < count; i++) {\n        keys.add(generateApiKey());\n      }\n      \n      expect(keys.size).toBe(count);\n    });\n\n    it('should have high entropy (no obvious patterns)', () => {\n      const keys = Array.from({ length: 100 }, () => generateApiKey());\n      \n      // Check that keys don't start with same characters\n      const firstChars = keys.map(k => k.charAt(3)); // First char after prefix\n      const uniqueFirstChars = new Set(firstChars);\n      \n      // Should have variety in first character\n      expect(uniqueFirstChars.size).toBeGreaterThan(10);\n    });\n  });\n\n  // KEYGEN-04: Key generation stores hash, not plaintext\n  describe('KEYGEN-04: Key hashing', () => {\n    it('should hash API key to hex string', async () => {\n      const key = generateApiKey();\n      const hash = await hashApiKey(key);\n      \n      // SHA-256 produces 64 hex characters\n      expect(hash).toHaveLength(64);\n      expect(hash).toMatch(/^[a-f0-9]{64}$/);\n    });\n\n    it('should produce consistent hash for same key', async () => {\n      const key = generateApiKey();\n      const hash1 = await hashApiKey(key);\n      const hash2 = await hashApiKey(key);\n      \n      expect(hash1).toBe(hash2);\n    });\n\n    it('should produce different hashes for different keys', async () => {\n      const key1 = generateApiKey();\n      const key2 = generateApiKey();\n      const hash1 = await hashApiKey(key1);\n      const hash2 = await hashApiKey(key2);\n      \n      expect(hash1).not.toBe(hash2);\n    });\n\n    it('should not be reversible (one-way hash)', async () => {\n      const key = generateApiKey();\n      const hash = await hashApiKey(key);\n      \n      // Hash should not contain the original key\n      expect(hash).not.toContain(key);\n      expect(hash).not.toContain(key.substring(3));\n    });\n  });\n\n  // KEYVAL-01: Valid API key is accepted\n  describe('KEYVAL-01: Valid key validation', () => {\n    it('should accept valid API key with hb_ prefix', () => {\n      const key = generateApiKey();\n      const result = validateApiKeyFormat(key);\n      \n      expect(result.valid).toBe(true);\n      expect(result.error).toBeNull();\n      expect(result.message).toBeNull();\n    });\n\n    it('should accept keys with correct length and format', () => {\n      const validKey = 'hb_' + 'A'.repeat(32);\n      const result = validateApiKeyFormat(validKey);\n      \n      expect(result.valid).toBe(true);\n    });\n  });\n\n  // KEYVAL-02: Malformed API key is rejected\n  describe('KEYVAL-02-06: Invalid key rejection', () => {\n    it('should reject empty API key', () => {\n      const result = validateApiKeyFormat('');\n      \n      expect(result.valid).toBe(false);\n      expect(result.message).toContain('non-empty string');\n    });\n\n    it('should reject API key with wrong prefix', () => {\n      const invalidKey = 'invalid_' + 'A'.repeat(32);\n      const result = validateApiKeyFormat(invalidKey);\n      \n      expect(result.valid).toBe(false);\n      expect(result.message).toContain('must start with hb_');\n    });\n\n    it('should reject API key with wrong length', () => {\n      const shortKey = 'hb_short';\n      const result = validateApiKeyFormat(shortKey);\n      \n      expect(result.valid).toBe(false);\n      expect(result.message).toContain('exactly');\n    });\n\n    it('should reject null/undefined API key', () => {\n      const result1 = validateApiKeyFormat(null);\n      const result2 = validateApiKeyFormat(undefined);\n      \n      expect(result1.valid).toBe(false);\n      expect(result2.valid).toBe(false);\n    });\n\n    it('should reject API key with invalid characters', () => {\n      const invalidKey = 'hb_' + 'A'.repeat(30) + '!@';\n      const result = validateApiKeyFormat(invalidKey);\n      \n      expect(result.valid).toBe(false);\n      expect(result.message).toContain('invalid characters');\n    });\n  });\n\n  // KEYVAL-09: Legacy hb_test_ prefix handled\n  describe.skip('KEYVAL-09: Legacy test prefix support (NOT IMPLEMENTED)', () => {\n    // NOTE: Current implementation has a bug - it checks hb_ first which matches hb_test_\n    // So legacy prefixes don't actually work. This needs to be fixed in the implementation.\n    it('should accept legacy hb_test_ prefix', () => {\n      const legacyKey = 'hb_test_' + 'A'.repeat(32);\n      const result = validateApiKeyFormat(legacyKey);\n      expect(result.valid).toBe(true);\n    });\n  });\n\n  // KEYVAL-10: Legacy hb_live_ prefix handled\n  describe.skip('KEYVAL-10: Legacy live prefix support (NOT IMPLEMENTED)', () => {\n    // NOTE: Current implementation has a bug - it checks hb_ first which matches hb_live_\n    // So legacy prefixes don't actually work. This needs to be fixed in the implementation.\n    it('should accept legacy hb_live_ prefix', () => {\n      const legacyKey = 'hb_live_' + 'A'.repeat(32);\n      const result = validateApiKeyFormat(legacyKey);\n      expect(result.valid).toBe(true);\n    });\n  });\n\n  // KEYGEN-06: Generate key defaults to 5 year expiration\n  describe('KEYGEN-06: Default expiration', () => {\n    it('should default to 5 year expiration when not specified', () => {\n      const result = validateExpiration();\n      \n      expect(result.valid).toBe(true);\n      expect(result.expiresAt).toBeDefined();\n      \n      // Check it's approximately 5 years from now\n      const expirationDate = new Date(result.expiresAt);\n      const now = new Date();\n      const diff = expirationDate - now;\n      \n      // Should be close to 5 years (allow for daylight savings differences)\n      const fiveYearsInMs = 5 * 365.25 * 24 * 60 * 60 * 1000; // Include leap years\n      const oneDayInMs = 24 * 60 * 60 * 1000;\n      expect(Math.abs(diff - fiveYearsInMs)).toBeLessThan(oneDayInMs * 2);\n    });\n  });\n\n  // KEYGEN-08: Empty key name uses smart default\n  describe('KEYGEN-08: Key name validation', () => {\n    it('should accept empty key name', () => {\n      const result = validateKeyName('');\n      \n      expect(result.valid).toBe(true);\n      expect(result.message).toBeNull();\n    });\n\n    it('should accept null/undefined key name', () => {\n      const result1 = validateKeyName(null);\n      const result2 = validateKeyName(undefined);\n      \n      expect(result1.valid).toBe(true);\n      expect(result2.valid).toBe(true);\n    });\n\n    it('should accept whitespace-only name', () => {\n      const result = validateKeyName('   ');\n      \n      expect(result.valid).toBe(true);\n    });\n  });\n\n  // KEYGEN-09: Key name length validation (>255 chars)\n  describe('KEYGEN-09: Key name length limit', () => {\n    it('should accept name with exactly 255 characters', () => {\n      const name = 'A'.repeat(255);\n      const result = validateKeyName(name);\n      \n      expect(result.valid).toBe(true);\n    });\n\n    it('should reject name with more than 255 characters', () => {\n      const name = 'A'.repeat(256);\n      const result = validateKeyName(name);\n      \n      expect(result.valid).toBe(false);\n      expect(result.message).toContain('255 characters or less');\n    });\n  });\n\n  // KEYGEN-11: Expiration beyond 5 years rejected\n  describe('KEYGEN-11: Maximum expiration validation', () => {\n    it('should reject expiration more than 5 years in future', () => {\n      const sixYearsFromNow = new Date();\n      sixYearsFromNow.setFullYear(sixYearsFromNow.getFullYear() + 6);\n      \n      const result = validateExpiration(sixYearsFromNow.toISOString());\n      \n      expect(result.valid).toBe(false);\n      expect(result.message).toContain('5 years');\n    });\n\n    it('should accept expiration exactly 5 years in future', () => {\n      const fiveYearsFromNow = new Date();\n      fiveYearsFromNow.setFullYear(fiveYearsFromNow.getFullYear() + 5);\n      \n      const result = validateExpiration(fiveYearsFromNow.toISOString());\n      \n      expect(result.valid).toBe(true);\n    });\n\n    it('should reject already expired date', () => {\n      const yesterday = new Date();\n      yesterday.setDate(yesterday.getDate() - 1);\n      \n      const result = validateExpiration(yesterday.toISOString());\n      \n      expect(result.valid).toBe(false);\n      expect(result.message).toContain('future');\n    });\n  });\n\n  // SEC-06: XSS in key name\n  describe('SEC-06: XSS prevention in key name', () => {\n    it('should reject key name with HTML tags', () => {\n      const maliciousName = '<script>alert(\"XSS\")</script>';\n      const result = validateKeyName(maliciousName);\n      \n      // Should either reject or sanitize, but not allow raw HTML\n      expect(result.valid).toBe(true); // Current implementation allows it\n      // Note: Implementation doesn't sanitize or escape HTML\n      // This is not necessarily a vulnerability if names are properly escaped in UI\n    });\n\n    it('should handle key name with script tags', () => {\n      const result = validateKeyName('<img src=x onerror=alert(1)>');\n      \n      expect(result.valid).toBe(true);\n      // Names are accepted as-is; security depends on output encoding\n    });\n\n    it('should handle key name with event handlers', () => {\n      const result = validateKeyName('onclick=alert(1)');\n      \n      expect(result.valid).toBe(true);\n      // Current implementation accepts any string as name\n    });\n\n    it('should handle very long key names with XSS attempts', () => {\n      const longXSS = '<script>alert(1)</script>'.repeat(50);\n      const result = validateKeyName(longXSS);\n      \n      // Should be rejected due to length, not XSS content\n      expect(result.valid).toBe(false);\n      expect(result.message).toContain('255 characters');\n    });\n\n    it('should properly validate empty string after trimming HTML', () => {\n      // Edge case: name that becomes empty after potential sanitization\n      const result = validateKeyName('   ');\n      \n      // Empty names are allowed per spec\n      expect(result.valid).toBe(true);\n    });\n  });\n\n  // Additional tests\n  describe('Additional validation tests', () => {\n    it('should generate valid UUID v4 for key ID', () => {\n      const keyId = generateKeyId();\n      \n      // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx\n      const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;\n      expect(keyId).toMatch(uuidRegex);\n    });\n\n    it('should generate unique key IDs', () => {\n      const ids = new Set();\n      for (let i = 0; i < 100; i++) {\n        ids.add(generateKeyId());\n      }\n      \n      expect(ids.size).toBe(100);\n    });\n\n    it('should reject non-string key name', () => {\n      // Implementation has bug: doesn't check type before calling trim()\n      // This will throw TypeError instead of returning validation error\n      expect(() => {\n        const result = validateKeyName(123);\n      }).toThrow(TypeError);\n    });\n\n    it('should reject invalid date format for expiration', () => {\n      const result = validateExpiration('invalid-date');\n      \n      expect(result.valid).toBe(false);\n      expect(result.message).toContain('Invalid expiration date format');\n    });\n  });\n});\n\ndescribe('Authentication Utilities - P1 Encryption Tests', () => {\n  // Helper to generate a mock 256-bit encryption key (32 bytes base64 encoded)\n  const generateMockEncryptionKey = () => {\n    const keyBytes = new Uint8Array(32);\n    crypto.getRandomValues(keyBytes);\n    return btoa(String.fromCharCode(...keyBytes));\n  };\n\n  // ENCRYPT-01: Encrypt API key with AES-256-GCM\n  describe('ENCRYPT-01: Encrypt API key with AES-256-GCM', () => {\n    it('should encrypt API key successfully', async () => {\n      const { generateApiKey, encryptApiKey } = await import('./utils.js');\n      const apiKey = generateApiKey();\n      const encryptionKey = generateMockEncryptionKey();\n\n      const encrypted = await encryptApiKey(apiKey, encryptionKey);\n\n      expect(encrypted).toBeDefined();\n      expect(typeof encrypted).toBe('string');\n      expect(encrypted.length).toBeGreaterThan(0);\n      expect(encrypted).not.toBe(apiKey);\n    });\n\n    it('should produce different output for same key on repeated calls', async () => {\n      const { generateApiKey, encryptApiKey } = await import('./utils.js');\n      const apiKey = generateApiKey();\n      const encryptionKey = generateMockEncryptionKey();\n\n      const encrypted1 = await encryptApiKey(apiKey, encryptionKey);\n      const encrypted2 = await encryptApiKey(apiKey, encryptionKey);\n\n      // Should be different due to random IV\n      expect(encrypted1).not.toBe(encrypted2);\n    });\n  });\n\n  // ENCRYPT-02: Decrypt API key successfully\n  describe('ENCRYPT-02: Decrypt API key successfully', () => {\n    it('should decrypt encrypted API key', async () => {\n      const { generateApiKey, encryptApiKey, decryptApiKey } = await import('./utils.js');\n      const apiKey = generateApiKey();\n      const encryptionKey = generateMockEncryptionKey();\n\n      const encrypted = await encryptApiKey(apiKey, encryptionKey);\n      const decrypted = await decryptApiKey(encrypted, encryptionKey);\n\n      expect(decrypted).toBe(apiKey);\n    });\n\n    it('should decrypt multiple different keys correctly', async () => {\n      const { generateApiKey, encryptApiKey, decryptApiKey } = await import('./utils.js');\n      const encryptionKey = generateMockEncryptionKey();\n\n      const apiKey1 = generateApiKey();\n      const apiKey2 = generateApiKey();\n      const apiKey3 = generateApiKey();\n\n      const encrypted1 = await encryptApiKey(apiKey1, encryptionKey);\n      const encrypted2 = await encryptApiKey(apiKey2, encryptionKey);\n      const encrypted3 = await encryptApiKey(apiKey3, encryptionKey);\n\n      expect(await decryptApiKey(encrypted1, encryptionKey)).toBe(apiKey1);\n      expect(await decryptApiKey(encrypted2, encryptionKey)).toBe(apiKey2);\n      expect(await decryptApiKey(encrypted3, encryptionKey)).toBe(apiKey3);\n    });\n  });\n\n  // ENCRYPT-03: Decryption fails with wrong key\n  describe('ENCRYPT-03: Decryption fails with wrong key', () => {\n    it('should fail to decrypt with wrong encryption key', async () => {\n      const { generateApiKey, encryptApiKey, decryptApiKey } = await import('./utils.js');\n      const apiKey = generateApiKey();\n      const encryptionKey1 = generateMockEncryptionKey();\n      const encryptionKey2 = generateMockEncryptionKey();\n\n      const encrypted = await encryptApiKey(apiKey, encryptionKey1);\n\n      await expect(\n        decryptApiKey(encrypted, encryptionKey2)\n      ).rejects.toThrow('Decryption failed');\n    });\n\n    it('should fail to decrypt with corrupted data', async () => {\n      const { decryptApiKey } = await import('./utils.js');\n      const encryptionKey = generateMockEncryptionKey();\n      const corruptedData = 'invalid_base64_data!!!';\n\n      await expect(\n        decryptApiKey(corruptedData, encryptionKey)\n      ).rejects.toThrow();\n    });\n  });\n\n  // ENCRYPT-04: Each encryption uses unique IV\n  describe('ENCRYPT-04: Each encryption uses unique IV', () => {\n    it('should use different IV for each encryption', async () => {\n      const { generateApiKey, encryptApiKey } = await import('./utils.js');\n      const apiKey = generateApiKey();\n      const encryptionKey = generateMockEncryptionKey();\n\n      const encrypted1 = await encryptApiKey(apiKey, encryptionKey);\n      const encrypted2 = await encryptApiKey(apiKey, encryptionKey);\n\n      // Decode base64 to check IV (first 12 bytes)\n      const data1 = Uint8Array.from(atob(encrypted1), c => c.charCodeAt(0));\n      const data2 = Uint8Array.from(atob(encrypted2), c => c.charCodeAt(0));\n\n      const iv1 = data1.slice(0, 12);\n      const iv2 = data2.slice(0, 12);\n\n      // IVs should be different\n      expect(Buffer.from(iv1).toString('hex')).not.toBe(Buffer.from(iv2).toString('hex'));\n    });\n\n    it('should include IV in encrypted output', async () => {\n      const { generateApiKey, encryptApiKey } = await import('./utils.js');\n      const apiKey = generateApiKey();\n      const encryptionKey = generateMockEncryptionKey();\n\n      const encrypted = await encryptApiKey(apiKey, encryptionKey);\n      const data = Uint8Array.from(atob(encrypted), c => c.charCodeAt(0));\n\n      // Should have at least 12 bytes for IV plus some ciphertext\n      expect(data.length).toBeGreaterThan(12);\n    });\n  });\n\n  // ENCRYPT-05: Encrypted output is base64 encoded\n  describe('ENCRYPT-05: Encrypted output is base64 encoded', () => {\n    it('should produce valid base64 output', async () => {\n      const { generateApiKey, encryptApiKey } = await import('./utils.js');\n      const apiKey = generateApiKey();\n      const encryptionKey = generateMockEncryptionKey();\n\n      const encrypted = await encryptApiKey(apiKey, encryptionKey);\n\n      // Valid base64 should decode without error\n      expect(() => atob(encrypted)).not.toThrow();\n    });\n\n    it('should only contain base64 characters', async () => {\n      const { generateApiKey, encryptApiKey } = await import('./utils.js');\n      const apiKey = generateApiKey();\n      const encryptionKey = generateMockEncryptionKey();\n\n      const encrypted = await encryptApiKey(apiKey, encryptionKey);\n\n      // Base64 regex: only A-Z, a-z, 0-9, +, /, and = for padding\n      expect(encrypted).toMatch(/^[A-Za-z0-9+/]+=*$/);\n    });\n  });\n});\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/durable-objects/admin-action-log.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/durable-objects/alert-store.js","messages":[{"ruleId":"no-unused-vars","severity":1,"message":"'key' is assigned a value but never used.","line":66,"column":17,"nodeType":"Identifier","messageId":"unusedVar","endLine":66,"endColumn":20}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * AlertStore Durable Object\n * Stores and manages system alerts with deduplication\n */\n\nexport class AlertStore {\n  constructor(state, env) {\n    this.state = state;\n    this.env = env;\n  }\n\n  async fetch(request) {\n    const url = new URL(request.url);\n    const path = url.pathname;\n\n    try {\n      // Create alert\n      if (path === '/create' && request.method === 'POST') {\n        const body = await request.json();\n        return this.createAlert(body);\n      }\n\n      // List alerts\n      if (path === '/list' && request.method === 'GET') {\n        return this.listAlerts(url.searchParams);\n      }\n\n      // Acknowledge alert\n      if (path.startsWith('/acknowledge/') && request.method === 'POST') {\n        const alertId = path.split('/')[2];\n        const body = await request.json();\n        return this.acknowledgeAlert(alertId, body);\n      }\n\n      // Resolve alert\n      if (path.startsWith('/resolve/') && request.method === 'POST') {\n        const alertId = path.split('/')[2];\n        return this.resolveAlert(alertId);\n      }\n\n      return new Response('Not Found', { status: 404 });\n    } catch (error) {\n      console.error('AlertStore error:', error);\n      return new Response(\n        JSON.stringify({ error: error.message }),\n        { status: 500, headers: { 'Content-Type': 'application/json' } }\n      );\n    }\n  }\n\n  /**\n   * Create a new alert with deduplication\n   */\n  async createAlert(body) {\n    const { type, severity, title, message, metadata = {} } = body;\n\n    if (!type || !severity || !title || !message) {\n      return new Response(\n        JSON.stringify({ error: 'Missing required fields' }),\n        { status: 400, headers: { 'Content-Type': 'application/json' } }\n      );\n    }\n\n    // Check for duplicate unresolved alerts of the same type\n    const alerts = await this.state.storage.list({ prefix: 'alert_' });\n    for (const [key, alert] of alerts) {\n      if (alert.type === type && !alert.resolved_at) {\n        // Check if this is within cooldown period (1 hour)\n        const hoursSinceCreation = (Date.now() - new Date(alert.created_at).getTime()) / (1000 * 60 * 60);\n        if (hoursSinceCreation < 1) {\n          // Duplicate alert within cooldown, don't create new one\n          return new Response(\n            JSON.stringify({ alert, duplicate: true }),\n            { status: 200, headers: { 'Content-Type': 'application/json' } }\n          );\n        }\n      }\n    }\n\n    // Create new alert\n    const alertId = `alert_${Date.now()}_${Math.random().toString(36).substring(7)}`;\n    const alert = {\n      id: alertId,\n      type,\n      severity,\n      title,\n      message,\n      metadata,\n      created_at: new Date().toISOString(),\n      acknowledged_at: null,\n      acknowledged_by: null,\n      resolved_at: null\n    };\n\n    await this.state.storage.put(alertId, alert);\n\n    return new Response(\n      JSON.stringify({ alert, duplicate: false }),\n      { status: 201, headers: { 'Content-Type': 'application/json' } }\n    );\n  }\n\n  /**\n   * List alerts with optional filtering\n   */\n  async listAlerts(searchParams) {\n    const severity = searchParams.get('severity');\n    const status = searchParams.get('status'); // 'active', 'acknowledged', 'resolved'\n    const limit = parseInt(searchParams.get('limit') || '100');\n\n    const alerts = await this.state.storage.list({ prefix: 'alert_' });\n    let alertList = Array.from(alerts.values());\n\n    // Filter by severity\n    if (severity) {\n      alertList = alertList.filter(a => a.severity === severity);\n    }\n\n    // Filter by status\n    if (status === 'active') {\n      alertList = alertList.filter(a => !a.acknowledged_at && !a.resolved_at);\n    } else if (status === 'acknowledged') {\n      alertList = alertList.filter(a => a.acknowledged_at && !a.resolved_at);\n    } else if (status === 'resolved') {\n      alertList = alertList.filter(a => a.resolved_at);\n    }\n\n    // Sort by creation date (newest first)\n    alertList.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));\n\n    // Apply limit\n    alertList = alertList.slice(0, limit);\n\n    return new Response(\n      JSON.stringify({ alerts: alertList, count: alertList.length }),\n      { status: 200, headers: { 'Content-Type': 'application/json' } }\n    );\n  }\n\n  /**\n   * Acknowledge an alert\n   */\n  async acknowledgeAlert(alertId, body) {\n    const { acknowledged_by } = body;\n\n    if (!acknowledged_by) {\n      return new Response(\n        JSON.stringify({ error: 'acknowledged_by required' }),\n        { status: 400, headers: { 'Content-Type': 'application/json' } }\n      );\n    }\n\n    const alert = await this.state.storage.get(alertId);\n    if (!alert) {\n      return new Response(\n        JSON.stringify({ error: 'Alert not found' }),\n        { status: 404, headers: { 'Content-Type': 'application/json' } }\n      );\n    }\n\n    alert.acknowledged_at = new Date().toISOString();\n    alert.acknowledged_by = acknowledged_by;\n    await this.state.storage.put(alertId, alert);\n\n    return new Response(\n      JSON.stringify({ alert }),\n      { status: 200, headers: { 'Content-Type': 'application/json' } }\n    );\n  }\n\n  /**\n   * Resolve an alert (auto-resolution for health alerts)\n   */\n  async resolveAlert(alertId) {\n    const alert = await this.state.storage.get(alertId);\n    if (!alert) {\n      return new Response(\n        JSON.stringify({ error: 'Alert not found' }),\n        { status: 404, headers: { 'Content-Type': 'application/json' } }\n      );\n    }\n\n    alert.resolved_at = new Date().toISOString();\n    await this.state.storage.put(alertId, alert);\n\n    return new Response(\n      JSON.stringify({ alert }),\n      { status: 200, headers: { 'Content-Type': 'application/json' } }\n    );\n  }\n}\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/durable-objects/application-registry.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/durable-objects/application-registry.test.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/durable-objects/audit-log.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/durable-objects/content-metadata-rate-limit.test.js","messages":[{"ruleId":"no-unused-vars","severity":1,"message":"'now' is assigned a value but never used.","line":417,"column":13,"nodeType":"Identifier","messageId":"unusedVar","endLine":417,"endColumn":16}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * ContentMetadata Durable Object - Rate Limit Tests\n * Tests for rate limiting operations in src/durable-objects/content-metadata.js\n */\n\nimport { describe, it, expect, beforeEach } from 'vitest';\nimport { ContentMetadata } from './content-metadata.js';\n\n/**\n * Helper to create mock Durable Object state with in-memory storage\n */\nfunction createMockState(initialData = {}) {\n  const storage = new Map();\n  \n  // Initialize with content if provided\n  if (initialData.content) {\n    storage.set('content', initialData.content);\n  }\n  \n  return {\n    storage: {\n      get: async (key) => storage.get(key),\n      put: async (key, value) => storage.set(key, value),\n      delete: async (key) => storage.delete(key),\n    },\n    blockConcurrencyWhile: async (callback) => await callback(),\n  };\n}\n\n/**\n * Helper to create content metadata with rate limiting fields\n */\nfunction createContentWithRateLimit(overrides = {}) {\n  const now = new Date();\n  const thirtyDaysFromNow = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);\n  \n  return {\n    hash_256t: '00000008YWJjZGVmZ2g',\n    size_bytes: 1024,\n    uploader_id: 'user_123',\n    created_at: now.toISOString(),\n    expires_at: new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000).toISOString(),\n    last_served_at: null,\n    rate_limit_records: [],\n    default_rate_limit: {\n      min_time_between_requests_ms: 30 * 24 * 60 * 60 * 1000, // 30 days\n      expires_at: thirtyDaysFromNow.toISOString()\n    },\n    ...overrides,\n  };\n}\n\ndescribe('ContentMetadata Rate Limit Tests', () => {\n  let mockState;\n  let mockEnv;\n  let contentMetadata;\n\n  beforeEach(() => {\n    mockState = createMockState();\n    mockEnv = {\n      ENVIRONMENT: 'test',\n    };\n    contentMetadata = new ContentMetadata(mockState, mockEnv);\n  });\n\n  // TEST-DEFAULT-001: Newly uploaded non-inline CID should have 30-day MTBR default\n  describe('TEST-DEFAULT-001: Default rate limit for new uploads', () => {\n    it('should set 30-day MTBR for non-inline content on creation', async () => {\n      const now = Date.now();\n      const request = new Request('http://test.com/content', {\n        method: 'POST',\n        headers: { 'content-type': 'application/json' },\n        body: JSON.stringify({\n          hash_256t: '00000008YWJjZGVmZ2g',\n          size_bytes: 1024, // Non-inline (> 64 bytes)\n          uploader_id: 'user_123',\n          retention_months: 12,\n          amount_cents: 100,\n          payment_id: 'pay_123'\n        })\n      });\n\n      const response = await contentMetadata.fetch(request);\n      expect(response.status).toBe(201); // createContent returns 201 for new content\n\n      const data = await response.json();\n      expect(data.default_rate_limit).toBeDefined();\n      expect(data.default_rate_limit.min_time_between_requests_ms).toBe(30 * 24 * 60 * 60 * 1000);\n      \n      // Verify expires_at is approximately 30 days from now\n      const expiresAt = new Date(data.default_rate_limit.expires_at).getTime();\n      const expectedExpiry = now + (30 * 24 * 60 * 60 * 1000);\n      expect(Math.abs(expiresAt - expectedExpiry)).toBeLessThan(5000); // Within 5 seconds\n    });\n\n    it('should NOT set default rate limit for inline content', async () => {\n      const request = new Request('http://test.com/content', {\n        method: 'POST',\n        headers: { 'content-type': 'application/json' },\n        body: JSON.stringify({\n          hash_256t: '00000008YWJj',\n          size_bytes: 64, // Inline (≤ 64 bytes)\n          uploader_id: 'user_123',\n          retention_months: 12,\n          amount_cents: 0,\n          payment_id: 'pay_123'\n        })\n      });\n\n      const response = await contentMetadata.fetch(request);\n      expect(response.status).toBe(201); // createContent returns 201 for new content\n\n      const data = await response.json();\n      expect(data.default_rate_limit).toBeNull();\n    });\n  });\n\n  // TEST-DEFAULT-003: First request after upload should succeed\n  describe('TEST-DEFAULT-003: First request with null last_served_at', () => {\n    it('should allow first request when last_served_at is null', async () => {\n      const content = createContentWithRateLimit();\n      mockState = createMockState({ content });\n      contentMetadata = new ContentMetadata(mockState, mockEnv);\n\n      const request = new Request('http://test.com/rate-limit/check', {\n        method: 'POST'\n      });\n\n      const response = await contentMetadata.fetch(request);\n      expect(response.status).toBe(200);\n\n      const data = await response.json();\n      expect(data.allowed).toBe(true);\n      // The response doesn't include last_served_at, but it should be updated in storage\n      expect(data.next_available_at).toBeDefined();\n      expect(data.effective_mtbr_ms).toBeDefined();\n    });\n  });\n\n  // TEST-DEFAULT-004: Second request immediately after first should fail with 429\n  describe('TEST-DEFAULT-004: Immediate second request', () => {\n    it('should return 429 for second request before MTBR elapsed', async () => {\n      const content = createContentWithRateLimit({\n        last_served_at: new Date().toISOString() // Just served\n      });\n      mockState = createMockState({ content });\n      contentMetadata = new ContentMetadata(mockState, mockEnv);\n\n      const request = new Request('http://test.com/rate-limit/check', {\n        method: 'POST'\n      });\n\n      const response = await contentMetadata.fetch(request);\n      expect(response.status).toBe(429);\n\n      const data = await response.json();\n      expect(data.error).toBe('rate_limit_exceeded');\n      expect(data.retry_after_seconds).toBeDefined();\n      expect(data.next_available_at).toBeDefined();\n    });\n  });\n\n  // TEST-EXISTING-001: CID with no default_rate_limit should return 429\n  describe('TEST-EXISTING-001: Existing CID without default rate limit', () => {\n    it('should return 429 for CID with no default_rate_limit', async () => {\n      const content = createContentWithRateLimit({\n        default_rate_limit: null,\n        rate_limit_records: []\n      });\n      mockState = createMockState({ content });\n      contentMetadata = new ContentMetadata(mockState, mockEnv);\n\n      const request = new Request('http://test.com/rate-limit/check', {\n        method: 'POST'\n      });\n\n      const response = await contentMetadata.fetch(request);\n      expect(response.status).toBe(429);\n\n      const data = await response.json();\n      expect(data.error).toBe('rate_limit_exceeded');\n      expect(data.next_available_at).toBeNull(); // Infinite MTBR\n    });\n  });\n\n  // TEST-MTBR-001: Single active rate limit should be effective\n  describe('TEST-MTBR-001: Single active rate limit', () => {\n    it('should use single purchased rate limit', async () => {\n      const now = new Date();\n      const futureExpiry = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days\n      \n      const content = createContentWithRateLimit({\n        default_rate_limit: null,\n        rate_limit_records: [{\n          record_id: 'rec_123',\n          payer_id: 'user_456',\n          min_time_between_requests_ms: 1000, // 1 second\n          starts_at: now.toISOString(),\n          expires_at: futureExpiry.toISOString(),\n          max_requests: 604800,\n          max_bytes: 629145600,\n          price_cents: 629,\n          created_at: now.toISOString()\n        }],\n        last_served_at: null\n      });\n      mockState = createMockState({ content });\n      contentMetadata = new ContentMetadata(mockState, mockEnv);\n\n      const request = new Request('http://test.com/rate-limit', {\n        method: 'GET'\n      });\n\n      const response = await contentMetadata.fetch(request);\n      expect(response.status).toBe(200);\n\n      const data = await response.json();\n      expect(data.effective_mtbr_ms).toBe(1000);\n      expect(data.active_rate_limits).toHaveLength(1);\n    });\n  });\n\n  // TEST-MTBR-002: Multiple active rate limits should use lowest MTBR\n  describe('TEST-MTBR-002: Multiple active rate limits', () => {\n    it('should use lowest MTBR when multiple rate limits active', async () => {\n      const now = new Date();\n      const futureExpiry = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);\n      \n      const content = createContentWithRateLimit({\n        default_rate_limit: null,\n        rate_limit_records: [\n          {\n            record_id: 'rec_1',\n            payer_id: 'user_1',\n            min_time_between_requests_ms: 5000, // 5 seconds\n            starts_at: now.toISOString(),\n            expires_at: futureExpiry.toISOString(),\n            max_requests: 120960,\n            max_bytes: 123863040,\n            price_cents: 124,\n            created_at: now.toISOString()\n          },\n          {\n            record_id: 'rec_2',\n            payer_id: 'user_2',\n            min_time_between_requests_ms: 1000, // 1 second (lowest)\n            starts_at: now.toISOString(),\n            expires_at: futureExpiry.toISOString(),\n            max_requests: 604800,\n            max_bytes: 619724800,\n            price_cents: 620,\n            created_at: now.toISOString()\n          },\n          {\n            record_id: 'rec_3',\n            payer_id: 'user_3',\n            min_time_between_requests_ms: 10000, // 10 seconds\n            starts_at: now.toISOString(),\n            expires_at: futureExpiry.toISOString(),\n            max_requests: 60480,\n            max_bytes: 61972480,\n            price_cents: 62,\n            created_at: now.toISOString()\n          }\n        ]\n      });\n      mockState = createMockState({ content });\n      contentMetadata = new ContentMetadata(mockState, mockEnv);\n\n      const request = new Request('http://test.com/rate-limit', {\n        method: 'GET'\n      });\n\n      const response = await contentMetadata.fetch(request);\n      expect(response.status).toBe(200);\n\n      const data = await response.json();\n      expect(data.effective_mtbr_ms).toBe(1000); // Lowest MTBR\n      expect(data.active_rate_limits).toHaveLength(3);\n    });\n  });\n\n  // TEST-MTBR-003: Expired rate limit should not be considered\n  describe('TEST-MTBR-003: Expired rate limits', () => {\n    it('should ignore expired rate limit records', async () => {\n      const now = new Date();\n      const pastExpiry = new Date(now.getTime() - 24 * 60 * 60 * 1000); // Yesterday\n      const futureExpiry = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);\n      \n      const content = createContentWithRateLimit({\n        default_rate_limit: null,\n        rate_limit_records: [\n          {\n            record_id: 'rec_expired',\n            payer_id: 'user_1',\n            min_time_between_requests_ms: 100, // Expired, but lower\n            starts_at: new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000).toISOString(),\n            expires_at: pastExpiry.toISOString(),\n            max_requests: 100,\n            max_bytes: 102400,\n            price_cents: 1,\n            created_at: pastExpiry.toISOString()\n          },\n          {\n            record_id: 'rec_active',\n            payer_id: 'user_2',\n            min_time_between_requests_ms: 5000, // Active\n            starts_at: now.toISOString(),\n            expires_at: futureExpiry.toISOString(),\n            max_requests: 120960,\n            max_bytes: 123863040,\n            price_cents: 124,\n            created_at: now.toISOString()\n          }\n        ]\n      });\n      mockState = createMockState({ content });\n      contentMetadata = new ContentMetadata(mockState, mockEnv);\n\n      const request = new Request('http://test.com/rate-limit', {\n        method: 'GET'\n      });\n\n      const response = await contentMetadata.fetch(request);\n      expect(response.status).toBe(200);\n\n      const data = await response.json();\n      expect(data.effective_mtbr_ms).toBe(5000); // Only active rate limit\n      expect(data.active_rate_limits).toHaveLength(1);\n    });\n  });\n\n  // TEST-MTBR-004: Future rate limit should not be considered\n  describe('TEST-MTBR-004: Future rate limits', () => {\n    it('should ignore future rate limit records not yet started', async () => {\n      const now = new Date();\n      const futureStart = new Date(now.getTime() + 24 * 60 * 60 * 1000); // Tomorrow\n      const futureExpiry = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000);\n      \n      const content = createContentWithRateLimit({\n        default_rate_limit: null,\n        rate_limit_records: [\n          {\n            record_id: 'rec_future',\n            payer_id: 'user_1',\n            min_time_between_requests_ms: 100, // Future, but lower\n            starts_at: futureStart.toISOString(),\n            expires_at: futureExpiry.toISOString(),\n            max_requests: 100,\n            max_bytes: 102400,\n            price_cents: 1,\n            created_at: now.toISOString()\n          }\n        ]\n      });\n      mockState = createMockState({ content });\n      contentMetadata = new ContentMetadata(mockState, mockEnv);\n\n      const request = new Request('http://test.com/rate-limit', {\n        method: 'GET'\n      });\n\n      const response = await contentMetadata.fetch(request);\n      expect(response.status).toBe(200);\n\n      const data = await response.json();\n      expect(data.is_rate_limited).toBe(true); // No active rate limits\n      expect(data.effective_mtbr_ms).toBe(null);\n      expect(data.active_rate_limits).toHaveLength(0);\n    });\n  });\n\n  // TEST-MTBR-007: Mix of default and purchased rate limits\n  describe('TEST-MTBR-007: Default and purchased rate limits', () => {\n    it('should use lowest MTBR between default and purchased', async () => {\n      const now = new Date();\n      const futureExpiry = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);\n      \n      const content = createContentWithRateLimit({\n        // Default: 30 days MTBR\n        rate_limit_records: [\n          {\n            record_id: 'rec_1',\n            payer_id: 'user_1',\n            min_time_between_requests_ms: 1000, // 1 second (lowest)\n            starts_at: now.toISOString(),\n            expires_at: futureExpiry.toISOString(),\n            max_requests: 604800,\n            max_bytes: 619724800,\n            price_cents: 620,\n            created_at: now.toISOString()\n          }\n        ]\n      });\n      mockState = createMockState({ content });\n      contentMetadata = new ContentMetadata(mockState, mockEnv);\n\n      const request = new Request('http://test.com/rate-limit', {\n        method: 'GET'\n      });\n\n      const response = await contentMetadata.fetch(request);\n      expect(response.status).toBe(200);\n\n      const data = await response.json();\n      expect(data.effective_mtbr_ms).toBe(1000); // Purchased is lower than default\n    });\n  });\n\n  // TEST-PURCHASE-001: Valid purchase should create rate limit record\n  describe('TEST-PURCHASE-001: Rate limit purchase', () => {\n    it('should create rate limit record on successful purchase', async () => {\n      const content = createContentWithRateLimit();\n      mockState = createMockState({ content });\n      contentMetadata = new ContentMetadata(mockState, mockEnv);\n\n      const now = new Date();\n      const request = new Request('http://test.com/rate-limit/purchase', {\n        method: 'POST',\n        headers: { 'content-type': 'application/json' },\n        body: JSON.stringify({\n          record_id: 'rec_new',\n          payer_id: 'user_456',\n          min_time_between_requests_ms: 1000,\n          duration_seconds: 604800, // 7 days\n          max_requests: 604800,\n          max_bytes: 619724800,\n          price_cents: 620\n        })\n      });\n\n      const response = await contentMetadata.fetch(request);\n      expect(response.status).toBe(201); // Rate limit purchase returns 201\n\n      const data = await response.json();\n      expect(data.record_id).toBe('rec_new');\n      expect(data.starts_at).toBeDefined();\n      expect(data.expires_at).toBeDefined();\n\n      // Verify record was added\n      const statusRequest = new Request('http://test.com/rate-limit', {\n        method: 'GET'\n      });\n      const statusResponse = await contentMetadata.fetch(statusRequest);\n      const statusData = await statusResponse.json();\n      \n      expect(statusData.active_rate_limits).toHaveLength(1);\n      expect(statusData.active_rate_limits[0].record_id).toBe('rec_new');\n    });\n  });\n});\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/durable-objects/content-metadata.js","messages":[{"ruleId":"no-unused-vars","severity":1,"message":"'MINIMUM_MTBR_MS' is assigned a value but never used.","line":8,"column":7,"nodeType":"Identifier","messageId":"unusedVar","endLine":8,"endColumn":22},{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 33 to the 15 allowed.","line":16,"column":9,"nodeType":null,"messageId":"refactorFunction","endLine":16,"endColumn":14},{"ruleId":"complexity","severity":1,"message":"Async method 'fetch' has a complexity of 34. Maximum allowed is 10.","line":16,"column":14,"nodeType":"FunctionExpression","messageId":"complex","endLine":121,"endColumn":4}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":3,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * ContentMetadata Durable Object\n * Stores metadata for uploaded content including hash, size, expiration, and contest status\n */\n\n// Rate limiting constants\nconst DEFAULT_RATE_LIMIT_MS = 30 * 24 * 60 * 60 * 1000; // 30 days in milliseconds\nconst MINIMUM_MTBR_MS = 100; // Minimum time between requests: 100ms\n\nexport class ContentMetadata {\n  constructor(state, env) {\n    this.state = state;\n    this.env = env;\n  }\n\n  async fetch(request) {\n    const url = new URL(request.url);\n    const method = request.method;\n\n    try {\n      // Create or update content record\n      if (url.pathname === '/content' && method === 'POST') {\n        const data = await request.json();\n        return await this.createContent(data);\n      }\n\n      // Get content metadata\n      if (url.pathname === '/content' && method === 'GET') {\n        return await this.getContent();\n      }\n\n      // Check if content exists\n      if (url.pathname === '/exists' && method === 'GET') {\n        return await this.checkExists();\n      }\n\n      // Extend content retention\n      if (url.pathname === '/extend' && method === 'POST') {\n        const data = await request.json();\n        return await this.extendRetention(data);\n      }\n\n      // Increment download count\n      if (url.pathname === '/increment-download' && method === 'POST') {\n        return await this.incrementDownloadCount();\n      }\n\n      // Check and update rate limit\n      if (url.pathname === '/rate-limit/check' && method === 'POST') {\n        return await this.checkRateLimit();\n      }\n\n      // Get rate limit status\n      if (url.pathname === '/rate-limit' && method === 'GET') {\n        return await this.getRateLimitStatus();\n      }\n\n      // Purchase rate limit\n      if (url.pathname === '/rate-limit/purchase' && method === 'POST') {\n        const data = await request.json();\n        return await this.purchaseRateLimit(data);\n      }\n\n      // Get alternate suppliers\n      if (url.pathname === '/suppliers' && method === 'GET') {\n        return await this.getSuppliers();\n      }\n\n      // Add alternate supplier\n      if (url.pathname === '/suppliers/add' && method === 'POST') {\n        const data = await request.json();\n        return await this.addSupplier(data);\n      }\n\n      // Remove alternate supplier\n      if (url.pathname === '/suppliers/remove' && method === 'POST') {\n        const data = await request.json();\n        return await this.removeSupplier(data);\n      }\n\n      // Delete content metadata (hard delete for expiration)\n      if (url.pathname === '/content' && method === 'DELETE') {\n        return await this.deleteContent();\n      }\n\n      // Soft delete content\n      if (url.pathname === '/soft-delete' && method === 'POST') {\n        const data = await request.json();\n        return await this.softDeleteContent(data);\n      }\n\n      // Mark R2 deletion complete\n      if (url.pathname === '/r2-deleted' && method === 'POST') {\n        return await this.markR2Deleted();\n      }\n\n      // Mark content as contested (HTTP 451)\n      if (url.pathname === '/contested' && method === 'POST') {\n        const data = await request.json();\n        return await this.markContested(data);\n      }\n\n      // Unmark content as contested\n      if (url.pathname === '/contested' && method === 'DELETE') {\n        return await this.unmarkContested();\n      }\n\n      return new Response('Not Found', { status: 404 });\n    } catch (error) {\n      return new Response(\n        JSON.stringify({\n          error: 'Internal error',\n          message: error.message\n        }),\n        {\n          status: 500,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n  }\n\n  /**\n   * Create content record\n   */\n  async createContent(data) {\n    const existingContent = await this.state.storage.get('content');\n\n    if (existingContent) {\n      // Content already exists, extend retention instead\n      return new Response(\n        JSON.stringify({\n          error: 'Content already exists',\n          message: 'This content has already been uploaded',\n          content: existingContent\n        }),\n        {\n          status: 409,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Calculate expiration date\n    const created_at = new Date();\n    const expires_at = new Date(created_at);\n    const currentDay = expires_at.getDate();\n    expires_at.setMonth(expires_at.getMonth() + data.retention_months);\n    // If the day changed due to month length differences, set to last day of that month\n    if (expires_at.getDate() !== currentDay) {\n      expires_at.setDate(0); // Sets to last day of previous month\n    }\n\n    // Determine if content is inline (≤64 bytes)\n    // Inline content doesn't need rate limiting\n    const isInline = data.size_bytes <= 64;\n\n    const content = {\n      hash_256t: data.hash_256t,\n      size_bytes: data.size_bytes,\n      content_type: data.content_type || 'application/octet-stream',\n      uploader_id: data.uploader_id,\n      created_at: created_at.toISOString(),\n      expires_at: expires_at.toISOString(),\n      retention_months: data.retention_months,\n      retention_payments: [\n        {\n          payment_id: data.payment_id || null,\n          amount_cents: data.amount_cents,\n          months_added: data.retention_months,\n          payer_id: data.uploader_id,\n          created_at: created_at.toISOString()\n        }\n      ],\n      // Rate limiting fields\n      last_served_at: null,\n      rate_limit_records: [],\n      // Set default rate limit for non-inline content only\n      default_rate_limit: isInline ? null : {\n        min_time_between_requests_ms: DEFAULT_RATE_LIMIT_MS,\n        expires_at: new Date(created_at.getTime() + DEFAULT_RATE_LIMIT_MS).toISOString()\n      },\n      // Alternate suppliers\n      alternate_suppliers: [],\n      // Contested content flag (HTTP 451)\n      contested: false,\n      contested_reason: null,\n      \n      // Deletion fields (for soft delete)\n      deleted_at: null,\n      deleted_by: null,\n      deletion_reason: null,\n      pending_r2_deletion: false\n    };\n\n    await this.state.storage.put('content', content);\n\n    return new Response(JSON.stringify(content), {\n      status: 201,\n      headers: { 'content-type': 'application/json' }\n    });\n  }\n\n  /**\n   * Get content metadata\n   */\n  async getContent() {\n    const content = await this.state.storage.get('content');\n\n    if (!content) {\n      return new Response(\n        JSON.stringify({\n          error: 'Content not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    return new Response(JSON.stringify(content), {\n      status: 200,\n      headers: { 'content-type': 'application/json' }\n    });\n  }\n\n  /**\n   * Check if content exists\n   */\n  async checkExists() {\n    const content = await this.state.storage.get('content');\n\n    if (!content) {\n      return new Response(\n        JSON.stringify({\n          exists: false\n        }),\n        {\n          status: 200,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    return new Response(\n      JSON.stringify({\n        exists: true,\n        size_bytes: content.size_bytes,\n        expires_at: content.expires_at\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Extend content retention\n   */\n  async extendRetention(data) {\n    const content = await this.state.storage.get('content');\n\n    if (!content) {\n      return new Response(\n        JSON.stringify({\n          error: 'Content not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Calculate new expiration\n    const current_expires_at = new Date(content.expires_at);\n    const new_expires_at = new Date(current_expires_at);\n    const currentDay = new_expires_at.getDate();\n    new_expires_at.setMonth(new_expires_at.getMonth() + data.months_to_add);\n    // If the day changed due to month length differences, set to last day of that month\n    if (new_expires_at.getDate() !== currentDay) {\n      new_expires_at.setDate(0); // Sets to last day of previous month\n    }\n\n    // Add payment record\n    const payment = {\n      payment_id: data.payment_id || null,\n      amount_cents: data.amount_cents,\n      months_added: data.months_to_add,\n      payer_id: data.payer_id || null,\n      created_at: new Date().toISOString()\n    };\n\n    content.expires_at = new_expires_at.toISOString();\n    content.retention_months = (content.retention_months || 0) + data.months_to_add;\n    content.retention_payments.push(payment);\n\n    await this.state.storage.put('content', content);\n\n    return new Response(\n      JSON.stringify({\n        expires_at: content.expires_at,\n        months_added: data.months_to_add,\n        total_payments: content.retention_payments.length\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Increment download count\n   */\n  async incrementDownloadCount() {\n    const content = await this.state.storage.get('content');\n\n    if (!content) {\n      return new Response(\n        JSON.stringify({\n          error: 'Content not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Initialize download count if not present\n    if (typeof content.download_count === 'undefined') {\n      content.download_count = 0;\n    }\n\n    content.download_count += 1;\n    content.last_downloaded_at = new Date().toISOString();\n\n    await this.state.storage.put('content', content);\n\n    return new Response(\n      JSON.stringify({\n        download_count: content.download_count\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Check rate limit and update last_served_at if allowed\n   * Returns 200 if allowed, 429 if rate limited\n   */\n  async checkRateLimit() {\n    const content = await this.state.storage.get('content');\n\n    if (!content) {\n      return new Response(\n        JSON.stringify({\n          error: 'Content not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    const now = new Date();\n    const effectiveMTBR = this.getEffectiveMTBR(content, now);\n\n    // If MTBR is infinite, content is rate limited\n    if (effectiveMTBR === Infinity) {\n      return new Response(\n        JSON.stringify({\n          error: 'rate_limit_exceeded',\n          message: 'Rate limit exceeded. Purchase bandwidth to serve this content.',\n          cid: content.hash_256t,\n          retry_after_seconds: null,\n          next_available_at: null\n        }),\n        {\n          status: 429,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Check if this is the first request (last_served_at is null)\n    if (!content.last_served_at) {\n      // Allow the first request\n      content.last_served_at = now.toISOString();\n      await this.state.storage.put('content', content);\n\n      const nextAvailableAt = new Date(now.getTime() + effectiveMTBR);\n      return new Response(\n        JSON.stringify({\n          allowed: true,\n          next_available_at: nextAvailableAt.toISOString(),\n          effective_mtbr_ms: effectiveMTBR\n        }),\n        {\n          status: 200,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Check if enough time has passed\n    const lastServedAt = new Date(content.last_served_at);\n    const timeSinceLastServed = now.getTime() - lastServedAt.getTime();\n\n    if (timeSinceLastServed >= effectiveMTBR) {\n      // Allow the request\n      content.last_served_at = now.toISOString();\n      await this.state.storage.put('content', content);\n\n      const nextAvailableAt = new Date(now.getTime() + effectiveMTBR);\n      return new Response(\n        JSON.stringify({\n          allowed: true,\n          next_available_at: nextAvailableAt.toISOString(),\n          effective_mtbr_ms: effectiveMTBR\n        }),\n        {\n          status: 200,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Rate limited\n    const nextAvailableAt = new Date(lastServedAt.getTime() + effectiveMTBR);\n    const retryAfterSeconds = Math.ceil((nextAvailableAt.getTime() - now.getTime()) / 1000);\n\n    return new Response(\n      JSON.stringify({\n        error: 'rate_limit_exceeded',\n        message: `Rate limit exceeded. Wait until ${nextAvailableAt.toISOString()}`,\n        cid: content.hash_256t,\n        retry_after_seconds: retryAfterSeconds,\n        next_available_at: nextAvailableAt.toISOString()\n      }),\n      {\n        status: 429,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Get rate limit status (does not update last_served_at)\n   */\n  async getRateLimitStatus() {\n    const content = await this.state.storage.get('content');\n\n    if (!content) {\n      return new Response(\n        JSON.stringify({\n          error: 'Content not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    const now = new Date();\n    const isInline = content.size_bytes <= 64;\n    const effectiveMTBR = this.getEffectiveMTBR(content, now);\n\n    // Get active rate limits\n    const activeRateLimits = (content.rate_limit_records || [])\n      .filter(record => {\n        const startsAt = new Date(record.starts_at);\n        const expiresAt = new Date(record.expires_at);\n        return startsAt <= now && expiresAt > now;\n      })\n      .map(record => ({\n        record_id: record.record_id,\n        min_time_between_requests_ms: record.min_time_between_requests_ms,\n        expires_at: record.expires_at\n      }));\n\n    // Calculate next_available_at\n    let nextAvailableAt = null;\n    if (content.last_served_at && effectiveMTBR !== Infinity) {\n      const lastServedAt = new Date(content.last_served_at);\n      const calculatedNextAvailable = new Date(lastServedAt.getTime() + effectiveMTBR);\n      if (calculatedNextAvailable > now) {\n        nextAvailableAt = calculatedNextAvailable.toISOString();\n      }\n    }\n\n    return new Response(\n      JSON.stringify({\n        cid: content.hash_256t,\n        size_bytes: content.size_bytes,\n        is_inline: isInline,\n        last_served_at: content.last_served_at,\n        effective_mtbr_ms: effectiveMTBR === Infinity ? null : effectiveMTBR,\n        next_available_at: nextAvailableAt,\n        is_rate_limited: effectiveMTBR === Infinity,\n        active_rate_limits: activeRateLimits,\n        default_rate_limit: content.default_rate_limit,\n        content_expires_at: content.expires_at\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Purchase rate limit bandwidth for this content\n   */\n  async purchaseRateLimit(data) {\n    const content = await this.state.storage.get('content');\n\n    if (!content) {\n      return new Response(\n        JSON.stringify({\n          error: 'Content not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Create rate limit record\n    const now = new Date();\n    const record = {\n      record_id: data.record_id,\n      payer_id: data.payer_id,\n      min_time_between_requests_ms: data.min_time_between_requests_ms,\n      starts_at: now.toISOString(),\n      expires_at: new Date(now.getTime() + data.duration_seconds * 1000).toISOString(),\n      max_requests: data.max_requests,\n      max_bytes: data.max_bytes,\n      price_cents: data.price_cents,\n      created_at: now.toISOString()\n    };\n\n    // Initialize rate_limit_records if not present (for existing content)\n    if (!content.rate_limit_records) {\n      content.rate_limit_records = [];\n    }\n\n    content.rate_limit_records.push(record);\n    await this.state.storage.put('content', content);\n\n    return new Response(\n      JSON.stringify({\n        record_id: record.record_id,\n        starts_at: record.starts_at,\n        expires_at: record.expires_at\n      }),\n      {\n        status: 201,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Calculate effective minimum time between requests\n   * Returns Infinity if no active rate limits exist\n   */\n  getEffectiveMTBR(content, now) {\n    const activeRateLimits = [];\n\n    // Check purchased rate limits\n    if (content.rate_limit_records) {\n      for (const record of content.rate_limit_records) {\n        const startsAt = new Date(record.starts_at);\n        const expiresAt = new Date(record.expires_at);\n        if (startsAt <= now && expiresAt > now) {\n          activeRateLimits.push(record.min_time_between_requests_ms);\n        }\n      }\n    }\n\n    // Check default rate limit\n    if (content.default_rate_limit) {\n      const expiresAt = new Date(content.default_rate_limit.expires_at);\n      if (expiresAt > now) {\n        activeRateLimits.push(content.default_rate_limit.min_time_between_requests_ms);\n      }\n    }\n\n    // If no active rate limits, return Infinity (blocked)\n    if (activeRateLimits.length === 0) {\n      return Infinity;\n    }\n\n    // Return the lowest MTBR (most permissive)\n    return Math.min(...activeRateLimits);\n  }\n\n  /**\n   * Get alternate suppliers\n   */\n  async getSuppliers() {\n    const content = await this.state.storage.get('content');\n\n    if (!content) {\n      return new Response(\n        JSON.stringify({\n          error: 'Content not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    return new Response(\n      JSON.stringify({\n        alternate_suppliers: content.alternate_suppliers || []\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Add alternate supplier\n   */\n  async addSupplier(data) {\n    const content = await this.state.storage.get('content');\n\n    if (!content) {\n      return new Response(\n        JSON.stringify({\n          error: 'Content not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    if (!content.alternate_suppliers) {\n      content.alternate_suppliers = [];\n    }\n\n    // Check if supplier already exists\n    const exists = content.alternate_suppliers.some(s => s.supplier_id === data.supplier_id);\n    if (exists) {\n      return new Response(\n        JSON.stringify({\n          error: 'Supplier already added'\n        }),\n        {\n          status: 409,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Add supplier\n    content.alternate_suppliers.push({\n      supplier_id: data.supplier_id,\n      supplier_name: data.supplier_name,\n      supplier_url: data.supplier_url,\n      verified_at: new Date().toISOString()\n    });\n\n    await this.state.storage.put('content', content);\n\n    return new Response(\n      JSON.stringify({ success: true }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Remove alternate supplier\n   */\n  async removeSupplier(data) {\n    const content = await this.state.storage.get('content');\n\n    if (!content) {\n      return new Response(\n        JSON.stringify({\n          error: 'Content not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    if (!content.alternate_suppliers) {\n      content.alternate_suppliers = [];\n    }\n\n    // Remove supplier\n    content.alternate_suppliers = content.alternate_suppliers.filter(\n      s => s.supplier_id !== data.supplier_id\n    );\n\n    await this.state.storage.put('content', content);\n\n    return new Response(\n      JSON.stringify({ success: true }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Delete content metadata (hard delete)\n   * Used by expiration system to remove expired content\n   */\n  async deleteContent() {\n    const content = await this.state.storage.get('content');\n\n    if (!content) {\n      // Already deleted or never existed\n      return new Response(\n        JSON.stringify({ success: true, alreadyDeleted: true }),\n        {\n          status: 200,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Delete all stored data for this content\n    await this.state.storage.deleteAll();\n\n    return new Response(\n      JSON.stringify({ success: true }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Mark content as contested (HTTP 451 - Unavailable For Legal Reasons)\n   * This prevents content from being served while legal process is ongoing\n   */\n  async markContested(data) {\n    const content = await this.state.storage.get('content');\n\n    if (!content) {\n      return new Response(\n        JSON.stringify({\n          error: 'Content not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Validate input\n    if (!data || typeof data !== 'object') {\n      return new Response(\n        JSON.stringify({\n          error: 'Invalid request',\n          message: 'Request body must be a valid JSON object'\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    content.contested = true;\n    content.contested_reason = data.reason || 'Content is subject to legal review';\n    content.contested_at = new Date().toISOString();\n    content.contested_by = data.admin_id || 'system';\n\n    await this.state.storage.put('content', content);\n\n    return new Response(\n      JSON.stringify({ success: true, contested: true }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Unmark content as contested (restore normal access)\n   */\n  async unmarkContested() {\n    const content = await this.state.storage.get('content');\n\n    if (!content) {\n      return new Response(\n        JSON.stringify({\n          error: 'Content not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    content.contested = false;\n    content.contested_reason = null;\n    content.uncontested_at = new Date().toISOString();\n\n    await this.state.storage.put('content', content);\n\n    return new Response(\n      JSON.stringify({ success: true, contested: false }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Soft delete content\n   * Marks content as deleted but doesn't remove R2 data immediately\n   */\n  async softDeleteContent(data) {\n    const content = await this.state.storage.get('content');\n\n    if (!content) {\n      return new Response(\n        JSON.stringify({ error: 'Content not found' }),\n        { status: 404, headers: { 'content-type': 'application/json' } }\n      );\n    }\n\n    if (content.deleted_at) {\n      return new Response(\n        JSON.stringify({ error: 'Content already deleted' }),\n        { status: 409, headers: { 'content-type': 'application/json' } }\n      );\n    }\n\n    // Mark as deleted\n    content.deleted_at = new Date().toISOString();\n    content.deleted_by = data.deleted_by || 'unknown';\n    content.deletion_reason = data.deletion_reason || 'user_request';\n    content.pending_r2_deletion = true;\n\n    await this.state.storage.put('content', content);\n\n    return new Response(\n      JSON.stringify({ success: true, deleted: true }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Mark R2 deletion as complete\n   */\n  async markR2Deleted() {\n    const content = await this.state.storage.get('content');\n\n    if (!content) {\n      return new Response(\n        JSON.stringify({ error: 'Content not found' }),\n        { status: 404, headers: { 'content-type': 'application/json' } }\n      );\n    }\n\n    content.pending_r2_deletion = false;\n\n    await this.state.storage.put('content', content);\n\n    return new Response(\n      JSON.stringify({ success: true }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n}\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/durable-objects/contest-record.js","messages":[{"ruleId":"no-unused-vars","severity":1,"message":"'request' is defined but never used. Allowed unused args must match /^_/u.","line":11,"column":15,"nodeType":"Identifier","messageId":"unusedVar","endLine":11,"endColumn":22}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * ContestRecord Durable Object\n * Stores content contestation records and evidence\n */\nexport class ContestRecord {\n  constructor(state, env) {\n    this.state = state;\n    this.env = env;\n  }\n\n  async fetch(request) {\n    // TODO: Implement contest record operations\n    // - Store contest submissions\n    // - Track evidence and status\n    // - Record moderator decisions\n    // - Maintain public contest history\n\n    return new Response('ContestRecord DO operational', { status: 200 });\n  }\n}\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/durable-objects/deletion-record.js","messages":[{"ruleId":"complexity","severity":1,"message":"Async method 'fetch' has a complexity of 12. Maximum allowed is 10.","line":16,"column":14,"nodeType":"FunctionExpression","messageId":"complex","endLine":59,"endColumn":4}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * DeletionRecord Durable Object\n * Stores public deletion records for transparency\n * \n * Single global instance stores all deletion records with pagination\n */\n\nimport { createHash } from 'node:crypto';\n\nexport class DeletionRecord {\n  constructor(state, env) {\n    this.state = state;\n    this.env = env;\n  }\n\n  async fetch(request) {\n    const url = new URL(request.url);\n    const method = request.method;\n\n    try {\n      // Create deletion record\n      if (url.pathname === '/record' && method === 'POST') {\n        const data = await request.json();\n        return await this.createDeletionRecord(data);\n      }\n\n      // Get all deletion records (paginated)\n      if (url.pathname === '/records' && method === 'GET') {\n        const limit = parseInt(url.searchParams.get('limit') || '50');\n        const offset = parseInt(url.searchParams.get('offset') || '0');\n        const reason = url.searchParams.get('reason');\n        return await this.getDeletionRecords(limit, offset, reason);\n      }\n\n      // Get specific deletion record by hash\n      if (url.pathname.startsWith('/record/') && method === 'GET') {\n        const hash = url.pathname.split('/')[2];\n        return await this.getDeletionRecord(hash);\n      }\n\n      // Get deletion statistics\n      if (url.pathname === '/stats' && method === 'GET') {\n        return await this.getDeletionStats();\n      }\n\n      return new Response('Not Found', { status: 404 });\n    } catch (error) {\n      return new Response(\n        JSON.stringify({\n          error: 'Internal error',\n          message: error.message\n        }),\n        {\n          status: 500,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n  }\n\n  /**\n   * Hash uploader ID for privacy\n   */\n  hashUploaderId(uploaderId) {\n    if (!uploaderId) return null;\n    return createHash('sha256').update(uploaderId).digest('hex').substring(0, 16);\n  }\n\n  /**\n   * Create a deletion record\n   */\n  async createDeletionRecord(data) {\n    const { hash_256t, reason, uploader_id, size_bytes, content_type } = data;\n\n    // Check if deletion record already exists (idempotency)\n    const existingRecord = await this.state.storage.get(`deletion:${hash_256t}`);\n    if (existingRecord) {\n      return new Response(JSON.stringify(existingRecord), {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      });\n    }\n\n    const deletionRecord = {\n      hash_256t,\n      deleted_at: new Date().toISOString(),\n      reason, // 'expired', 'contested', 'admin_action', etc.\n      uploader_id_hash: this.hashUploaderId(uploader_id),\n      size_bytes: size_bytes || null,\n      content_type: content_type || null\n    };\n\n    // Store deletion record\n    await this.state.storage.put(`deletion:${hash_256t}`, deletionRecord);\n\n    // Add to chronological list\n    const allDeletions = await this.state.storage.get('deletions') || [];\n    allDeletions.unshift(hash_256t); // newest first\n    await this.state.storage.put('deletions', allDeletions);\n\n    // Update statistics\n    await this.updateStats(reason);\n\n    return new Response(JSON.stringify(deletionRecord), {\n      status: 201,\n      headers: { 'content-type': 'application/json' }\n    });\n  }\n\n  /**\n   * Get deletion records with pagination and filtering\n   */\n  async getDeletionRecords(limit, offset, reasonFilter) {\n    const allDeletionHashes = await this.state.storage.get('deletions') || [];\n    \n    // Get deletions in the requested range\n    const requestedHashes = allDeletionHashes.slice(offset, offset + limit);\n    \n    // Fetch full deletion objects\n    const deletions = [];\n    for (const hash of requestedHashes) {\n      const deletion = await this.state.storage.get(`deletion:${hash}`);\n      if (deletion) {\n        // Apply reason filter if specified\n        if (!reasonFilter || deletion.reason === reasonFilter) {\n          deletions.push(deletion);\n        }\n      }\n    }\n\n    return new Response(\n      JSON.stringify({\n        deletions,\n        total: allDeletionHashes.length,\n        limit,\n        offset\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Get a specific deletion record by hash\n   */\n  async getDeletionRecord(hash) {\n    const deletion = await this.state.storage.get(`deletion:${hash}`);\n\n    if (!deletion) {\n      return new Response(\n        JSON.stringify({\n          error: 'Deletion record not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    return new Response(JSON.stringify(deletion), {\n      status: 200,\n      headers: { 'content-type': 'application/json' }\n    });\n  }\n\n  /**\n   * Update deletion statistics\n   */\n  async updateStats(reason) {\n    const stats = await this.state.storage.get('stats') || {\n      total_deletions: 0,\n      by_reason: {}\n    };\n\n    stats.total_deletions++;\n    stats.by_reason[reason] = (stats.by_reason[reason] || 0) + 1;\n    stats.last_updated = new Date().toISOString();\n\n    await this.state.storage.put('stats', stats);\n  }\n\n  /**\n   * Get deletion statistics\n   */\n  async getDeletionStats() {\n    const stats = await this.state.storage.get('stats') || {\n      total_deletions: 0,\n      by_reason: {},\n      last_updated: null\n    };\n\n    return new Response(JSON.stringify(stats), {\n      status: 200,\n      headers: { 'content-type': 'application/json' }\n    });\n  }\n}\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/durable-objects/deletion-record.test.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/durable-objects/dispute-index.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/durable-objects/dispute-record.js","messages":[{"ruleId":"complexity","severity":1,"message":"Async method 'fetch' has a complexity of 12. Maximum allowed is 10.","line":19,"column":14,"nodeType":"FunctionExpression","messageId":"complex","endLine":57,"endColumn":4},{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 32 to the 15 allowed.","line":62,"column":9,"nodeType":null,"messageId":"refactorFunction","endLine":62,"endColumn":22},{"ruleId":"complexity","severity":1,"message":"Async method 'createDispute' has a complexity of 31. Maximum allowed is 10.","line":62,"column":22,"nodeType":"FunctionExpression","messageId":"complex","endLine":233,"endColumn":4},{"ruleId":"complexity","severity":1,"message":"Async method 'updateDispute' has a complexity of 11. Maximum allowed is 10.","line":272,"column":22,"nodeType":"FunctionExpression","messageId":"complex","endLine":334,"endColumn":4}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":4,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * DisputeRecord Durable Object\n * Stores dispute records and history for a single CID\n */\n\n/**\n * DisputeRecord Durable Object\n * One instance per CID\n */\nexport class DisputeRecord {\n  constructor(state, env) {\n    this.state = state;\n    this.env = env;\n  }\n\n  /**\n   * Handle requests to this Durable Object\n   */\n  async fetch(request) {\n    const url = new URL(request.url);\n    const method = request.method;\n\n    try {\n      // POST /dispute - Create new dispute\n      if (method === 'POST' && url.pathname === '/dispute') {\n        return await this.createDispute(request);\n      }\n\n      // GET /dispute - Get current active dispute\n      if (method === 'GET' && url.pathname === '/dispute') {\n        return await this.getActiveDispute();\n      }\n\n      // GET /history - Get all disputes for this CID\n      if (method === 'GET' && url.pathname === '/history') {\n        return await this.getHistory();\n      }\n\n      // PATCH /dispute - Update dispute status\n      if (method === 'PATCH' && url.pathname === '/dispute') {\n        return await this.updateDispute(request);\n      }\n\n      // GET /can-dispute - Check if can create new dispute\n      if (method === 'GET' && url.pathname === '/can-dispute') {\n        return await this.canDispute();\n      }\n\n      return new Response('Not Found', { status: 404 });\n    } catch (error) {\n      console.error('DisputeRecord error:', error);\n      return new Response(JSON.stringify({ error: error.message }), {\n        status: 500,\n        headers: { 'Content-Type': 'application/json' }\n      });\n    }\n  }\n\n  /**\n   * Create a new dispute\n   */\n  async createDispute(request) {\n    const data = await request.json();\n    \n    // Validation\n    if (!data.cid) {\n      return new Response(JSON.stringify({ error: 'CID_REQUIRED' }), {\n        status: 400,\n        headers: { 'Content-Type': 'application/json' }\n      });\n    }\n\n    const validClaimTypes = ['copyright', 'illegal', 'privacy', 'harassment', 'malware', 'other'];\n    if (!data.claim_type || !validClaimTypes.includes(data.claim_type)) {\n      return new Response(JSON.stringify({ error: 'INVALID_CLAIM_TYPE' }), {\n        status: 400,\n        headers: { 'Content-Type': 'application/json' }\n      });\n    }\n\n    if (!data.evidence || data.evidence.length < 50) {\n      return new Response(JSON.stringify({ error: 'EVIDENCE_TOO_SHORT' }), {\n        status: 400,\n        headers: { 'Content-Type': 'application/json' }\n      });\n    }\n\n    if (data.evidence.length > 10000) {\n      return new Response(JSON.stringify({ error: 'EVIDENCE_TOO_LONG' }), {\n        status: 400,\n        headers: { 'Content-Type': 'application/json' }\n      });\n    }\n\n    // Validate evidence URLs\n    if (data.evidence_urls && data.evidence_urls.length > 0) {\n      if (data.evidence_urls.length > 10) {\n        return new Response(JSON.stringify({ error: 'TOO_MANY_EVIDENCE_URLS' }), {\n          status: 400,\n          headers: { 'Content-Type': 'application/json' }\n        });\n      }\n\n      for (const url of data.evidence_urls) {\n        try {\n          const parsed = new URL(url);\n          // Only allow HTTPS URLs\n          if (parsed.protocol !== 'https:') {\n            return new Response(JSON.stringify({ error: 'INVALID_EVIDENCE_URL', details: 'Only HTTPS URLs allowed' }), {\n              status: 400,\n              headers: { 'Content-Type': 'application/json' }\n            });\n          }\n          // Prevent internal network addresses and localhost\n          const hostname = parsed.hostname.toLowerCase();\n          if (hostname === 'localhost' || \n              hostname === '127.0.0.1' || \n              hostname === '0.0.0.0' ||\n              hostname.startsWith('192.168.') ||\n              hostname.startsWith('10.') ||\n              hostname.startsWith('172.16.') ||\n              hostname.startsWith('169.254.') ||\n              hostname === '::1') {\n            return new Response(JSON.stringify({ error: 'INVALID_EVIDENCE_URL', details: 'Internal URLs not allowed' }), {\n              status: 400,\n              headers: { 'Content-Type': 'application/json' }\n            });\n          }\n        } catch {\n          return new Response(JSON.stringify({ error: 'INVALID_EVIDENCE_URL' }), {\n            status: 400,\n            headers: { 'Content-Type': 'application/json' }\n          });\n        }\n      }\n    }\n\n    // Validate contact info\n    if (!data.contact || !data.contact.type || !data.contact.value) {\n      return new Response(JSON.stringify({ error: 'CONTACT_REQUIRED' }), {\n        status: 400,\n        headers: { 'Content-Type': 'application/json' }\n      });\n    }\n\n    if (!['email', 'url'].includes(data.contact.type)) {\n      return new Response(JSON.stringify({ error: 'INVALID_CONTACT_TYPE' }), {\n        status: 400,\n        headers: { 'Content-Type': 'application/json' }\n      });\n    }\n\n    // Check for existing open dispute\n    const activeDispute = await this.state.storage.get('active_dispute');\n    if (activeDispute) {\n      return new Response(JSON.stringify({ error: 'DISPUTE_EXISTS' }), {\n        status: 409,\n        headers: { 'Content-Type': 'application/json' }\n      });\n    }\n\n    // Check re-dispute rate limit (30 days)\n    const history = await this.state.storage.get('dispute_history') || [];\n    const lastClosedDispute = history\n      .filter(d => d.closed_at)\n      .sort((a, b) => new Date(b.closed_at) - new Date(a.closed_at))[0];\n\n    if (lastClosedDispute) {\n      const daysSinceClosed = (Date.now() - new Date(lastClosedDispute.closed_at).getTime()) / (1000 * 60 * 60 * 24);\n      if (daysSinceClosed < 30) {\n        return new Response(JSON.stringify({ \n          error: 'REDISPUTE_TOO_SOON',\n          days_remaining: Math.ceil(30 - daysSinceClosed)\n        }), {\n          status: 429,\n          headers: { 'Content-Type': 'application/json' }\n        });\n      }\n    }\n\n    // Create dispute\n    const now = new Date().toISOString();\n    const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); // 30 days\n    \n    const dispute = {\n      dispute_id: `disp_${crypto.randomUUID()}`,\n      cid: data.cid,\n      status: 'open',\n      created_at: now,\n      updated_at: now,\n      closed_at: null,\n      expires_at: expiresAt,\n      \n      // Submitter info\n      submitter_contact: {\n        type: data.contact.type,\n        value: data.contact.value,\n        verified: false\n      },\n      submitter_ip_hash: data.submitter_ip_hash || null,\n      \n      // Claim details\n      claim_type: data.claim_type,\n      evidence: data.evidence,\n      evidence_urls: data.evidence_urls || [],\n      \n      // Resolution\n      resolution: null,\n      resolution_reason: null,\n      resolved_by: null\n    };\n\n    // Store as active dispute\n    await this.state.storage.put('active_dispute', dispute);\n    \n    // Add to history\n    history.push(dispute);\n    await this.state.storage.put('dispute_history', history);\n\n    return new Response(JSON.stringify({ \n      success: true,\n      dispute: {\n        dispute_id: dispute.dispute_id,\n        cid: dispute.cid,\n        status: dispute.status,\n        created_at: dispute.created_at,\n        expires_at: dispute.expires_at\n      }\n    }), {\n      status: 201,\n      headers: { 'Content-Type': 'application/json' }\n    });\n  }\n\n  /**\n   * Get active dispute\n   */\n  async getActiveDispute() {\n    const activeDispute = await this.state.storage.get('active_dispute');\n    \n    if (!activeDispute) {\n      return new Response(JSON.stringify({ error: 'NO_ACTIVE_DISPUTE' }), {\n        status: 404,\n        headers: { 'Content-Type': 'application/json' }\n      });\n    }\n\n    return new Response(JSON.stringify(activeDispute), {\n      status: 200,\n      headers: { 'Content-Type': 'application/json' }\n    });\n  }\n\n  /**\n   * Get dispute history\n   */\n  async getHistory() {\n    const history = await this.state.storage.get('dispute_history') || [];\n    \n    return new Response(JSON.stringify({ \n      disputes: history,\n      total: history.length\n    }), {\n      status: 200,\n      headers: { 'Content-Type': 'application/json' }\n    });\n  }\n\n  /**\n   * Update dispute status\n   */\n  async updateDispute(request) {\n    const data = await request.json();\n    const activeDispute = await this.state.storage.get('active_dispute');\n    \n    if (!activeDispute) {\n      return new Response(JSON.stringify({ error: 'NO_ACTIVE_DISPUTE' }), {\n        status: 404,\n        headers: { 'Content-Type': 'application/json' }\n      });\n    }\n\n    // Update fields\n    if (data.status) {\n      const validStatuses = ['open', 'under_review', 'closed_deleted', 'closed_denied', 'closed_expired'];\n      if (!validStatuses.includes(data.status)) {\n        return new Response(JSON.stringify({ error: 'INVALID_STATUS' }), {\n          status: 400,\n          headers: { 'Content-Type': 'application/json' }\n        });\n      }\n      activeDispute.status = data.status;\n    }\n\n    if (data.resolution) {\n      activeDispute.resolution = data.resolution;\n    }\n\n    if (data.resolution_reason) {\n      activeDispute.resolution_reason = data.resolution_reason;\n    }\n\n    if (data.resolved_by) {\n      activeDispute.resolved_by = data.resolved_by;\n    }\n\n    // If closing, set closed_at\n    if (data.status && data.status.startsWith('closed_')) {\n      activeDispute.closed_at = new Date().toISOString();\n      // Remove from active disputes\n      await this.state.storage.delete('active_dispute');\n    } else {\n      // Update active dispute\n      await this.state.storage.put('active_dispute', activeDispute);\n    }\n\n    activeDispute.updated_at = new Date().toISOString();\n\n    // Update history\n    const history = await this.state.storage.get('dispute_history') || [];\n    const index = history.findIndex(d => d.dispute_id === activeDispute.dispute_id);\n    if (index >= 0) {\n      history[index] = activeDispute;\n      await this.state.storage.put('dispute_history', history);\n    }\n\n    return new Response(JSON.stringify({ \n      success: true,\n      dispute: activeDispute\n    }), {\n      status: 200,\n      headers: { 'Content-Type': 'application/json' }\n    });\n  }\n\n  /**\n   * Check if can create new dispute (for rate limiting)\n   */\n  async canDispute() {\n    // Check for active dispute\n    const activeDispute = await this.state.storage.get('active_dispute');\n    if (activeDispute) {\n      return new Response(JSON.stringify({ \n        can_dispute: false,\n        reason: 'DISPUTE_EXISTS'\n      }), {\n        status: 200,\n        headers: { 'Content-Type': 'application/json' }\n      });\n    }\n\n    // Check re-dispute rate limit\n    const history = await this.state.storage.get('dispute_history') || [];\n    const lastClosedDispute = history\n      .filter(d => d.closed_at)\n      .sort((a, b) => new Date(b.closed_at) - new Date(a.closed_at))[0];\n\n    if (lastClosedDispute) {\n      const daysSinceClosed = (Date.now() - new Date(lastClosedDispute.closed_at).getTime()) / (1000 * 60 * 60 * 24);\n      if (daysSinceClosed < 30) {\n        return new Response(JSON.stringify({ \n          can_dispute: false,\n          reason: 'REDISPUTE_TOO_SOON',\n          days_remaining: Math.ceil(30 - daysSinceClosed)\n        }), {\n          status: 200,\n          headers: { 'Content-Type': 'application/json' }\n        });\n      }\n    }\n\n    return new Response(JSON.stringify({ \n      can_dispute: true\n    }), {\n      status: 200,\n      headers: { 'Content-Type': 'application/json' }\n    });\n  }\n}\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/durable-objects/dispute-record.test.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/durable-objects/expiration-index.js","messages":[{"ruleId":"complexity","severity":1,"message":"Async method 'fetch' has a complexity of 12. Maximum allowed is 10.","line":20,"column":14,"nodeType":"FunctionExpression","messageId":"complex","endLine":67,"endColumn":4}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * ExpirationIndex Durable Object\n * Global index that maps expiration dates to content hashes\n * Enables efficient batch processing of expired content\n * \n * Single global instance with structure:\n * {\n *   \"2026-01-23\": [\"hash1\", \"hash2\", ...],\n *   \"2026-01-24\": [\"hash3\", \"hash4\", ...],\n *   ...\n * }\n */\n\nexport class ExpirationIndex {\n  constructor(state, env) {\n    this.state = state;\n    this.env = env;\n  }\n\n  async fetch(request) {\n    const url = new URL(request.url);\n    const method = request.method;\n\n    try {\n      // Register content with expiration date\n      if (url.pathname === '/register' && method === 'POST') {\n        const data = await request.json();\n        return await this.register(data);\n      }\n\n      // Unregister content (when deleted or extended)\n      if (url.pathname === '/unregister' && method === 'POST') {\n        const data = await request.json();\n        return await this.unregister(data);\n      }\n\n      // Update expiration date (when retention is extended)\n      if (url.pathname === '/update' && method === 'POST') {\n        const data = await request.json();\n        return await this.updateExpiration(data);\n      }\n\n      // Get expired content for a specific date\n      if (url.pathname === '/expired' && method === 'GET') {\n        const date = url.searchParams.get('date');\n        return await this.getExpired(date);\n      }\n\n      // Get statistics\n      if (url.pathname === '/stats' && method === 'GET') {\n        return await this.getStats();\n      }\n\n      return new Response('Not Found', { status: 404 });\n    } catch (error) {\n      return new Response(\n        JSON.stringify({\n          error: 'Internal error',\n          message: error.message\n        }),\n        {\n          status: 500,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n  }\n\n  /**\n   * Extract date in YYYY-MM-DD format from ISO timestamp\n   */\n  extractDate(isoTimestamp) {\n    return isoTimestamp.split('T')[0];\n  }\n\n  /**\n   * Register content with expiration date\n   */\n  async register(data) {\n    const { hash_256t, expires_at } = data;\n    const expirationDate = this.extractDate(expires_at);\n\n    // Get existing hashes for this date\n    const hashes = await this.state.storage.get(expirationDate) || [];\n\n    // Add hash if not already present (idempotency)\n    if (!hashes.includes(hash_256t)) {\n      hashes.push(hash_256t);\n      await this.state.storage.put(expirationDate, hashes);\n    }\n\n    return new Response(\n      JSON.stringify({ success: true, date: expirationDate }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Unregister content (when deleted or before extension)\n   */\n  async unregister(data) {\n    const { hash_256t, expires_at } = data;\n    const expirationDate = this.extractDate(expires_at);\n\n    // Get existing hashes for this date\n    const hashes = await this.state.storage.get(expirationDate) || [];\n\n    // Remove hash\n    const updatedHashes = hashes.filter(h => h !== hash_256t);\n\n    if (updatedHashes.length === 0) {\n      // Clean up empty date entries\n      await this.state.storage.delete(expirationDate);\n    } else {\n      await this.state.storage.put(expirationDate, updatedHashes);\n    }\n\n    return new Response(\n      JSON.stringify({ success: true }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Update expiration date (when retention is extended)\n   */\n  async updateExpiration(data) {\n    const { hash_256t, old_expires_at, new_expires_at } = data;\n    const oldDate = this.extractDate(old_expires_at);\n    const newDate = this.extractDate(new_expires_at);\n\n    // If dates are the same, nothing to do\n    if (oldDate === newDate) {\n      return new Response(\n        JSON.stringify({ success: true, unchanged: true }),\n        {\n          status: 200,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Remove from old date\n    await this.unregister({ hash_256t, expires_at: old_expires_at });\n\n    // Add to new date\n    await this.register({ hash_256t, expires_at: new_expires_at });\n\n    return new Response(\n      JSON.stringify({ success: true, old_date: oldDate, new_date: newDate }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Get expired content for a specific date\n   */\n  async getExpired(date) {\n    if (!date) {\n      return new Response(\n        JSON.stringify({ error: 'Date parameter required' }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    const hashes = await this.state.storage.get(date) || [];\n\n    return new Response(\n      JSON.stringify({\n        date,\n        hashes,\n        count: hashes.length\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Get statistics about the expiration index\n   */\n  async getStats() {\n    const allKeys = await this.state.storage.list();\n    const stats = {\n      total_dates: allKeys.size,\n      dates: []\n    };\n\n    for (const [date, hashes] of allKeys) {\n      stats.dates.push({\n        date,\n        count: hashes.length\n      });\n    }\n\n    // Sort by date\n    stats.dates.sort((a, b) => a.date.localeCompare(b.date));\n\n    return new Response(JSON.stringify(stats), {\n      status: 200,\n      headers: { 'content-type': 'application/json' }\n    });\n  }\n}\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/durable-objects/expiration-index.test.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/durable-objects/infrastructure-cost.js","messages":[{"ruleId":"complexity","severity":1,"message":"Async method 'fetch' has a complexity of 13. Maximum allowed is 10.","line":24,"column":14,"nodeType":"FunctionExpression","messageId":"complex","endLine":67,"endColumn":4},{"ruleId":"complexity","severity":1,"message":"Async method 'getCostSummary' has a complexity of 14. Maximum allowed is 10.","line":170,"column":23,"nodeType":"FunctionExpression","messageId":"complex","endLine":206,"endColumn":4}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * InfrastructureCost Durable Object\n * Tracks platform-wide infrastructure costs\n * Single instance stores aggregate costs for all services\n */\n\n/**\n * Get current month period string in YYYY-MM format\n * @returns {string} Current month period (e.g., \"2026-01\")\n */\nfunction getCurrentMonthPeriod() {\n  const now = new Date();\n  const year = now.getUTCFullYear();\n  const month = String(now.getUTCMonth() + 1).padStart(2, '0');\n  return `${year}-${month}`;\n}\n\nexport class InfrastructureCost {\n  constructor(state, env) {\n    this.state = state;\n    this.env = env;\n  }\n\n  async fetch(request) {\n    const url = new URL(request.url);\n    const method = request.method;\n\n    try {\n      // Record costs\n      if (url.pathname === '/record' && method === 'POST') {\n        const data = await request.json();\n        return await this.recordCost(data);\n      }\n\n      // Get cost summary\n      if (url.pathname === '/summary' && method === 'GET') {\n        const period = url.searchParams.get('period') || 'all_time';\n        return await this.getCostSummary(period);\n      }\n\n      // Get detailed costs by service\n      if (url.pathname === '/by-service' && method === 'GET') {\n        const period = url.searchParams.get('period') || 'all_time';\n        return await this.getCostsByService(period);\n      }\n\n      // Get monthly breakdown\n      if (url.pathname === '/monthly' && method === 'GET') {\n        const months = parseInt(url.searchParams.get('months') || '12');\n        return await this.getMonthlyBreakdown(months);\n      }\n\n      return new Response('Not Found', { status: 404 });\n    } catch (error) {\n      console.error('InfrastructureCost error:', error);\n      return new Response(\n        JSON.stringify({\n          error: 'Internal error',\n          message: error.message\n        }),\n        {\n          status: 500,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n  }\n\n  /**\n   * Record infrastructure cost\n   * @param {object} data - Cost data\n   * @param {string} data.service - Service name (workers|r2_storage|r2_bandwidth|durable_objects|stripe)\n   * @param {number} data.cost_cents - Cost in cents\n   * @param {string} data.period - Time period (ISO date string for month, e.g., \"2024-01\")\n   * @param {object} data.metadata - Optional metadata (e.g., transaction_id, bytes, requests)\n   */\n  async recordCost(data) {\n    const { service, cost_cents, period, metadata = {} } = data;\n\n    // Validate inputs\n    if (!service) {\n      return new Response(\n        JSON.stringify({ error: 'service is required' }),\n        { status: 400, headers: { 'content-type': 'application/json' } }\n      );\n    }\n\n    if (cost_cents === undefined || cost_cents < 0) {\n      return new Response(\n        JSON.stringify({ error: 'cost_cents must be non-negative' }),\n        { status: 400, headers: { 'content-type': 'application/json' } }\n      );\n    }\n\n    const validServices = ['workers', 'r2_storage', 'r2_bandwidth', 'durable_objects', 'stripe'];\n    if (!validServices.includes(service)) {\n      return new Response(\n        JSON.stringify({ \n          error: 'invalid service',\n          valid_services: validServices\n        }),\n        { status: 400, headers: { 'content-type': 'application/json' } }\n      );\n    }\n\n    // Use current month if period not specified\n    // Format: YYYY-MM (e.g., \"2026-01\")\n    const costPeriod = period || getCurrentMonthPeriod();\n\n    // Create cost record\n    const costRecord = {\n      id: crypto.randomUUID(),\n      service,\n      cost_cents,\n      period: costPeriod,\n      metadata,\n      recorded_at: new Date().toISOString()\n    };\n\n    // Store individual cost record\n    await this.state.storage.put(`cost:${costRecord.id}`, costRecord);\n\n    // Update aggregates\n    await this.updateAggregates(service, cost_cents, costPeriod);\n\n    return new Response(\n      JSON.stringify(costRecord),\n      {\n        status: 201,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Update aggregate cost counters\n   */\n  async updateAggregates(service, cost_cents, period) {\n    // Update all-time totals\n    const totalKey = `total:${service}`;\n    const currentTotal = (await this.state.storage.get(totalKey)) || 0;\n    await this.state.storage.put(totalKey, currentTotal + cost_cents);\n\n    // Update all services total\n    const allServicesTotal = (await this.state.storage.get('total:all_services')) || 0;\n    await this.state.storage.put('total:all_services', allServicesTotal + cost_cents);\n\n    // Update period-specific totals\n    const periodKey = `period:${period}:${service}`;\n    const currentPeriodTotal = (await this.state.storage.get(periodKey)) || 0;\n    await this.state.storage.put(periodKey, currentPeriodTotal + cost_cents);\n\n    // Update period all services total\n    const periodAllKey = `period:${period}:all_services`;\n    const currentPeriodAll = (await this.state.storage.get(periodAllKey)) || 0;\n    await this.state.storage.put(periodAllKey, currentPeriodAll + cost_cents);\n\n    // Track periods we have data for\n    const periods = (await this.state.storage.get('periods')) || [];\n    if (!periods.includes(period)) {\n      periods.push(period);\n      periods.sort();\n      await this.state.storage.put('periods', periods);\n    }\n  }\n\n  /**\n   * Get cost summary\n   */\n  async getCostSummary(period) {\n    let costs = {};\n\n    if (period === 'all_time') {\n      // Get all-time totals\n      costs = {\n        workers: (await this.state.storage.get('total:workers')) || 0,\n        r2_storage: (await this.state.storage.get('total:r2_storage')) || 0,\n        r2_bandwidth: (await this.state.storage.get('total:r2_bandwidth')) || 0,\n        durable_objects: (await this.state.storage.get('total:durable_objects')) || 0,\n        stripe: (await this.state.storage.get('total:stripe')) || 0,\n        total: (await this.state.storage.get('total:all_services')) || 0\n      };\n    } else {\n      // Get period-specific totals\n      costs = {\n        workers: (await this.state.storage.get(`period:${period}:workers`)) || 0,\n        r2_storage: (await this.state.storage.get(`period:${period}:r2_storage`)) || 0,\n        r2_bandwidth: (await this.state.storage.get(`period:${period}:r2_bandwidth`)) || 0,\n        durable_objects: (await this.state.storage.get(`period:${period}:durable_objects`)) || 0,\n        stripe: (await this.state.storage.get(`period:${period}:stripe`)) || 0,\n        total: (await this.state.storage.get(`period:${period}:all_services`)) || 0\n      };\n    }\n\n    return new Response(\n      JSON.stringify({\n        period,\n        costs,\n        timestamp: new Date().toISOString()\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Get costs by service with breakdown\n   */\n  async getCostsByService(period) {\n    const summary = await this.getCostSummary(period);\n    const summaryData = await summary.json();\n\n    // Calculate percentages\n    const total = summaryData.costs.total;\n    const breakdown = Object.entries(summaryData.costs)\n      .filter(([key]) => key !== 'total')\n      .map(([service, cost]) => ({\n        service,\n        cost_cents: cost,\n        cost_dollars: cost / 100,\n        percentage: total > 0 ? (cost / total) * 100 : 0\n      }))\n      .sort((a, b) => b.cost_cents - a.cost_cents);\n\n    return new Response(\n      JSON.stringify({\n        period,\n        total_cents: total,\n        total_dollars: total / 100,\n        breakdown,\n        timestamp: new Date().toISOString()\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Get monthly breakdown of costs\n   */\n  async getMonthlyBreakdown(months) {\n    const periods = (await this.state.storage.get('periods')) || [];\n    const recentPeriods = periods.slice(-months);\n\n    const breakdown = await Promise.all(\n      recentPeriods.map(async (period) => {\n        const summary = await this.getCostSummary(period);\n        const data = await summary.json();\n        return {\n          period,\n          costs: data.costs\n        };\n      })\n    );\n\n    return new Response(\n      JSON.stringify({\n        months: breakdown.length,\n        breakdown,\n        timestamp: new Date().toISOString()\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n}\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/durable-objects/key-registry.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/durable-objects/message-thread.js","messages":[{"ruleId":"no-unused-vars","severity":1,"message":"'request' is defined but never used. Allowed unused args must match /^_/u.","line":11,"column":15,"nodeType":"Identifier","messageId":"unusedVar","endLine":11,"endColumn":22}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * MessageThread Durable Object\n * Stores messages between payers and contesters\n */\nexport class MessageThread {\n  constructor(state, env) {\n    this.state = state;\n    this.env = env;\n  }\n\n  async fetch(request) {\n    // TODO: Implement messaging operations\n    // - Store messages in threads\n    // - Enforce message limits\n    // - Track admin moderation requests\n    // - Send email notifications\n\n    return new Response('MessageThread DO operational', { status: 200 });\n  }\n}\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/durable-objects/payment-record.js","messages":[{"ruleId":"complexity","severity":1,"message":"Async method 'fetch' has a complexity of 12. Maximum allowed is 10.","line":13,"column":14,"nodeType":"FunctionExpression","messageId":"complex","endLine":53,"endColumn":4},{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.","line":58,"column":9,"nodeType":null,"messageId":"refactorFunction","endLine":58,"endColumn":26},{"ruleId":"complexity","severity":1,"message":"Async method 'createTransaction' has a complexity of 17. Maximum allowed is 10.","line":58,"column":26,"nodeType":"FunctionExpression","messageId":"complex","endLine":104,"endColumn":4}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":3,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * PaymentRecord Durable Object\n * Stores payment transactions and financial records\n * \n * One instance per user_id to store all their transactions\n */\nexport class PaymentRecord {\n  constructor(state, env) {\n    this.state = state;\n    this.env = env;\n  }\n\n  async fetch(request) {\n    const url = new URL(request.url);\n    const method = request.method;\n\n    try {\n      if (url.pathname === '/transaction' && method === 'POST') {\n        const data = await request.json();\n        return await this.createTransaction(data);\n      }\n\n      if (url.pathname === '/transactions' && method === 'GET') {\n        const limit = parseInt(url.searchParams.get('limit') || '20');\n        const offset = parseInt(url.searchParams.get('offset') || '0');\n        const type = url.searchParams.get('type');\n        return await this.getTransactions(limit, offset, type);\n      }\n\n      if (url.pathname.startsWith('/transaction/') && method === 'GET') {\n        const transactionId = url.pathname.split('/')[2];\n        return await this.getTransaction(transactionId);\n      }\n\n      if (url.pathname === '/session/exists' && method === 'GET') {\n        const sessionId = url.searchParams.get('session_id');\n        return await this.checkSessionExists(sessionId);\n      }\n\n      return new Response('Not Found', { status: 404 });\n    } catch (error) {\n      return new Response(\n        JSON.stringify({\n          error: 'Internal error',\n          message: error.message\n        }),\n        {\n          status: 500,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n  }\n\n  /**\n   * Create a new transaction record\n   */\n  async createTransaction(data) {\n    const transaction = {\n      id: data.transaction_id,\n      transaction_id: data.transaction_id,\n      type: data.type, // deposit | upload_payment | cid_extension | donation_received | rate_limit_purchase | content_deletion\n      user_id: data.user_id,\n      amount_cents: data.amount_cents,\n      balance_before_cents: data.balance_before_cents,\n      balance_after_cents: data.balance_after_cents,\n      stripe_session_id: data.stripe_session_id || null,\n      stripe_payment_intent: data.stripe_payment_intent || null,\n      stripe_fee_cents: data.stripe_fee_cents || null, // Stripe processing fee\n      cid: data.cid || null,\n      retention_months: data.retention_months || null,\n      // Rate limit purchase fields\n      min_time_between_requests_ms: data.min_time_between_requests_ms || null,\n      duration_seconds: data.duration_seconds || null,\n      max_requests: data.max_requests || null,\n      max_bytes: data.max_bytes || null,\n      // Content deletion fields\n      content_size: data.content_size || null,\n      deletion_reason: data.deletion_reason || null,\n      dispute_id: data.dispute_id || null,\n      // Failed transaction tracking\n      status: data.status || 'success',  // \"success\" | \"failed\"\n      failure_reason: data.failure_reason || null,\n      created_at: new Date().toISOString()\n    };\n\n    // Store transaction with transaction_id as key\n    await this.state.storage.put(`tx:${transaction.transaction_id}`, transaction);\n\n    // If this transaction has a Stripe session ID, index it for idempotency checking\n    if (transaction.stripe_session_id) {\n      await this.state.storage.put(`session:${transaction.stripe_session_id}`, transaction.transaction_id);\n    }\n\n    // Also add to chronological list for easy retrieval\n    const allTransactions = await this.state.storage.get('transactions') || [];\n    allTransactions.unshift(transaction.transaction_id); // newest first\n    await this.state.storage.put('transactions', allTransactions);\n\n    return new Response(JSON.stringify(transaction), {\n      status: 201,\n      headers: { 'content-type': 'application/json' }\n    });\n  }\n\n  /**\n   * Get transaction history with pagination and filtering\n   */\n  async getTransactions(limit, offset, typeFilter) {\n    const allTransactionIds = await this.state.storage.get('transactions') || [];\n    \n    // Get transactions in the requested range\n    const requestedIds = allTransactionIds.slice(offset, offset + limit);\n    \n    // Fetch full transaction objects\n    const transactions = [];\n    for (const txId of requestedIds) {\n      const tx = await this.state.storage.get(`tx:${txId}`);\n      if (tx) {\n        if (!tx.id) {\n          tx.id = tx.transaction_id;\n        }\n        // Apply type filter if specified\n        if (!typeFilter || tx.type === typeFilter) {\n          transactions.push(tx);\n        }\n      }\n    }\n\n    return new Response(\n      JSON.stringify({\n        transactions: transactions,\n        total: allTransactionIds.length,\n        limit: limit,\n        offset: offset\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Get a specific transaction by ID\n   */\n  async getTransaction(transactionId) {\n    const transaction = await this.state.storage.get(`tx:${transactionId}`);\n\n    if (!transaction) {\n      return new Response(\n        JSON.stringify({\n          error: 'Transaction not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    if (!transaction.id) {\n      transaction.id = transaction.transaction_id;\n    }\n\n    return new Response(JSON.stringify(transaction), {\n      status: 200,\n      headers: { 'content-type': 'application/json' }\n    });\n  }\n\n  /**\n   * Check if a Stripe session has already been processed\n   */\n  async checkSessionExists(sessionId) {\n    if (!sessionId) {\n      return new Response(\n        JSON.stringify({\n          error: 'Missing session_id parameter'\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    const transactionId = await this.state.storage.get(`session:${sessionId}`);\n    \n    return new Response(\n      JSON.stringify({\n        exists: !!transactionId,\n        transaction_id: transactionId || null\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n}\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/durable-objects/platform-stats.js","messages":[{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 28 to the 15 allowed.","line":96,"column":9,"nodeType":null,"messageId":"refactorFunction","endLine":96,"endColumn":17},{"ruleId":"complexity","severity":1,"message":"Async method 'getStats' has a complexity of 28. Maximum allowed is 10.","line":96,"column":17,"nodeType":"FunctionExpression","messageId":"complex","endLine":165,"endColumn":4},{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 21 to the 15 allowed.","line":170,"column":9,"nodeType":null,"messageId":"refactorFunction","endLine":170,"endColumn":26},{"ruleId":"complexity","severity":1,"message":"Async method 'getFinancialStats' has a complexity of 22. Maximum allowed is 10.","line":170,"column":26,"nodeType":"FunctionExpression","messageId":"complex","endLine":210,"endColumn":4}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":4,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * PlatformStats Durable Object\n * Aggregates and caches platform-wide statistics\n * Uses hybrid strategy: real-time counters + periodic snapshot computation\n */\n\nexport class PlatformStats {\n  constructor(state, env) {\n    this.state = state;\n    this.env = env;\n  }\n\n  async fetch(request) {\n    const url = new URL(request.url);\n    const path = url.pathname;\n\n    try {\n      // Increment counter\n      if (path === '/increment' && request.method === 'POST') {\n        const body = await request.json();\n        return this.incrementCounter(body);\n      }\n\n      // Set value (for non-numeric values like timestamps)\n      if (path === '/set' && request.method === 'POST') {\n        const body = await request.json();\n        return this.setValue(body);\n      }\n\n      // Get statistics\n      if (path === '/stats' && request.method === 'GET') {\n        return this.getStats(url.searchParams);\n      }\n\n      // Compute snapshot (called by scheduled job)\n      if (path === '/snapshot' && request.method === 'POST') {\n        return this.computeSnapshot();\n      }\n\n      return new Response('Not Found', { status: 404 });\n    } catch (error) {\n      console.error('PlatformStats error:', error);\n      return new Response(\n        JSON.stringify({ error: error.message }),\n        { status: 500, headers: { 'Content-Type': 'application/json' } }\n      );\n    }\n  }\n\n  /**\n   * Increment a counter in real-time\n   */\n  async incrementCounter(body) {\n    const { counter, value = 1 } = body;\n\n    if (!counter) {\n      return new Response(\n        JSON.stringify({ error: 'Counter name required' }),\n        { status: 400, headers: { 'Content-Type': 'application/json' } }\n      );\n    }\n\n    const current = (await this.state.storage.get(counter)) || 0;\n    await this.state.storage.put(counter, current + value);\n\n    return new Response(\n      JSON.stringify({ counter, value: current + value }),\n      { status: 200, headers: { 'Content-Type': 'application/json' } }\n    );\n  }\n\n  /**\n   * Set a value directly (for non-numeric values like timestamps)\n   */\n  async setValue(body) {\n    const { key, value } = body;\n\n    if (!key) {\n      return new Response(\n        JSON.stringify({ error: 'Key name required' }),\n        { status: 400, headers: { 'Content-Type': 'application/json' } }\n      );\n    }\n\n    await this.state.storage.put(key, value);\n\n    return new Response(\n      JSON.stringify({ key, value }),\n      { status: 200, headers: { 'Content-Type': 'application/json' } }\n    );\n  }\n\n  /**\n   * Get current statistics\n   */\n  async getStats(searchParams) {\n    const statsType = searchParams.get('type') || 'all';\n\n    // Get all counters\n    const counters = await this.state.storage.list();\n    const stats = {};\n    counters.forEach((value, key) => {\n      stats[key] = value;\n    });\n\n    // Get last snapshot timestamp\n    const lastSnapshotAt = stats.last_snapshot_at || null;\n\n    // Build response based on requested type\n    let response;\n    if (statsType === 'all') {\n      response = {\n        timestamp: new Date().toISOString(),\n        period: 'all_time',\n        content: {\n          total_files: stats.total_uploads || 0,\n          total_size_bytes: stats.total_uploads_bytes || 0,\n          inline_content_count: stats.inline_content_count || 0,\n          active_files: stats.active_content_count || 0,\n          expired_files: stats.expired_files || 0,\n          expiring_soon_count: stats.expiring_soon_count || 0,\n          total_downloads: stats.total_downloads || 0\n        },\n        users: {\n          total_accounts: stats.total_users || 0,\n          active_accounts: stats.active_users || 0,\n          deleted_accounts: stats.deleted_users || 0,\n          accounts_with_balance: stats.users_with_balance || 0\n        },\n        financial: {\n          total_revenue_cents: stats.total_revenue_cents || 0,\n          total_deposits_cents: stats.total_deposits_cents || 0,\n          total_spent_cents: stats.total_spent_cents || 0,\n          platform_balance_cents: stats.platform_balance_cents || 0,\n          average_deposit_cents: stats.average_deposit_cents || 0\n        },\n        api_keys: {\n          total_created: stats.total_api_keys || 0,\n          active_keys: stats.active_api_keys || 0,\n          revoked_keys: stats.revoked_api_keys || 0\n        },\n        rate_limits: {\n          total_purchases: stats.rate_limit_purchases || 0,\n          total_revenue_cents: stats.rate_limit_revenue_cents || 0\n        },\n        last_snapshot_at: lastSnapshotAt\n      };\n    } else if (statsType === 'financial') {\n      response = await this.getFinancialStats(stats);\n    } else if (statsType === 'content') {\n      response = await this.getContentStats(stats);\n    } else if (statsType === 'users') {\n      response = await this.getUserStats(stats);\n    } else {\n      return new Response(\n        JSON.stringify({ error: 'Invalid stats type' }),\n        { status: 400, headers: { 'Content-Type': 'application/json' } }\n      );\n    }\n\n    return new Response(\n      JSON.stringify(response),\n      { status: 200, headers: { 'Content-Type': 'application/json' } }\n    );\n  }\n\n  /**\n   * Get detailed financial statistics\n   */\n  async getFinancialStats(stats) {\n    return {\n      timestamp: new Date().toISOString(),\n      summary: {\n        total_revenue_cents: stats.total_revenue_cents || 0,\n        total_deposits_cents: stats.total_deposits_cents || 0,\n        total_refunds_cents: stats.total_refunds_cents || 0,\n        net_revenue_cents: (stats.total_revenue_cents || 0) - (stats.total_refunds_cents || 0)\n      },\n      breakdown_by_type: {\n        upload_payment: {\n          count: stats.upload_payment_count || 0,\n          total_cents: stats.upload_payment_total || 0\n        },\n        cid_extension: {\n          count: stats.extension_count || 0,\n          total_cents: stats.extension_total || 0\n        },\n        donation_received: {\n          count: stats.donation_count || 0,\n          total_cents: stats.donation_total || 0\n        },\n        rate_limit_purchase: {\n          count: stats.rate_limit_purchases || 0,\n          total_cents: stats.rate_limit_revenue_cents || 0\n        }\n      },\n      deposits: {\n        count: stats.total_deposits || 0,\n        total_cents: stats.total_deposits_cents || 0,\n        average_cents: stats.average_deposit_cents || 0,\n        min_cents: stats.min_deposit_cents || 0,\n        max_cents: stats.max_deposit_cents || 0\n      },\n      disputes: {\n        total_count: stats.total_disputes || 0,\n        pending_count: stats.pending_disputes || 0,\n        resolved_count: stats.resolved_disputes || 0\n      }\n    };\n  }\n\n  /**\n   * Get content statistics\n   */\n  async getContentStats(stats) {\n    return {\n      timestamp: new Date().toISOString(),\n      total_files: stats.total_uploads || 0,\n      total_size_bytes: stats.total_uploads_bytes || 0,\n      inline_content_count: stats.inline_content_count || 0,\n      active_files: stats.active_content_count || 0,\n      expired_files: stats.expired_files || 0,\n      expiring_soon_count: stats.expiring_soon_count || 0,\n      total_downloads: stats.total_downloads || 0\n    };\n  }\n\n  /**\n   * Get user statistics\n   */\n  async getUserStats(stats) {\n    return {\n      timestamp: new Date().toISOString(),\n      total_accounts: stats.total_users || 0,\n      active_accounts: stats.active_users || 0,\n      deleted_accounts: stats.deleted_users || 0,\n      accounts_with_balance: stats.users_with_balance || 0\n    };\n  }\n\n  /**\n   * Compute snapshot of complex aggregates (called by scheduled job)\n   * This queries all Durable Objects to compute accurate counts\n   */\n  async computeSnapshot() {\n    // Note: In a full implementation, this would query all user profiles,\n    // content metadata, and payment records to compute accurate aggregates.\n    // For now, we'll just update the snapshot timestamp.\n    \n    await this.state.storage.put('last_snapshot_at', new Date().toISOString());\n\n    return new Response(\n      JSON.stringify({ success: true, timestamp: new Date().toISOString() }),\n      { status: 200, headers: { 'Content-Type': 'application/json' } }\n    );\n  }\n}\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/durable-objects/supplier-registry.js","messages":[{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 19 to the 15 allowed.","line":16,"column":9,"nodeType":null,"messageId":"refactorFunction","endLine":16,"endColumn":14},{"ruleId":"complexity","severity":1,"message":"Async method 'fetch' has a complexity of 20. Maximum allowed is 10.","line":16,"column":14,"nodeType":"FunctionExpression","messageId":"complex","endLine":79,"endColumn":4},{"ruleId":"complexity","severity":1,"message":"Async method 'createOrUpdateSupplier' has a complexity of 13. Maximum allowed is 10.","line":106,"column":31,"nodeType":"FunctionExpression","messageId":"complex","endLine":133,"endColumn":4},{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 18 to the 15 allowed.","line":272,"column":9,"nodeType":null,"messageId":"refactorFunction","endLine":272,"endColumn":22},{"ruleId":"complexity","severity":1,"message":"Async method 'recordRequest' has a complexity of 15. Maximum allowed is 10.","line":272,"column":22,"nodeType":"FunctionExpression","messageId":"complex","endLine":345,"endColumn":4}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":5,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * SupplierRegistry Durable Object\n * Manages alternate supplier registrations and their CID mappings\n */\n\n/**\n * Supplier Registry Durable Object\n * Stores supplier information, CID mappings, and statistics\n */\nexport class SupplierRegistry {\n  constructor(state, env) {\n    this.state = state;\n    this.env = env;\n  }\n\n  async fetch(request) {\n    const url = new URL(request.url);\n    const path = url.pathname;\n\n    try {\n      // GET /supplier - Get supplier details\n      if (path === '/supplier' && request.method === 'GET') {\n        return this.getSupplier();\n      }\n\n      // POST /supplier - Create or update supplier\n      if (path === '/supplier' && request.method === 'POST') {\n        const data = await request.json();\n        return this.createOrUpdateSupplier(data);\n      }\n\n      // DELETE /supplier - Delete supplier\n      if (path === '/supplier' && request.method === 'DELETE') {\n        return this.deleteSupplier();\n      }\n\n      // GET /cids - Get list of CIDs for this supplier\n      if (path === '/cids' && request.method === 'GET') {\n        const page = parseInt(url.searchParams.get('page') || '1');\n        const limit = parseInt(url.searchParams.get('limit') || '50');\n        return this.getCIDs(page, limit);\n      }\n\n      // POST /cids - Add CIDs (from scan)\n      if (path === '/cids' && request.method === 'POST') {\n        const data = await request.json();\n        return this.addCIDs(data.cids);\n      }\n\n      // DELETE /cids - Clear all CIDs\n      if (path === '/cids' && request.method === 'DELETE') {\n        return this.clearCIDs();\n      }\n\n      // GET /stats - Get supplier statistics\n      if (path === '/stats' && request.method === 'GET') {\n        return this.getStats();\n      }\n\n      // POST /stats/record - Record request result\n      if (path === '/stats/record' && request.method === 'POST') {\n        const data = await request.json();\n        return this.recordRequest(data);\n      }\n\n      return new Response('Not Found', { status: 404 });\n    } catch (error) {\n      return new Response(\n        JSON.stringify({\n          error: 'Internal Server Error',\n          message: error.message\n        }),\n        {\n          status: 500,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n  }\n\n  /**\n   * Get supplier details\n   */\n  async getSupplier() {\n    const supplier = await this.state.storage.get('supplier');\n    \n    if (!supplier) {\n      return new Response(\n        JSON.stringify({ error: 'Supplier not found' }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    return new Response(JSON.stringify(supplier), {\n      status: 200,\n      headers: { 'content-type': 'application/json' }\n    });\n  }\n\n  /**\n   * Create or update supplier\n   */\n  async createOrUpdateSupplier(data) {\n    const now = new Date().toISOString();\n    const existingSupplier = await this.state.storage.get('supplier');\n\n    const supplier = {\n      supplier_id: data.supplier_id,\n      owner_user_id: data.owner_user_id,\n      name: data.name,\n      supplier_type: data.supplier_type,\n      base_url: data.base_url,\n      single_cid: data.single_cid || null,\n      discovered_cids: existingSupplier?.discovered_cids || [],\n      created_at: existingSupplier?.created_at || now,\n      last_scanned_at: data.last_scanned_at || existingSupplier?.last_scanned_at || null,\n      scan_status: data.scan_status || existingSupplier?.scan_status || 'pending',\n      scan_error: data.scan_error || null,\n      cid_count: existingSupplier?.discovered_cids?.length || 0,\n      is_active: data.is_active !== undefined ? data.is_active : (existingSupplier?.is_active ?? true),\n      stats: existingSupplier?.stats || this.initializeStats(data.supplier_id)\n    };\n\n    await this.state.storage.put('supplier', supplier);\n\n    return new Response(JSON.stringify(supplier), {\n      status: 200,\n      headers: { 'content-type': 'application/json' }\n    });\n  }\n\n  /**\n   * Delete supplier\n   */\n  async deleteSupplier() {\n    await this.state.storage.deleteAll();\n    \n    return new Response(JSON.stringify({ success: true }), {\n      status: 200,\n      headers: { 'content-type': 'application/json' }\n    });\n  }\n\n  /**\n   * Get CIDs with pagination\n   */\n  async getCIDs(page = 1, limit = 50) {\n    const supplier = await this.state.storage.get('supplier');\n    \n    if (!supplier) {\n      return new Response(\n        JSON.stringify({ error: 'Supplier not found' }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    const cids = supplier.discovered_cids || [];\n    const start = (page - 1) * limit;\n    const end = start + limit;\n    const paginatedCids = cids.slice(start, end);\n\n    return new Response(\n      JSON.stringify({\n        cids: paginatedCids,\n        page,\n        limit,\n        total: cids.length,\n        has_more: end < cids.length\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Add CIDs to supplier (from scan)\n   */\n  async addCIDs(newCids) {\n    const supplier = await this.state.storage.get('supplier');\n    \n    if (!supplier) {\n      return new Response(\n        JSON.stringify({ error: 'Supplier not found' }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Merge with existing CIDs (avoid duplicates)\n    const existingCids = new Set(supplier.discovered_cids || []);\n    newCids.forEach(cid => existingCids.add(cid));\n    \n    supplier.discovered_cids = Array.from(existingCids);\n    supplier.cid_count = supplier.discovered_cids.length;\n\n    await this.state.storage.put('supplier', supplier);\n\n    return new Response(\n      JSON.stringify({\n        success: true,\n        cid_count: supplier.cid_count\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Clear all CIDs\n   */\n  async clearCIDs() {\n    const supplier = await this.state.storage.get('supplier');\n    \n    if (!supplier) {\n      return new Response(\n        JSON.stringify({ error: 'Supplier not found' }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    supplier.discovered_cids = [];\n    supplier.cid_count = 0;\n\n    await this.state.storage.put('supplier', supplier);\n\n    return new Response(JSON.stringify({ success: true }), {\n      status: 200,\n      headers: { 'content-type': 'application/json' }\n    });\n  }\n\n  /**\n   * Get supplier statistics\n   */\n  async getStats() {\n    const supplier = await this.state.storage.get('supplier');\n    \n    if (!supplier || !supplier.stats) {\n      return new Response(\n        JSON.stringify({ error: 'Statistics not found' }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    return new Response(JSON.stringify(supplier.stats), {\n      status: 200,\n      headers: { 'content-type': 'application/json' }\n    });\n  }\n\n  /**\n   * Record a request result (for statistics)\n   */\n  async recordRequest(data) {\n    const supplier = await this.state.storage.get('supplier');\n    \n    if (!supplier) {\n      return new Response(\n        JSON.stringify({ error: 'Supplier not found' }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    const stats = supplier.stats || this.initializeStats(supplier.supplier_id);\n    const success = data.success;\n    const responseTimeMs = data.response_time_ms || null;\n    const wasProxy = data.was_proxy || false;\n    const verificationFailed = data.verification_failed || false;\n\n    // Update totals\n    stats.total_requests++;\n    if (success) {\n      stats.successful_requests++;\n      stats.last_success_at = new Date().toISOString();\n    } else {\n      stats.failed_requests++;\n      stats.last_failure_at = new Date().toISOString();\n    }\n\n    // Update verification failures\n    if (verificationFailed) {\n      stats.verification_failures++;\n    }\n\n    // Update rolling window (last 100 requests)\n    if (!Array.isArray(stats.recent_results)) {\n      stats.recent_results = [];\n    }\n    \n    stats.recent_results.push(success);\n    if (stats.recent_results.length > 100) {\n      stats.recent_results.shift(); // Remove oldest\n    }\n\n    // Recalculate recent counts\n    stats.recent_success_count = stats.recent_results.filter(r => r === true).length;\n    stats.recent_failure_count = stats.recent_results.filter(r => r === false).length;\n\n    // Update average response time (exponential moving average)\n    if (responseTimeMs !== null && success) {\n      if (stats.avg_response_time_ms === null || stats.avg_response_time_ms === 0) {\n        stats.avg_response_time_ms = responseTimeMs;\n      } else {\n        // Use exponential moving average (alpha = 0.2)\n        stats.avg_response_time_ms = stats.avg_response_time_ms * 0.8 + responseTimeMs * 0.2;\n      }\n    }\n\n    // Update proxy counter\n    if (wasProxy) {\n      stats.requests_since_last_proxy = 0;\n      stats.last_verified_at = new Date().toISOString();\n    } else {\n      stats.requests_since_last_proxy++;\n    }\n\n    supplier.stats = stats;\n    await this.state.storage.put('supplier', supplier);\n\n    return new Response(JSON.stringify({ success: true, stats }), {\n      status: 200,\n      headers: { 'content-type': 'application/json' }\n    });\n  }\n\n  /**\n   * Initialize statistics for a new supplier\n   */\n  initializeStats(supplierId) {\n    return {\n      supplier_id: supplierId,\n      total_requests: 0,\n      successful_requests: 0,\n      failed_requests: 0,\n      recent_results: [],\n      recent_success_count: 0,\n      recent_failure_count: 0,\n      avg_response_time_ms: null,\n      last_success_at: null,\n      last_failure_at: null,\n      last_verified_at: null,\n      verification_failures: 0,\n      requests_since_last_proxy: 0\n    };\n  }\n}\n\n// Configuration constants (export for testing)\nexport const ROLLING_WINDOW_SIZE = 100;\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/durable-objects/user-profile.js","messages":[{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 57 to the 15 allowed.","line":15,"column":9,"nodeType":null,"messageId":"refactorFunction","endLine":15,"endColumn":14},{"ruleId":"complexity","severity":1,"message":"Async method 'fetch' has a complexity of 61. Maximum allowed is 10.","line":15,"column":14,"nodeType":"FunctionExpression","messageId":"complex","endLine":168,"endColumn":4},{"ruleId":"jsdoc/require-param-description","severity":1,"message":"Missing JSDoc @param \"keyId\" description.","line":760,"column":1,"nodeType":"Block","endLine":760,"endColumn":1},{"ruleId":"jsdoc/require-param-description","severity":1,"message":"Missing JSDoc @param \"request\" description.","line":761,"column":1,"nodeType":"Block","endLine":761,"endColumn":1},{"ruleId":"complexity","severity":1,"message":"Async method 'updateApiKeyName' has a complexity of 11. Maximum allowed is 10.","line":764,"column":25,"nodeType":"FunctionExpression","messageId":"complex","endLine":891,"endColumn":4},{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 25 to the 15 allowed.","line":1295,"column":9,"nodeType":null,"messageId":"refactorFunction","endLine":1295,"endColumn":21},{"ruleId":"complexity","severity":1,"message":"Async method 'debitBalance' has a complexity of 18. Maximum allowed is 10.","line":1295,"column":21,"nodeType":"FunctionExpression","messageId":"complex","endLine":1400,"endColumn":4}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":7,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * UserProfile Durable Object\n * Stores user profile data synced from Clerk authentication\n */\n\n// Maximum API keys per user\nconst MAX_API_KEYS = 25;\n\nexport class UserProfile {\n  constructor(state, env) {\n    this.state = state;\n    this.env = env;\n  }\n\n  async fetch(request) {\n    const url = new URL(request.url);\n    const method = request.method;\n\n    try {\n      // Route to appropriate handler\n      if (url.pathname === '/profile' && method === 'GET') {\n        return await this.getProfile();\n      }\n\n      if (url.pathname === '/profile' && method === 'POST') {\n        const data = await request.json();\n        return await this.createProfile(data);\n      }\n\n      if (url.pathname === '/profile' && method === 'PUT') {\n        const data = await request.json();\n        return await this.updateProfile(data);\n      }\n\n      if (url.pathname === '/settings' && method === 'GET') {\n        return await this.getSettings();\n      }\n\n      if (url.pathname === '/settings' && method === 'PATCH') {\n        const data = await request.json();\n        return await this.updateSettings(data);\n      }\n\n      if (url.pathname === '/profile' && method === 'DELETE') {\n        return await this.deleteProfile();\n      }\n\n      if (url.pathname === '/apikeys' && method === 'POST') {\n        const data = await request.json();\n        return await this.createApiKey(data);\n      }\n\n      if (url.pathname === '/apikeys' && method === 'GET') {\n        return await this.listApiKeys();\n      }\n\n      if (url.pathname.startsWith('/apikeys/') && method === 'DELETE') {\n        const keyId = url.pathname.split('/')[2];\n        return await this.revokeApiKey(keyId);\n      }\n\n      if (url.pathname.startsWith('/apikeys/') && method === 'GET') {\n        const keyId = url.pathname.split('/')[2];\n        return await this.getApiKey(keyId);\n      }\n\n      if (url.pathname === '/uploads' && method === 'POST') {\n        const data = await request.json();\n        return await this.addUpload(data);\n      }\n\n      if (url.pathname === '/uploads' && method === 'GET') {\n        return await this.getUploads();\n      }\n\n      if (url.pathname.startsWith('/apikeys/') && url.pathname.endsWith('/use') && method === 'POST') {\n        const keyId = url.pathname.split('/')[2];\n        return await this.updateLastUsed(keyId);\n      }\n\n      if (url.pathname.startsWith('/apikeys/') && url.pathname.endsWith('/reveal') && method === 'POST') {\n        const keyId = url.pathname.split('/')[2];\n        return await this.revealApiKey(keyId);\n      }\n\n      // Note: Internal route uses '/update' suffix for clarity and consistency with '/reveal' pattern\n      if (url.pathname.startsWith('/apikeys/') && url.pathname.endsWith('/update') && method === 'PATCH') {\n        const keyId = url.pathname.split('/')[2];\n        return await this.updateApiKeyName(keyId, request);\n      }\n\n      if (url.pathname === '/balance' && method === 'GET') {\n        return await this.getBalance();\n      }\n\n      if (url.pathname === '/balance/deposit' && method === 'POST') {\n        const data = await request.json();\n        return await this.depositBalance(data);\n      }\n\n      if (url.pathname === '/balance/debit' && method === 'POST') {\n        const data = await request.json();\n        return await this.debitBalance(data);\n      }\n\n      if (url.pathname === '/oauth/grants' && method === 'GET') {\n        return await this.listOAuthGrants();\n      }\n\n      if (url.pathname === '/oauth/grants' && method === 'POST') {\n        const data = await request.json();\n        return await this.upsertOAuthGrant(data);\n      }\n\n      if (url.pathname.startsWith('/oauth/grants/') && method === 'GET') {\n        const grantId = url.pathname.split('/')[3];\n        return await this.getOAuthGrant(grantId);\n      }\n\n      if (url.pathname.startsWith('/oauth/grants/') && method === 'DELETE') {\n        const appId = url.pathname.split('/')[3];\n        return await this.revokeOAuthGrantByApp(appId);\n      }\n\n      if (url.pathname.startsWith('/oauth/grants-by-id/') && method === 'POST') {\n        const grantId = url.pathname.split('/')[3];\n        return await this.revokeOAuthGrantById(grantId);\n      }\n\n      if (url.pathname === '/oauth/refresh-tokens' && method === 'POST') {\n        const data = await request.json();\n        return await this.storeOAuthRefreshToken(data);\n      }\n\n      if (url.pathname === '/oauth/refresh-tokens/rotate' && method === 'POST') {\n        const data = await request.json();\n        return await this.rotateOAuthRefreshToken(data);\n      }\n\n      if (url.pathname === '/oauth/refresh-tokens/revoke' && method === 'POST') {\n        const data = await request.json();\n        return await this.revokeOAuthRefreshToken(data);\n      }\n\n      if (url.pathname === '/suppliers/add' && method === 'POST') {\n        const data = await request.json();\n        return await this.addSupplier(data);\n      }\n\n      if (url.pathname === '/suppliers/remove' && method === 'POST') {\n        const data = await request.json();\n        return await this.removeSupplier(data);\n      }\n\n      return new Response('Not Found', { status: 404 });\n    } catch (error) {\n      return new Response(\n        JSON.stringify({\n          error: 'Internal error',\n          message: error.message\n        }),\n        {\n          status: 500,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n  }\n\n  /**\n   * Get user profile\n   */\n  async getProfile() {\n    const profile = await this.state.storage.get('profile');\n\n    if (!profile) {\n      return new Response(\n        JSON.stringify({\n          error: 'Profile not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    return new Response(JSON.stringify(profile), {\n      status: 200,\n      headers: { 'content-type': 'application/json' }\n    });\n  }\n\n  /**\n   * Create new user profile\n   */\n  async createProfile(data) {\n    const existingProfile = await this.state.storage.get('profile');\n\n    if (existingProfile) {\n      return new Response(\n        JSON.stringify({\n          error: 'Profile already exists'\n        }),\n        {\n          status: 409,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    const initialBalance = Number.isInteger(data.initial_balance_cents) && data.initial_balance_cents > 0\n      ? data.initial_balance_cents\n      : 0;\n\n    const profile = {\n      user_id: data.user_id,\n      providers: data.providers || [],\n      created_at: new Date().toISOString(),\n      updated_at: new Date().toISOString(),\n      deleted_at: null,\n      api_keys: [],\n      uploads: [],\n      balance_cents: initialBalance,\n      total_deposited_cents: initialBalance,\n      total_spent_cents: 0,\n      supplier_ids: [],\n      supplier_count: 0,\n      default_retention_months: Number.isInteger(data.default_retention_months) ? data.default_retention_months : null,\n      oauth_grants: [],\n      oauth_refresh_tokens: []\n    };\n\n    await this.state.storage.put('profile', profile);\n\n    return new Response(JSON.stringify(profile), {\n      status: 201,\n      headers: { 'content-type': 'application/json' }\n    });\n  }\n\n  /**\n   * Update user profile\n   */\n  async updateProfile(data) {\n    const profile = await this.state.storage.get('profile');\n\n    if (!profile) {\n      return new Response(\n        JSON.stringify({\n          error: 'Profile not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Update allowed fields\n    if (data.providers) {\n      profile.providers = data.providers;\n    }\n\n    if (data.default_retention_months !== undefined) {\n      profile.default_retention_months = data.default_retention_months;\n    }\n\n    profile.updated_at = new Date().toISOString();\n\n    await this.state.storage.put('profile', profile);\n\n    return new Response(JSON.stringify(profile), {\n      status: 200,\n      headers: { 'content-type': 'application/json' }\n    });\n  }\n\n  /**\n   * Delete user profile (soft delete)\n   */\n  async deleteProfile() {\n    const profile = await this.state.storage.get('profile');\n\n    if (!profile) {\n      return new Response(\n        JSON.stringify({\n          error: 'Profile not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Soft delete - mark as deleted but retain payment records\n    profile.deleted_at = new Date().toISOString();\n    profile.updated_at = new Date().toISOString();\n\n    // Clear sensitive data but keep payment history\n    profile.api_keys = [];\n    profile.uploads = [];\n\n    await this.state.storage.put('profile', profile);\n\n    return new Response(\n      JSON.stringify({\n        success: true,\n        message: 'Profile deleted successfully'\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Create a new API key\n   */\n  async createApiKey(data) {\n    const profile = await this.state.storage.get('profile');\n\n    if (!profile) {\n      return new Response(\n        JSON.stringify({\n          error: 'Profile not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Check if user is deleted\n    if (profile.deleted_at) {\n      return new Response(\n        JSON.stringify({\n          error: 'AUTH_USER_DELETED',\n          message: 'User account has been deleted'\n        }),\n        {\n          status: 403,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Check API key limit\n    const activeKeys = profile.api_keys.filter(key => !key.revoked_at);\n    if (activeKeys.length >= MAX_API_KEYS) {\n      return new Response(\n        JSON.stringify({\n          error: 'AUTH_KEY_LIMIT',\n          message: `Maximum of ${MAX_API_KEYS} API keys allowed`\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    const apiKey = {\n      key_id: data.key_id,\n      key_hash: data.key_hash,\n      key_encrypted: data.key_encrypted, // AES-256-GCM encrypted key for reveal\n      name: data.name,\n      created_at: new Date().toISOString(),\n      expires_at: data.expires_at,\n      last_used_at: null,\n      usage_count: 0,\n      revoked_at: null,\n      reveal_timestamps: [] // Track last 3 reveal times for rate limiting\n    };\n\n    profile.api_keys.push(apiKey);\n    profile.updated_at = new Date().toISOString();\n\n    await this.state.storage.put('profile', profile);\n\n    return new Response(\n      JSON.stringify({\n        key_id: apiKey.key_id,\n        name: apiKey.name,\n        created_at: apiKey.created_at,\n        expires_at: apiKey.expires_at\n      }),\n      {\n        status: 201,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * List all API keys (without key values)\n   */\n  async listApiKeys() {\n    const profile = await this.state.storage.get('profile');\n\n    if (!profile) {\n      return new Response(\n        JSON.stringify({\n          error: 'Profile not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Return keys without hashes\n    const keys = profile.api_keys.map(key => ({\n      key_id: key.key_id,\n      name: key.name,\n      created_at: key.created_at,\n      expires_at: key.expires_at,\n      last_used_at: key.last_used_at,\n      usage_count: key.usage_count || 0,\n      revoked: !!key.revoked_at\n    }));\n\n    return new Response(JSON.stringify(keys), {\n      status: 200,\n      headers: { 'content-type': 'application/json' }\n    });\n  }\n\n  /**\n   * Get specific API key by ID\n   */\n  async getApiKey(keyId) {\n    const profile = await this.state.storage.get('profile');\n\n    if (!profile) {\n      return new Response(\n        JSON.stringify({\n          error: 'Profile not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    const apiKey = profile.api_keys.find(key => key.key_id === keyId);\n\n    if (!apiKey) {\n      return new Response(\n        JSON.stringify({\n          error: 'API key not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    return new Response(JSON.stringify(apiKey), {\n      status: 200,\n      headers: { 'content-type': 'application/json' }\n    });\n  }\n\n  /**\n   * Revoke an API key\n   */\n  async revokeApiKey(keyId) {\n    const profile = await this.state.storage.get('profile');\n\n    if (!profile) {\n      return new Response(\n        JSON.stringify({\n          error: 'Profile not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    const apiKey = profile.api_keys.find(key => key.key_id === keyId);\n\n    if (!apiKey) {\n      return new Response(\n        JSON.stringify({\n          error: 'API key not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    if (apiKey.revoked_at) {\n      // Idempotent - already revoked, return success\n      return new Response(\n        JSON.stringify({\n          success: true,\n          message: 'API key already revoked',\n          revoked_at: apiKey.revoked_at\n        }),\n        {\n          status: 200,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    apiKey.revoked_at = new Date().toISOString();\n    profile.updated_at = new Date().toISOString();\n\n    await this.state.storage.put('profile', profile);\n\n    return new Response(\n      JSON.stringify({\n        success: true,\n        message: 'API key revoked successfully'\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Reveal an API key (with rate limiting)\n   * Returns encrypted key data if within rate limits\n   */\n  async revealApiKey(keyId) {\n    const profile = await this.state.storage.get('profile');\n\n    if (!profile) {\n      return new Response(\n        JSON.stringify({\n          error: 'Profile not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    const apiKey = profile.api_keys.find(key => key.key_id === keyId);\n\n    if (!apiKey) {\n      return new Response(\n        JSON.stringify({\n          error: 'API key not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Check if key is revoked\n    if (apiKey.revoked_at) {\n      return new Response(\n        JSON.stringify({\n          error: 'KEY_REVOKED',\n          message: 'Cannot reveal a revoked API key'\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Check rate limiting (3 reveals per hour)\n    const now = new Date();\n    const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);\n    \n    // Initialize reveal_timestamps if not present (backward compatibility)\n    if (!apiKey.reveal_timestamps) {\n      apiKey.reveal_timestamps = [];\n    }\n\n    // Filter to only recent reveals (within last hour)\n    const recentReveals = apiKey.reveal_timestamps\n      .map(ts => new Date(ts))\n      .filter(ts => ts > oneHourAgo);\n\n    if (recentReveals.length >= 3) {\n      // Calculate when the oldest reveal will be outside the window\n      const oldestReveal = recentReveals.sort((a, b) => a - b)[0];\n      const resetTime = new Date(oldestReveal.getTime() + 60 * 60 * 1000);\n      const retryAfterSeconds = Math.ceil((resetTime.getTime() - now.getTime()) / 1000);\n\n      return new Response(\n        JSON.stringify({\n          error: 'REVEAL_RATE_LIMITED',\n          message: 'Maximum 3 reveals per hour exceeded',\n          retry_after_seconds: retryAfterSeconds\n        }),\n        {\n          status: 429,\n          headers: {\n            'content-type': 'application/json',\n            'Retry-After': retryAfterSeconds.toString()\n          }\n        }\n      );\n    }\n\n    // Record this reveal\n    apiKey.reveal_timestamps.push(now.toISOString());\n    \n    // Keep only last 3 timestamps (sliding window)\n    if (apiKey.reveal_timestamps.length > 3) {\n      apiKey.reveal_timestamps = apiKey.reveal_timestamps.slice(-3);\n    }\n\n    profile.updated_at = new Date().toISOString();\n    await this.state.storage.put('profile', profile);\n\n    // Return encrypted key data (decryption happens in API handler with encryption key)\n    return new Response(\n      JSON.stringify({\n        key_id: apiKey.key_id,\n        key_encrypted: apiKey.key_encrypted,\n        name: apiKey.name,\n        created_at: apiKey.created_at,\n        expires_at: apiKey.expires_at,\n        last_used_at: apiKey.last_used_at\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Add an upload to user's history\n   */\n  async addUpload(data) {\n    const profile = await this.state.storage.get('profile');\n\n    if (!profile) {\n      return new Response(\n        JSON.stringify({\n          error: 'Profile not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    const upload = {\n      content_hash: data.content_hash,\n      uploaded_at: new Date().toISOString(),\n      size_bytes: data.size_bytes,\n      payment_id: data.payment_id || null\n    };\n\n    profile.uploads.push(upload);\n    profile.updated_at = new Date().toISOString();\n\n    await this.state.storage.put('profile', profile);\n\n    return new Response(JSON.stringify(upload), {\n      status: 201,\n      headers: { 'content-type': 'application/json' }\n    });\n  }\n\n  /**\n   * Get user's upload history\n   */\n  async getUploads() {\n    const profile = await this.state.storage.get('profile');\n\n    if (!profile) {\n      return new Response(\n        JSON.stringify({\n          error: 'Profile not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    return new Response(JSON.stringify(profile.uploads || []), {\n      status: 200,\n      headers: { 'content-type': 'application/json' }\n    });\n  }\n\n  /**\n   * Update last_used_at timestamp for an API key\n   */\n  async updateLastUsed(keyId) {\n    const profile = await this.state.storage.get('profile');\n\n    if (!profile) {\n      return new Response(\n        JSON.stringify({\n          error: 'Profile not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    const apiKey = profile.api_keys.find(key => key.key_id === keyId);\n\n    if (!apiKey) {\n      return new Response(\n        JSON.stringify({\n          error: 'API key not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    apiKey.last_used_at = new Date().toISOString();\n    \n    // Increment usage count (initialize to 0 if not present for backward compatibility)\n    apiKey.usage_count = (apiKey.usage_count || 0) + 1;\n    \n    profile.updated_at = new Date().toISOString();\n\n    await this.state.storage.put('profile', profile);\n\n    return new Response(\n      JSON.stringify({\n        success: true\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Update API key name\n   * @param {string} keyId\n   * @param {Request} request\n   * @returns {Response}\n   */\n  async updateApiKeyName(keyId, request) {\n    const profile = await this.state.storage.get('profile');\n\n    if (!profile) {\n      return new Response(\n        JSON.stringify({\n          error: 'Profile not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Find the API key\n    const apiKey = profile.api_keys.find(key => key.key_id === keyId);\n\n    if (!apiKey) {\n      return new Response(\n        JSON.stringify({\n          error: 'API key not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Check if key is revoked\n    if (apiKey.revoked_at) {\n      return new Response(\n        JSON.stringify({\n          error: 'KEY_REVOKED',\n          message: 'Cannot update a revoked API key'\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Check if key is expired\n    const now = new Date();\n    const expiresAt = new Date(apiKey.expires_at);\n    if (expiresAt < now) {\n      return new Response(\n        JSON.stringify({\n          error: 'KEY_EXPIRED',\n          message: 'Cannot update an expired API key'\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Parse request body\n    let body;\n    try {\n      body = await request.json();\n    } catch (error) {\n      return new Response(\n        JSON.stringify({\n          error: 'Invalid request body'\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    const { name } = body;\n\n    // Validate name (same validation as in auth handler for consistency)\n    if (!name || typeof name !== 'string') {\n      return new Response(\n        JSON.stringify({\n          error: 'Invalid name',\n          message: 'Name is required and must be a string'\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    if (name.length < 1 || name.length > 100) {\n      return new Response(\n        JSON.stringify({\n          error: 'Invalid name length',\n          message: 'Name must be between 1 and 100 characters'\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Update the name\n    apiKey.name = name;\n    profile.updated_at = new Date().toISOString();\n\n    await this.state.storage.put('profile', profile);\n\n    // Return updated key metadata (without sensitive fields)\n    return new Response(\n      JSON.stringify({\n        key_id: apiKey.key_id,\n        name: apiKey.name,\n        created_at: apiKey.created_at,\n        expires_at: apiKey.expires_at,\n        last_used_at: apiKey.last_used_at,\n        usage_count: apiKey.usage_count || 0,\n        revoked: !!apiKey.revoked_at\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Get balance information\n   */\n  async getBalance() {\n    const profile = await this.state.storage.get('profile');\n\n    if (!profile) {\n      return new Response(\n        JSON.stringify({\n          error: 'Profile not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    return new Response(\n      JSON.stringify({\n        balance_cents: profile.balance_cents || 0,\n        total_deposited_cents: profile.total_deposited_cents || 0,\n        total_spent_cents: profile.total_spent_cents || 0\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  normalizeOAuthCollections(profile) {\n    if (!profile.oauth_grants) {\n      profile.oauth_grants = [];\n    }\n    if (!profile.oauth_refresh_tokens) {\n      profile.oauth_refresh_tokens = [];\n    }\n  }\n\n  async getSettings() {\n    const profile = await this.state.storage.get('profile');\n\n    if (!profile) {\n      return new Response(JSON.stringify({ error: 'Profile not found' }), {\n        status: 404,\n        headers: { 'content-type': 'application/json' }\n      });\n    }\n\n    return new Response(JSON.stringify({\n      default_retention_months: profile.default_retention_months || null\n    }), {\n      headers: { 'content-type': 'application/json' }\n    });\n  }\n\n  async updateSettings(data) {\n    const profile = await this.state.storage.get('profile');\n\n    if (!profile) {\n      return new Response(JSON.stringify({ error: 'Profile not found' }), {\n        status: 404,\n        headers: { 'content-type': 'application/json' }\n      });\n    }\n\n    const value = data.default_retention_months;\n    if (!Number.isInteger(value) || value < 1) {\n      return new Response(JSON.stringify({\n        error: 'invalid_settings',\n        message: 'default_retention_months must be an integer >= 1'\n      }), {\n        status: 400,\n        headers: { 'content-type': 'application/json' }\n      });\n    }\n\n    profile.default_retention_months = value;\n    profile.updated_at = new Date().toISOString();\n    await this.state.storage.put('profile', profile);\n\n    return new Response(JSON.stringify({\n      default_retention_months: profile.default_retention_months\n    }), {\n      headers: { 'content-type': 'application/json' }\n    });\n  }\n\n  nextMonthlyReset(fromDate = new Date()) {\n    const next = new Date(fromDate);\n    next.setMonth(next.getMonth() + 1);\n    return next.toISOString();\n  }\n\n  async upsertOAuthGrant(data) {\n    const profile = await this.state.storage.get('profile');\n\n    if (!profile) {\n      return new Response(JSON.stringify({ error: 'Profile not found' }), {\n        status: 404,\n        headers: { 'content-type': 'application/json' }\n      });\n    }\n\n    this.normalizeOAuthCollections(profile);\n    let grant = profile.oauth_grants.find((entry) => entry.app_id === data.app_id && !entry.revoked_at);\n\n    if (!grant) {\n      grant = {\n        grant_id: `grant_${crypto.randomUUID()}`,\n        user_id: profile.user_id,\n        app_id: data.app_id,\n        scopes: data.scopes || [],\n        spending_limit: data.spending_limit ?? null,\n        spending_used: 0,\n        spending_reset: this.nextMonthlyReset(),\n        created_at: new Date().toISOString(),\n        revoked_at: null\n      };\n      profile.oauth_grants.push(grant);\n    } else {\n      grant.scopes = data.scopes || grant.scopes;\n      grant.spending_limit = data.spending_limit ?? grant.spending_limit ?? null;\n      if (!grant.spending_reset) {\n        grant.spending_reset = this.nextMonthlyReset();\n      }\n    }\n\n    profile.updated_at = new Date().toISOString();\n    await this.state.storage.put('profile', profile);\n\n    return new Response(JSON.stringify(grant), {\n      headers: { 'content-type': 'application/json' }\n    });\n  }\n\n  async listOAuthGrants() {\n    const profile = await this.state.storage.get('profile');\n\n    if (!profile) {\n      return new Response(JSON.stringify({ error: 'Profile not found' }), {\n        status: 404,\n        headers: { 'content-type': 'application/json' }\n      });\n    }\n\n    this.normalizeOAuthCollections(profile);\n\n    return new Response(JSON.stringify({\n      authorizations: profile.oauth_grants.filter((grant) => !grant.revoked_at)\n    }), {\n      headers: { 'content-type': 'application/json' }\n    });\n  }\n\n  async getOAuthGrant(grantId) {\n    const profile = await this.state.storage.get('profile');\n\n    if (!profile) {\n      return new Response(JSON.stringify({ error: 'Profile not found' }), {\n        status: 404,\n        headers: { 'content-type': 'application/json' }\n      });\n    }\n\n    this.normalizeOAuthCollections(profile);\n    const grant = profile.oauth_grants.find((entry) => entry.grant_id === grantId);\n    if (!grant) {\n      return new Response(JSON.stringify({ error: 'Grant not found' }), {\n        status: 404,\n        headers: { 'content-type': 'application/json' }\n      });\n    }\n\n    return new Response(JSON.stringify(grant), {\n      headers: { 'content-type': 'application/json' }\n    });\n  }\n\n  async revokeOAuthGrantByApp(appId) {\n    const profile = await this.state.storage.get('profile');\n\n    if (!profile) {\n      return new Response(JSON.stringify({ error: 'Profile not found' }), {\n        status: 404,\n        headers: { 'content-type': 'application/json' }\n      });\n    }\n\n    this.normalizeOAuthCollections(profile);\n    const grant = profile.oauth_grants.find((entry) => entry.app_id === appId && !entry.revoked_at);\n    if (!grant) {\n      return new Response(JSON.stringify({ error: 'Grant not found' }), {\n        status: 404,\n        headers: { 'content-type': 'application/json' }\n      });\n    }\n\n    grant.revoked_at = new Date().toISOString();\n    for (const token of profile.oauth_refresh_tokens) {\n      if (token.grant_id === grant.grant_id && !token.revoked_at) {\n        token.revoked_at = grant.revoked_at;\n      }\n    }\n    profile.updated_at = new Date().toISOString();\n    await this.state.storage.put('profile', profile);\n\n    return new Response(JSON.stringify({\n      success: true,\n      app_id: appId,\n      revoked_at: grant.revoked_at\n    }), {\n      headers: { 'content-type': 'application/json' }\n    });\n  }\n\n  async revokeOAuthGrantById(grantId) {\n    const profile = await this.state.storage.get('profile');\n\n    if (!profile) {\n      return new Response(JSON.stringify({ error: 'Profile not found' }), {\n        status: 404,\n        headers: { 'content-type': 'application/json' }\n      });\n    }\n\n    this.normalizeOAuthCollections(profile);\n    const grant = profile.oauth_grants.find((entry) => entry.grant_id === grantId && !entry.revoked_at);\n    if (!grant) {\n      return new Response(JSON.stringify({ error: 'Grant not found' }), {\n        status: 404,\n        headers: { 'content-type': 'application/json' }\n      });\n    }\n\n    return this.revokeOAuthGrantByApp(grant.app_id);\n  }\n\n  async storeOAuthRefreshToken(data) {\n    const profile = await this.state.storage.get('profile');\n\n    if (!profile) {\n      return new Response(JSON.stringify({ error: 'Profile not found' }), {\n        status: 404,\n        headers: { 'content-type': 'application/json' }\n      });\n    }\n\n    this.normalizeOAuthCollections(profile);\n\n    const refreshToken = {\n      token_hash: data.token_hash,\n      grant_id: data.grant_id,\n      app_id: data.app_id,\n      user_id: profile.user_id,\n      expires_at: data.expires_at,\n      created_at: new Date().toISOString(),\n      revoked_at: null\n    };\n    profile.oauth_refresh_tokens.push(refreshToken);\n    profile.updated_at = new Date().toISOString();\n\n    await this.state.storage.put('profile', profile);\n\n    return new Response(JSON.stringify(refreshToken), {\n      status: 201,\n      headers: { 'content-type': 'application/json' }\n    });\n  }\n\n  async rotateOAuthRefreshToken(data) {\n    const profile = await this.state.storage.get('profile');\n\n    if (!profile) {\n      return new Response(JSON.stringify({ error: 'Profile not found' }), {\n        status: 404,\n        headers: { 'content-type': 'application/json' }\n      });\n    }\n\n    this.normalizeOAuthCollections(profile);\n    const token = profile.oauth_refresh_tokens.find((entry) => entry.token_hash === data.current_token_hash && !entry.revoked_at);\n    if (!token || new Date(token.expires_at) <= new Date()) {\n      return new Response(JSON.stringify({ error: 'Refresh token not found' }), {\n        status: 404,\n        headers: { 'content-type': 'application/json' }\n      });\n    }\n\n    token.revoked_at = new Date().toISOString();\n    const grant = profile.oauth_grants.find((entry) => entry.grant_id === token.grant_id && !entry.revoked_at);\n    const replacement = {\n      token_hash: data.new_token_hash,\n      grant_id: token.grant_id,\n      app_id: token.app_id,\n      user_id: profile.user_id,\n      expires_at: data.new_expires_at,\n      created_at: new Date().toISOString(),\n      revoked_at: null\n    };\n    profile.oauth_refresh_tokens.push(replacement);\n    profile.updated_at = new Date().toISOString();\n\n    await this.state.storage.put('profile', profile);\n\n    return new Response(JSON.stringify({\n      grant_id: token.grant_id,\n      app_id: token.app_id,\n      user_id: profile.user_id,\n      scopes: grant?.scopes || []\n    }), {\n      headers: { 'content-type': 'application/json' }\n    });\n  }\n\n  async revokeOAuthRefreshToken(data) {\n    const profile = await this.state.storage.get('profile');\n\n    if (!profile) {\n      return new Response(JSON.stringify({ error: 'Profile not found' }), {\n        status: 404,\n        headers: { 'content-type': 'application/json' }\n      });\n    }\n\n    this.normalizeOAuthCollections(profile);\n    const token = profile.oauth_refresh_tokens.find((entry) => entry.token_hash === data.token_hash && !entry.revoked_at);\n    if (!token) {\n      return new Response(JSON.stringify({ success: true }), {\n        headers: { 'content-type': 'application/json' }\n      });\n    }\n\n    token.revoked_at = new Date().toISOString();\n    profile.updated_at = new Date().toISOString();\n    await this.state.storage.put('profile', profile);\n\n    return new Response(JSON.stringify({ success: true }), {\n      headers: { 'content-type': 'application/json' }\n    });\n  }\n\n  /**\n   * Deposit to balance (credit)\n   * This should only be called after successful Stripe payment\n   */\n  async depositBalance(data) {\n    const profile = await this.state.storage.get('profile');\n\n    if (!profile) {\n      return new Response(\n        JSON.stringify({\n          error: 'Profile not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    const amount_cents = data.amount_cents;\n    if (!amount_cents || amount_cents <= 0) {\n      return new Response(\n        JSON.stringify({\n          error: 'Invalid amount',\n          message: 'Amount must be greater than 0'\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    const balance_before = profile.balance_cents || 0;\n    const balance_after = balance_before + amount_cents;\n\n    profile.balance_cents = balance_after;\n    profile.total_deposited_cents = (profile.total_deposited_cents || 0) + amount_cents;\n    profile.updated_at = new Date().toISOString();\n\n    await this.state.storage.put('profile', profile);\n\n    return new Response(\n      JSON.stringify({\n        balance_before_cents: balance_before,\n        balance_after_cents: balance_after,\n        amount_cents: amount_cents\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Debit from balance (payment)\n   * Returns error if insufficient balance\n   */\n  async debitBalance(data) {\n    const profile = await this.state.storage.get('profile');\n\n    if (!profile) {\n      return new Response(\n        JSON.stringify({\n          error: 'Profile not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    this.normalizeOAuthCollections(profile);\n    const amount_cents = data.amount_cents;\n    if (!amount_cents || amount_cents <= 0) {\n      return new Response(\n        JSON.stringify({\n          error: 'Invalid amount',\n          message: 'Amount must be greater than 0'\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    if (data.oauth_grant_id) {\n      const grant = profile.oauth_grants.find((entry) => entry.grant_id === data.oauth_grant_id && !entry.revoked_at);\n      if (!grant) {\n        return new Response(JSON.stringify({\n          error: 'invalid_grant',\n          message: 'OAuth grant not found'\n        }), {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        });\n      }\n\n      if (grant.spending_reset && new Date(grant.spending_reset) <= new Date()) {\n        grant.spending_used = 0;\n        grant.spending_reset = this.nextMonthlyReset();\n      }\n\n      if (grant.spending_limit !== null && grant.spending_limit !== undefined) {\n        const proposedSpend = (grant.spending_used || 0) + (amount_cents / 100);\n        if (proposedSpend > grant.spending_limit) {\n          return new Response(JSON.stringify({\n            error: 'spending_limit_exceeded',\n            message: 'This app would exceed its monthly spending limit'\n          }), {\n            status: 400,\n            headers: { 'content-type': 'application/json' }\n          });\n        }\n      }\n    }\n\n    const balance_before = profile.balance_cents || 0;\n    \n    // Check for sufficient balance\n    if (balance_before < amount_cents) {\n      return new Response(\n        JSON.stringify({\n          error: 'insufficient_balance',\n          message: 'Insufficient balance for this transaction',\n          balance_cents: balance_before,\n          required_cents: amount_cents,\n          shortfall_cents: amount_cents - balance_before\n        }),\n        {\n          status: 400,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    const balance_after = balance_before - amount_cents;\n\n    profile.balance_cents = balance_after;\n    profile.total_spent_cents = (profile.total_spent_cents || 0) + amount_cents;\n    if (data.oauth_grant_id) {\n      const grant = profile.oauth_grants.find((entry) => entry.grant_id === data.oauth_grant_id && !entry.revoked_at);\n      if (grant) {\n        grant.spending_used = (grant.spending_used || 0) + (amount_cents / 100);\n      }\n    }\n    profile.updated_at = new Date().toISOString();\n\n    await this.state.storage.put('profile', profile);\n\n    return new Response(\n      JSON.stringify({\n        balance_before_cents: balance_before,\n        balance_after_cents: balance_after,\n        amount_cents: amount_cents\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Add supplier to user profile\n   */\n  async addSupplier(data) {\n    const profile = await this.state.storage.get('profile');\n\n    if (!profile) {\n      return new Response(\n        JSON.stringify({\n          error: 'Profile not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    const supplierId = data.supplier_id;\n    \n    // Initialize supplier arrays if needed\n    if (!profile.supplier_ids) {\n      profile.supplier_ids = [];\n    }\n    if (!profile.supplier_count) {\n      profile.supplier_count = 0;\n    }\n\n    // Check if already added\n    if (profile.supplier_ids.includes(supplierId)) {\n      return new Response(\n        JSON.stringify({\n          error: 'Supplier already added'\n        }),\n        {\n          status: 409,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Add supplier\n    profile.supplier_ids.push(supplierId);\n    profile.supplier_count = profile.supplier_ids.length;\n    profile.updated_at = new Date().toISOString();\n\n    await this.state.storage.put('profile', profile);\n\n    return new Response(\n      JSON.stringify({\n        success: true,\n        supplier_count: profile.supplier_count\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n\n  /**\n   * Remove supplier from user profile\n   */\n  async removeSupplier(data) {\n    const profile = await this.state.storage.get('profile');\n\n    if (!profile) {\n      return new Response(\n        JSON.stringify({\n          error: 'Profile not found'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    const supplierId = data.supplier_id;\n    \n    // Initialize supplier arrays if needed\n    if (!profile.supplier_ids) {\n      profile.supplier_ids = [];\n    }\n    if (!profile.supplier_count) {\n      profile.supplier_count = 0;\n    }\n\n    // Remove supplier\n    profile.supplier_ids = profile.supplier_ids.filter(id => id !== supplierId);\n    profile.supplier_count = profile.supplier_ids.length;\n    profile.updated_at = new Date().toISOString();\n\n    await this.state.storage.put('profile', profile);\n\n    return new Response(\n      JSON.stringify({\n        success: true,\n        supplier_count: profile.supplier_count\n      }),\n      {\n        status: 200,\n        headers: { 'content-type': 'application/json' }\n      }\n    );\n  }\n}\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/durable-objects/user-profile.test.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/index-developers-route.test.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/index-oauth-cors.test.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/index-sdk-route.test.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/index.js","messages":[{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 45 to the 15 allowed.","line":154,"column":9,"nodeType":null,"messageId":"refactorFunction","endLine":154,"endColumn":14},{"ruleId":"complexity","severity":1,"message":"Async method 'fetch' has a complexity of 32. Maximum allowed is 10.","line":154,"column":14,"nodeType":"FunctionExpression","messageId":"complex","endLine":265,"endColumn":4},{"ruleId":"jsdoc/tag-lines","severity":1,"message":"Expected only 0 line after block description","line":311,"column":1,"nodeType":"Block","endLine":311,"endColumn":1,"fix":{"range":[10247,10732],"text":"/**\n   * Process expired content\n   *\n   * Batch processes content that has expired for the current date.\n   * Implements \"extension wins\" strategy: validates metadata before deletion\n   * to ensure content wasn't extended after being indexed for expiration.\n   *\n   * Batch Limit: Maximum 5,000 deletions per run (Cloudflare Workers 30s CPU limit)\n   * Strategy: Check metadata expires_at before deletion; skip if extended\n   * @param {Object} env - Environment bindings\n   */"}},{"ruleId":"no-unused-vars","severity":1,"message":"'env' is defined but never used. Allowed unused args must match /^_/u.","line":558,"column":34,"nodeType":"Identifier","messageId":"unusedVar","endLine":558,"endColumn":37},{"ruleId":"complexity","severity":1,"message":"Function 'handleApiRoutes' has a complexity of 141. Maximum allowed is 10.","line":590,"column":1,"nodeType":"FunctionDeclaration","messageId":"complex","endLine":919,"endColumn":2},{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 139 to the 15 allowed.","line":590,"column":10,"nodeType":null,"messageId":"refactorFunction","endLine":590,"endColumn":25},{"ruleId":"no-useless-escape","severity":2,"message":"Unnecessary escape character: \\/.","line":685,"column":62,"nodeType":"Literal","messageId":"unnecessaryEscape","endLine":685,"endColumn":63,"suggestions":[{"messageId":"removeEscape","fix":{"range":[24155,24156],"text":""},"desc":"Remove the `\\`. This maintains the current functionality."},{"messageId":"escapeBackslash","fix":{"range":[24155,24155],"text":"\\"},"desc":"Replace the `\\` with `\\\\` to include the actual backslash character."}]},{"ruleId":"no-useless-escape","severity":2,"message":"Unnecessary escape character: \\/.","line":764,"column":47,"nodeType":"Literal","messageId":"unnecessaryEscape","endLine":764,"endColumn":48,"suggestions":[{"messageId":"removeEscape","fix":{"range":[26820,26821],"text":""},"desc":"Remove the `\\`. This maintains the current functionality."},{"messageId":"escapeBackslash","fix":{"range":[26820,26820],"text":"\\"},"desc":"Replace the `\\` with `\\\\` to include the actual backslash character."}]},{"ruleId":"no-useless-escape","severity":2,"message":"Unnecessary escape character: \\/.","line":771,"column":46,"nodeType":"Literal","messageId":"unnecessaryEscape","endLine":771,"endColumn":47,"suggestions":[{"messageId":"removeEscape","fix":{"range":[27119,27120],"text":""},"desc":"Remove the `\\`. This maintains the current functionality."},{"messageId":"escapeBackslash","fix":{"range":[27119,27119],"text":"\\"},"desc":"Replace the `\\` with `\\\\` to include the actual backslash character."}]},{"ruleId":"no-useless-escape","severity":2,"message":"Unnecessary escape character: \\/.","line":779,"column":46,"nodeType":"Literal","messageId":"unnecessaryEscape","endLine":779,"endColumn":47,"suggestions":[{"messageId":"removeEscape","fix":{"range":[27457,27458],"text":""},"desc":"Remove the `\\`. This maintains the current functionality."},{"messageId":"escapeBackslash","fix":{"range":[27457,27457],"text":"\\"},"desc":"Replace the `\\` with `\\\\` to include the actual backslash character."}]},{"ruleId":"no-useless-escape","severity":2,"message":"Unnecessary escape character: \\/.","line":789,"column":54,"nodeType":"Literal","messageId":"unnecessaryEscape","endLine":789,"endColumn":55,"suggestions":[{"messageId":"removeEscape","fix":{"range":[27805,27806],"text":""},"desc":"Remove the `\\`. This maintains the current functionality."},{"messageId":"escapeBackslash","fix":{"range":[27805,27805],"text":"\\"},"desc":"Replace the `\\` with `\\\\` to include the actual backslash character."}]},{"ruleId":"no-useless-escape","severity":2,"message":"Unnecessary escape character: \\/.","line":794,"column":53,"nodeType":"Literal","messageId":"unnecessaryEscape","endLine":794,"endColumn":54,"suggestions":[{"messageId":"removeEscape","fix":{"range":[28003,28004],"text":""},"desc":"Remove the `\\`. This maintains the current functionality."},{"messageId":"escapeBackslash","fix":{"range":[28003,28003],"text":"\\"},"desc":"Replace the `\\` with `\\\\` to include the actual backslash character."}]},{"ruleId":"no-useless-escape","severity":2,"message":"Unnecessary escape character: \\/.","line":812,"column":48,"nodeType":"Literal","messageId":"unnecessaryEscape","endLine":812,"endColumn":49,"suggestions":[{"messageId":"removeEscape","fix":{"range":[28602,28603],"text":""},"desc":"Remove the `\\`. This maintains the current functionality."},{"messageId":"escapeBackslash","fix":{"range":[28602,28602],"text":"\\"},"desc":"Replace the `\\` with `\\\\` to include the actual backslash character."}]},{"ruleId":"no-useless-escape","severity":2,"message":"Unnecessary escape character: \\/.","line":817,"column":48,"nodeType":"Literal","messageId":"unnecessaryEscape","endLine":817,"endColumn":49,"suggestions":[{"messageId":"removeEscape","fix":{"range":[28800,28801],"text":""},"desc":"Remove the `\\`. This maintains the current functionality."},{"messageId":"escapeBackslash","fix":{"range":[28800,28800],"text":"\\"},"desc":"Replace the `\\` with `\\\\` to include the actual backslash character."}]},{"ruleId":"no-useless-escape","severity":2,"message":"Unnecessary escape character: \\/.","line":822,"column":48,"nodeType":"Literal","messageId":"unnecessaryEscape","endLine":822,"endColumn":49,"suggestions":[{"messageId":"removeEscape","fix":{"range":[29004,29005],"text":""},"desc":"Remove the `\\`. This maintains the current functionality."},{"messageId":"escapeBackslash","fix":{"range":[29004,29004],"text":"\\"},"desc":"Replace the `\\` with `\\\\` to include the actual backslash character."}]},{"ruleId":"no-useless-escape","severity":2,"message":"Unnecessary escape character: \\/.","line":827,"column":48,"nodeType":"Literal","messageId":"unnecessaryEscape","endLine":827,"endColumn":49,"suggestions":[{"messageId":"removeEscape","fix":{"range":[29207,29208],"text":""},"desc":"Remove the `\\`. This maintains the current functionality."},{"messageId":"escapeBackslash","fix":{"range":[29207,29207],"text":"\\"},"desc":"Replace the `\\` with `\\\\` to include the actual backslash character."}]},{"ruleId":"no-useless-escape","severity":2,"message":"Unnecessary escape character: \\/.","line":832,"column":46,"nodeType":"Literal","messageId":"unnecessaryEscape","endLine":832,"endColumn":47,"suggestions":[{"messageId":"removeEscape","fix":{"range":[29413,29414],"text":""},"desc":"Remove the `\\`. This maintains the current functionality."},{"messageId":"escapeBackslash","fix":{"range":[29413,29413],"text":"\\"},"desc":"Replace the `\\` with `\\\\` to include the actual backslash character."}]},{"ruleId":"no-useless-escape","severity":2,"message":"Unnecessary escape character: \\/.","line":862,"column":52,"nodeType":"Literal","messageId":"unnecessaryEscape","endLine":862,"endColumn":53,"suggestions":[{"messageId":"removeEscape","fix":{"range":[30466,30467],"text":""},"desc":"Remove the `\\`. This maintains the current functionality."},{"messageId":"escapeBackslash","fix":{"range":[30466,30466],"text":"\\"},"desc":"Replace the `\\` with `\\\\` to include the actual backslash character."}]},{"ruleId":"no-useless-escape","severity":2,"message":"Unnecessary escape character: \\/.","line":897,"column":56,"nodeType":"Literal","messageId":"unnecessaryEscape","endLine":897,"endColumn":57,"suggestions":[{"messageId":"removeEscape","fix":{"range":[31713,31714],"text":""},"desc":"Remove the `\\`. This maintains the current functionality."},{"messageId":"escapeBackslash","fix":{"range":[31713,31713],"text":"\\"},"desc":"Replace the `\\` with `\\\\` to include the actual backslash character."}]},{"ruleId":"no-unused-vars","severity":1,"message":"'handleRoot' is defined but never used.","line":963,"column":10,"nodeType":"Identifier","messageId":"unusedVar","endLine":963,"endColumn":20},{"ruleId":"complexity","severity":1,"message":"Async function 'handleHealth' has a complexity of 28. Maximum allowed is 10.","line":1009,"column":1,"nodeType":"FunctionDeclaration","messageId":"complex","endLine":1126,"endColumn":2},{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 35 to the 15 allowed.","line":1009,"column":16,"nodeType":null,"messageId":"refactorFunction","endLine":1009,"endColumn":28},{"ruleId":"complexity","severity":1,"message":"Async function 'checkClerk' has a complexity of 15. Maximum allowed is 10.","line":1290,"column":1,"nodeType":"FunctionDeclaration","messageId":"complex","endLine":1340,"endColumn":2},{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 18 to the 15 allowed.","line":1290,"column":16,"nodeType":null,"messageId":"refactorFunction","endLine":1290,"endColumn":26}],"suppressedMessages":[],"errorCount":13,"fatalErrorCount":0,"warningCount":11,"fixableErrorCount":0,"fixableWarningCount":1,"source":"/**\n * HashBin.org - Main Worker Entry Point\n * Content distribution platform using 256t hash-based addressing\n */\n\n// Export Durable Object classes\nexport { ContentMetadata } from './durable-objects/content-metadata.js';\nexport { UserProfile } from './durable-objects/user-profile.js';\nexport { PaymentRecord } from './durable-objects/payment-record.js';\nexport { ContestRecord } from './durable-objects/contest-record.js';\nexport { MessageThread } from './durable-objects/message-thread.js';\nexport { KeyRegistry } from './durable-objects/key-registry.js';\nexport { PlatformStats } from './durable-objects/platform-stats.js';\nexport { AlertStore } from './durable-objects/alert-store.js';\nexport { AuditLog } from './durable-objects/audit-log.js';\nexport { SupplierRegistry } from './durable-objects/supplier-registry.js';\nexport { InfrastructureCost } from './durable-objects/infrastructure-cost.js';\nexport { DeletionRecord } from './durable-objects/deletion-record.js';\nexport { ExpirationIndex } from './durable-objects/expiration-index.js';\nexport { DisputeRecord } from './durable-objects/dispute-record.js';\nexport { DisputeIndex } from './durable-objects/dispute-index.js';\nexport { AdminActionLog } from './durable-objects/admin-action-log.js';\nexport { ApplicationRegistry } from './durable-objects/application-registry.js';\n\n// Import API route handlers\nimport {\n  handleAuthCallback,\n  handleSessionInfo,\n  handleLogout,\n  handleLinkProvider,\n  handleCreateApiKey,\n  handleListApiKeys,\n  handleRevokeApiKey,\n  handleRevealApiKey,\n  handleUpdateApiKey,\n  handleDeleteAccount\n} from './api/auth.js';\n\nimport { handleGetBalance, handleGetBalanceHistory } from './api/balance.js';\n\nimport { \n  handleCreateDeposit, \n  handleStripeWebhook, \n  handleCalculateRetention,\n  handleCreateDonation\n} from './api/payments.js';\nimport { handleLocalDevDeposit } from './api/local-payments.js';\n\nimport {\n  handleUploadContent,\n  handleGetContent,\n  handleCheckContentExists,\n  handleExtendContent,\n  handleDownloadContent\n} from './api/content.js';\n\nimport { \n  handlePurchaseRateLimit, \n  handleGetRateLimit \n} from './api/rate-limit.js';\n\nimport { handleGetUserUploads } from './api/user.js';\n\nimport {\n  handleCreateSupplier,\n  handleListSuppliers,\n  handleGetSupplier,\n  handleDeleteSupplier,\n  handleUpdateSupplier,\n  handleRescanSupplier,\n  handleGetCIDSuppliers\n} from './api/suppliers.js';\n\nimport {\n  handleGetStats,\n  handleGetFinancialStats,\n  handleGetContentStats,\n  handleGetUserStats,\n  handleGetAdminHealth,\n  handleGetAlerts,\n  handleAcknowledgeAlert,\n  handleGetAuditLog,\n  handleExportData,\n  handleGetCosts,\n  handleGetCostsByService,\n  handleGetProfitability,\n  handleRecordCost\n} from './api/admin.js';\n\nimport {\n  handleListDeletions,\n  handleGetDeletion,\n  handleGetDeletionStats\n} from './api/public-deletions.js';\n\nimport {\n  handleCreateDispute,\n  handleListDisputes,\n  handleGetDispute,\n  handleGetContentDisputes\n} from './api/disputes.js';\n\nimport { \n  handleDeleteContent, \n  handleAdminDeleteContent \n} from './api/content-deletion.js';\n\nimport {\n  handleAdminListDisputes,\n  handleAdminUpdateDispute,\n  handleGetAdminActions\n} from './api/admin-disputes.js';\n\nimport { deleteContent, getContentMetadata } from './services/content-deletion.js';\nimport { getContentDomain } from './utils/content-domain.js';\nimport {\n  handleCreateDeveloperApp,\n  handleListDeveloperApps,\n  handleGetOAuthAuthorizePage,\n  handleOAuthAuthorize,\n  handleOAuthToken,\n  handleOAuthRevoke,\n  handleGetAccountSettings,\n  handleUpdateAccountSettings,\n  handleListAuthorizations,\n  handleRevokeAuthorization\n} from './api/oauth.js';\n\nimport { applyRateLimit, authenticate } from './auth/middleware.js';\nimport { handleOAuthCorsPreflight, withOAuthCors } from './auth/oauth-cors.js';\n\n// Configuration constants\nconst VALID_ENVIRONMENTS = ['development', 'production', 'local'];\nconst VALID_LOG_LEVELS = ['debug', 'info', 'warn', 'error'];\nconst HEALTH_CHECK_ID = 'health-check';\nconst GIT_SHA_PLACEHOLDER = 'unknown';\n\n// Anomaly detection thresholds\nconst ANOMALY_DETECTION = {\n  DEPOSIT_VELOCITY_MULTIPLIER: 5,    // Alert if deposits > 5x normal rate\n  DEPOSIT_VELOCITY_ABSOLUTE: 20,     // Alert if deposits > 20 per hour\n  UPLOAD_VELOCITY_MULTIPLIER: 5,     // Alert if uploads > 5x normal rate\n  UPLOAD_VELOCITY_ABSOLUTE: 100      // Alert if uploads > 100 per hour\n};\n\n// Git SHA embedded at build time - replaced during deployment\n// This constant forces code changes on each deploy, ensuring wrangler uploads new code\nconst BUNDLED_GIT_SHA = '__DEPLOY_GIT_SHA__';\n\n/**\n * Main Worker fetch handler\n */\nexport default {\n  async fetch(request, env, _ctx) {\n    const url = new URL(request.url);\n\n    if (request.method === 'OPTIONS' && (url.pathname.startsWith('/api/') || url.pathname.startsWith('/oauth/'))) {\n      return handleOAuthCorsPreflight(request, env);\n    }\n\n    // Stripe webhook endpoint (no rate limiting or auth required)\n    // Verified by Stripe signature instead\n    if (url.pathname === '/api/payments/webhook' && request.method === 'POST') {\n      return handleStripeWebhook(request, env);\n    }\n\n    // Apply rate limiting to all other requests\n    const authResult = await authenticate(request, env);\n    const rateLimitError = applyRateLimit(request, authResult);\n    if (rateLimitError) return rateLimitError;\n\n    // API and OAuth routes\n    if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/oauth/') || url.pathname === '/health') {\n      // Handle API routes (existing logic below)\n      const response = await handleApiRoutes(url, request, env);\n      return withOAuthCors(request, env, response);\n    }\n\n    // Info page route: /info/{cid}\n    const infoMatch = url.pathname.match(/^\\/info\\/([A-Za-z0-9_-]{8,94})$/);\n    if (infoMatch && request.method === 'GET') {\n      const cid = infoMatch[1];\n      // Redirect to info.html with CID as query parameter\n      const redirectUrl = new URL('/info.html', url);\n      redirectUrl.searchParams.set('cid', cid);\n      return Response.redirect(redirectUrl.toString(), 302);\n    }\n\n    const pathWithoutLeadingSlash = url.pathname.substring(1);\n\n    if (env.ENVIRONMENT === 'local' && pathWithoutLeadingSlash) {\n      const cidMatch = pathWithoutLeadingSlash.match(/^([A-Za-z0-9_-]{8,94})(?:\\.([a-zA-Z0-9]+))?$/);\n      if (cidMatch && (request.method === 'GET' || request.method === 'HEAD')) {\n        return handleDownloadContent(request, env, cidMatch[1], cidMatch[2] || null);\n      }\n    }\n\n    // If path contains invalid characters and isn't a static asset or CID, return 404\n    if (pathWithoutLeadingSlash && /[^A-Za-z0-9._/-]/.test(pathWithoutLeadingSlash)) {\n      return new Response(\n        JSON.stringify({\n          error: 'Invalid path',\n          message: 'The requested path is invalid'\n        }),\n        {\n          status: 404,\n          headers: { 'content-type': 'application/json' }\n        }\n      );\n    }\n\n    // Try to serve static assets for non-API paths\n    if (env.ASSETS) {\n      try {\n        // Special handling for /dashboard/uploads/{cid}/ -> serve detail.html\n        if (url.pathname.match(/^\\/dashboard\\/uploads\\/[^/]+\\/?$/)) {\n          const detailRequest = new Request(new URL('/dashboard/uploads/detail.html', url), request);\n          const detailAsset = await env.ASSETS.fetch(detailRequest);\n          if (detailAsset.status !== 404) {\n            return withGitShaComment(detailAsset, env);\n          }\n        }\n        \n        // Special handling for /dashboard/suppliers/{supplier_id} -> serve detail.html\n        if (url.pathname.match(/^\\/dashboard\\/suppliers\\/[^/]+\\/?$/)) {\n          const detailRequest = new Request(new URL('/dashboard/suppliers/detail.html', url), request);\n          const detailAsset = await env.ASSETS.fetch(detailRequest);\n          if (detailAsset.status !== 404) {\n            return withGitShaComment(detailAsset, env);\n          }\n        }\n\n        // Special handling for /developers -> serve developers/index.html\n        if (url.pathname === '/developers' || url.pathname === '/developers/') {\n          const developersRequest = new Request(new URL('/developers/index.html', url), request);\n          const developersAsset = await env.ASSETS.fetch(developersRequest);\n          if (developersAsset.status !== 404) {\n            return withGitShaComment(developersAsset, env);\n          }\n        }\n        \n        // Serve static files\n        const asset = await env.ASSETS.fetch(request);\n        \n        // If asset found, return it\n        if (asset.status !== 404) {\n          return withGitShaComment(asset, env);\n        }\n        \n        // If requesting a path without extension, try index.html\n        if (!url.pathname.includes('.')) {\n          const indexRequest = new Request(new URL('/index.html', url), request);\n          const indexAsset = await env.ASSETS.fetch(indexRequest);\n          if (indexAsset.status !== 404) {\n            return withGitShaComment(indexAsset, env);\n          }\n        }\n      } catch (error) {\n        console.error('Asset serving error:', error);\n      }\n    }\n\n    // If no static asset found, return 404\n    return new Response('Not Found', { status: 404 });\n  },\n\n  /**\n   * Scheduled handler for cron jobs\n   * Runs daily at 2 AM UTC for:\n   * - Content expiration processing\n   * - Platform statistics snapshot computation\n   * - Audit log cleanup (1-year retention)\n   * - Anomaly detection\n   */\n  async scheduled(event, env, _ctx) {\n    try {\n      console.log('Scheduled job executed:', new Date().toISOString());\n\n      // 1. Process expired content\n      await this.processExpiredContent(env);\n\n      // 2. Compute platform statistics snapshot\n      await this.computePlatformSnapshot(env);\n\n      // 3. Clean up old audit log entries (older than 1 year)\n      await this.cleanupAuditLog(env);\n\n      // 4. Run anomaly detection\n      await this.runAnomalyDetection(env);\n\n      // 5. Process expired disputes\n      await this.processExpiredDisputes(env);\n\n      // 6. Cleanup R2 objects pending deletion\n      await this.cleanupR2PendingDeletion(env);\n\n      console.log('Scheduled tasks completed');\n    } catch (error) {\n      console.error('Scheduled job error:', error);\n    }\n  },\n\n  /**\n   * Process expired content\n   * \n   * Batch processes content that has expired for the current date.\n   * Implements \"extension wins\" strategy: validates metadata before deletion\n   * to ensure content wasn't extended after being indexed for expiration.\n   * \n   * Batch Limit: Maximum 5,000 deletions per run (Cloudflare Workers 30s CPU limit)\n   * Strategy: Check metadata expires_at before deletion; skip if extended\n   * \n   * @param {Object} env - Environment bindings\n   */\n  async processExpiredContent(env) {\n    try {\n      console.log('Processing expired content...');\n      \n      const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD\n      const expirationIndexId = env.EXPIRATION_INDEX.idFromName('global');\n      const expirationIndexStub = env.EXPIRATION_INDEX.get(expirationIndexId);\n      \n      // Get expired content for today\n      const expiredResponse = await expirationIndexStub.fetch(\n        new Request(`https://dummy/expired?date=${today}`, { method: 'GET' })\n      );\n      const expiredData = await expiredResponse.json();\n      const expiredHashes = expiredData.hashes || [];\n      \n      console.log(`Found ${expiredHashes.length} expired content items for ${today}`);\n      \n      let deleted = 0;\n      let skipped = 0;\n      let failed = 0;\n      const BATCH_LIMIT = 5000;\n      \n      // Process up to BATCH_LIMIT items\n      for (const hash of expiredHashes.slice(0, BATCH_LIMIT)) {\n        try {\n          // Get current metadata to check for extension (\"extension wins\" strategy)\n          const metadata = await getContentMetadata(env, hash);\n          \n          if (!metadata) {\n            // Already deleted\n            skipped++;\n            continue;\n          }\n          \n          const expiresDate = metadata.expires_at.split('T')[0];\n          \n          // Check if content was extended after being indexed for expiration\n          if (expiresDate > today) {\n            console.log(`Skipping ${hash}: extended to ${expiresDate}`);\n            skipped++;\n            continue;\n          }\n          \n          // Use deletion service for consistent deletion logic\n          const result = await deleteContent(env, hash, metadata, 'expired');\n          \n          if (result.success) {\n            deleted++;\n          } else {\n            failed++;\n          }\n        } catch (error) {\n          failed++;\n          console.error(`Failed to delete ${hash}:`, error);\n        }\n      }\n      \n      console.log(`Expiration job complete: ${deleted} deleted, ${skipped} skipped, ${failed} failed`);\n    } catch (error) {\n      console.error('Error processing expired content:', error);\n    }\n  },\n\n  /**\n   * Compute platform statistics snapshot\n   */\n  async computePlatformSnapshot(env) {\n    try {\n      console.log('Computing platform statistics snapshot...');\n      \n      const statsId = env.PLATFORM_STATS.idFromName('global');\n      const statsStub = env.PLATFORM_STATS.get(statsId);\n      \n      await statsStub.fetch(new Request('https://dummy/snapshot', {\n        method: 'POST'\n      }));\n      \n      console.log('Platform statistics snapshot computed');\n    } catch (error) {\n      console.error('Error computing platform snapshot:', error);\n    }\n  },\n\n  /**\n   * Clean up old audit log entries (older than 1 year)\n   */\n  async cleanupAuditLog(env) {\n    try {\n      console.log('Cleaning up old audit log entries...');\n      \n      const auditLogId = env.AUDIT_LOG.idFromName('global');\n      const auditLogStub = env.AUDIT_LOG.get(auditLogId);\n      \n      const response = await auditLogStub.fetch(new Request('https://dummy/cleanup', {\n        method: 'POST'\n      }));\n      \n      const result = await response.json();\n      console.log(`Audit log cleanup complete. Removed ${result.deleted_count || 0} entries`);\n    } catch (error) {\n      console.error('Error cleaning up audit log:', error);\n    }\n  },\n\n  /**\n   * Run anomaly detection and create alerts\n   */\n  async runAnomalyDetection(env) {\n    try {\n      console.log('Running anomaly detection...');\n      \n      const statsId = env.PLATFORM_STATS.idFromName('global');\n      const statsStub = env.PLATFORM_STATS.get(statsId);\n      const alertStoreId = env.ALERT_STORE.idFromName('global');\n      const alertStoreStub = env.ALERT_STORE.get(alertStoreId);\n      \n      // Get current statistics\n      const statsResponse = await statsStub.fetch(new Request('https://dummy/stats?type=all'));\n      const stats = await statsResponse.json();\n      \n      // Check for anomalies and create alerts\n      \n      // 1. Check for high error rate (>5% in last 6 hours)\n      // This would need error tracking to be implemented\n      // Placeholder for now\n      \n      // 2. Check for unusual deposit velocity\n      const depositRate = stats.financial?.deposits_last_hour || 0;\n      const normalDepositRate = stats.financial?.avg_deposits_per_hour || 10;\n      if (depositRate > normalDepositRate * ANOMALY_DETECTION.DEPOSIT_VELOCITY_MULTIPLIER || \n          depositRate > ANOMALY_DETECTION.DEPOSIT_VELOCITY_ABSOLUTE) {\n        await alertStoreStub.fetch(new Request('https://dummy/create', {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify({\n            type: 'unusual_deposit_velocity',\n            severity: 'warning',\n            title: 'Unusual Deposit Activity Detected',\n            message: `Deposit rate (${depositRate}/hour) exceeds normal rate (${normalDepositRate}/hour)`,\n            metadata: {\n              current_rate: depositRate,\n              normal_rate: normalDepositRate,\n              threshold: normalDepositRate * ANOMALY_DETECTION.DEPOSIT_VELOCITY_MULTIPLIER\n            }\n          })\n        }));\n      }\n      \n      // 3. Check for unusual upload velocity\n      const uploadRate = stats.content?.uploads_last_hour || 0;\n      const normalUploadRate = stats.content?.avg_uploads_per_hour || 50;\n      if (uploadRate > normalUploadRate * ANOMALY_DETECTION.UPLOAD_VELOCITY_MULTIPLIER || \n          uploadRate > ANOMALY_DETECTION.UPLOAD_VELOCITY_ABSOLUTE) {\n        await alertStoreStub.fetch(new Request('https://dummy/create', {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify({\n            type: 'unusual_upload_velocity',\n            severity: 'warning',\n            title: 'Unusual Upload Activity Detected',\n            message: `Upload rate (${uploadRate}/hour) exceeds normal rate (${normalUploadRate}/hour)`,\n            metadata: {\n              current_rate: uploadRate,\n              normal_rate: normalUploadRate,\n              threshold: normalUploadRate * ANOMALY_DETECTION.UPLOAD_VELOCITY_MULTIPLIER\n            }\n          })\n        }));\n      }\n      \n      console.log('Anomaly detection complete');\n    } catch (error) {\n      console.error('Error running anomaly detection:', error);\n    }\n  },\n\n  /**\n   * Process expired disputes\n   * Updates disputes that are past their expires_at date to closed_expired\n   */\n  async processExpiredDisputes(env) {\n    try {\n      console.log('Processing expired disputes...');\n      \n      const indexId = env.DISPUTE_INDEX.idFromName('dispute-index:global');\n      const indexStub = env.DISPUTE_INDEX.get(indexId);\n      \n      // Get all open disputes\n      const listResponse = await indexStub.fetch(\n        new Request('http://internal/list?limit=1000')\n      );\n      const listData = await listResponse.json();\n      \n      const now = new Date();\n      let expired = 0;\n      \n      // Check each dispute for expiration\n      for (const dispute of listData.disputes || []) {\n        const expiresAt = new Date(dispute.expires_at);\n        \n        if (expiresAt <= now) {\n          try {\n            // Update dispute status to closed_expired\n            const disputeRecordId = env.DISPUTE_RECORD.idFromName(`dispute:${dispute.cid}`);\n            const disputeRecordStub = env.DISPUTE_RECORD.get(disputeRecordId);\n            \n            await disputeRecordStub.fetch(new Request('http://internal/dispute', {\n              method: 'PATCH',\n              headers: { 'Content-Type': 'application/json' },\n              body: JSON.stringify({\n                status: 'closed_expired',\n                resolution: 'expired',\n                resolution_reason: 'Dispute expired after 30 days without resolution',\n                resolved_by: 'system'\n              })\n            }));\n            \n            // Remove from DisputeIndex\n            await indexStub.fetch(new Request('http://internal/remove', {\n              method: 'POST',\n              headers: { 'Content-Type': 'application/json' },\n              body: JSON.stringify({\n                dispute_id: dispute.dispute_id\n              })\n            }));\n            \n            expired++;\n          } catch (error) {\n            console.error(`Error expiring dispute ${dispute.dispute_id}:`, error);\n          }\n        }\n      }\n      \n      console.log(`Expired disputes processed: ${expired} disputes closed`);\n    } catch (error) {\n      console.error('Error processing expired disputes:', error);\n    }\n  },\n\n  /**\n   * Cleanup R2 objects pending deletion\n   * Deletes R2 objects for content that has been soft-deleted for >24 hours\n   */\n  async cleanupR2PendingDeletion(env) {\n    try {\n      console.log('Cleaning up R2 pending deletion...');\n      \n      // Note: This requires an index or scanning mechanism to find content with pending_r2_deletion\n      // For this implementation, we document the requirements for production enhancement\n      \n      // Production Requirements:\n      // 1. Create a DeletionPendingIndex Durable Object to track soft-deleted content\n      // 2. When softDeleteContent() is called, add the CID to DeletionPendingIndex with timestamp\n      // 3. This scheduled job queries DeletionPendingIndex for entries > 24 hours old\n      // 4. For each eligible entry: delete from R2, call markR2Deleted(), remove from index\n      \n      // Issue: Without a global index, we cannot efficiently find ContentMetadata objects\n      // with pending_r2_deletion=true. This must be implemented before production use.\n      \n      // TODO(GitHub Issue): Implement DeletionPendingIndex for efficient R2 cleanup\n      // Until implemented, R2 cleanup must be performed manually or via separate tooling\n      \n      console.log('R2 cleanup: Requires DeletionPendingIndex implementation (see TODO in code)');\n      console.log('Manual workaround: Query ContentMetadata objects with pending_r2_deletion=true and deleted_at > 24h');\n\n      \n    } catch (error) {\n      console.error('Error cleaning up R2:', error);\n    }\n  },\n};\n\n/**\n * Handle API routes\n */\nfunction handleApiRoutes(url, request, env) {\n  // Basic routing\n  if (url.pathname === '/health') {\n    return handleHealth(env);\n  }\n\n  // Public config endpoint (serves non-secret configuration to frontend)\n  if (url.pathname === '/api/config' && request.method === 'GET') {\n    return handleConfig(env);\n  }\n\n  // Authentication API routes\n  if (url.pathname === '/api/auth/callback' && request.method === 'POST') {\n    return handleAuthCallback(request, env);\n  }\n\n  if (url.pathname === '/api/auth/session' && request.method === 'GET') {\n    return handleSessionInfo(request, env);\n  }\n\n  if (url.pathname === '/api/auth/logout' && request.method === 'POST') {\n    return handleLogout(request, env);\n  }\n\n  if (url.pathname === '/api/auth/link' && request.method === 'POST') {\n    return handleLinkProvider(request, env);\n  }\n\n  if (url.pathname === '/api/auth/apikeys' && request.method === 'POST') {\n    return handleCreateApiKey(request, env);\n  }\n\n  if (url.pathname === '/api/auth/apikeys' && request.method === 'GET') {\n    return handleListApiKeys(request, env);\n  }\n\n  if (url.pathname.startsWith('/api/auth/apikeys/') && request.method === 'DELETE') {\n    const keyId = url.pathname.split('/')[4];\n    return handleRevokeApiKey(request, env, keyId);\n  }\n\n  if (url.pathname.startsWith('/api/auth/apikeys/') && url.pathname.endsWith('/reveal') && request.method === 'POST') {\n    const keyId = url.pathname.split('/')[4];\n    return handleRevealApiKey(request, env, keyId);\n  }\n\n  if (url.pathname.startsWith('/api/auth/apikeys/') && request.method === 'PATCH') {\n    const parts = url.pathname.split('/');\n    // Ensure exact pattern: /api/auth/apikeys/{keyId} (5 parts)\n    if (parts.length === 5 && parts[4]) {\n      const keyId = parts[4];\n      return handleUpdateApiKey(request, env, keyId);\n    }\n  }\n\n  if (url.pathname === '/api/auth/account' && request.method === 'DELETE') {\n    return handleDeleteAccount(request, env);\n  }\n\n  if (url.pathname === '/api/developers/apps' && request.method === 'POST') {\n    return handleCreateDeveloperApp(request, env);\n  }\n\n  if (url.pathname === '/api/developers/apps' && request.method === 'GET') {\n    return handleListDeveloperApps(request, env);\n  }\n\n  if (url.pathname === '/oauth/authorize' && request.method === 'POST') {\n    return handleOAuthAuthorize(request, env);\n  }\n\n  if (url.pathname === '/oauth/authorize' && request.method === 'GET') {\n    return handleGetOAuthAuthorizePage(request, env);\n  }\n\n  if (url.pathname === '/oauth/token' && request.method === 'POST') {\n    return handleOAuthToken(request, env);\n  }\n\n  if (url.pathname === '/oauth/revoke' && request.method === 'POST') {\n    return handleOAuthRevoke(request, env);\n  }\n\n  if (url.pathname === '/api/account/settings' && request.method === 'GET') {\n    return handleGetAccountSettings(request, env);\n  }\n\n  if (url.pathname === '/api/account/settings' && request.method === 'PATCH') {\n    return handleUpdateAccountSettings(request, env);\n  }\n\n  if (url.pathname === '/api/account/authorizations' && request.method === 'GET') {\n    return handleListAuthorizations(request, env);\n  }\n\n  if (url.pathname.match(/^\\/api\\/account\\/authorizations\\/[^\\/]+$/) && request.method === 'DELETE') {\n    const appId = url.pathname.split('/')[4];\n    return handleRevokeAuthorization(request, env, appId);\n  }\n\n  // Balance API routes\n  if (url.pathname === '/api/balance' && request.method === 'GET') {\n    return handleGetBalance(request, env);\n  }\n\n  if (url.pathname === '/api/balance/history' && request.method === 'GET') {\n    return handleGetBalanceHistory(request, env);\n  }\n\n  if (url.pathname === '/api/balance/deposit' && request.method === 'POST') {\n    return handleCreateDeposit(request, env);\n  }\n\n  if (url.pathname === '/api/balance/dev-deposit' && request.method === 'POST') {\n    return handleLocalDevDeposit(request, env);\n  }\n\n  // Payment calculation endpoint (public)\n  if (url.pathname === '/api/payments/calculate' && request.method === 'POST') {\n    return handleCalculateRetention(request, env);\n  }\n\n  // Content API routes\n  if (url.pathname === '/api/content' && request.method === 'POST') {\n    return handleUploadContent(request, env);\n  }\n\n  if (url.pathname.startsWith('/api/content/') && request.method === 'GET') {\n    const parts = url.pathname.split('/');\n    const cid = parts[3];\n    const action = parts[4];\n\n    if (!action) {\n      return handleGetContent(request, env, cid);\n    } else if (action === 'exists') {\n      return handleCheckContentExists(request, env, cid);\n    }\n  }\n\n  if (url.pathname.startsWith('/api/content/') && url.pathname.endsWith('/extend') && request.method === 'POST') {\n    const cid = url.pathname.split('/')[3];\n    return handleExtendContent(request, env, cid);\n  }\n\n  // Rate limit API routes\n  if (url.pathname === '/api/content/rate-limit/purchase' && request.method === 'POST') {\n    return handlePurchaseRateLimit(request, env);\n  }\n\n  if (url.pathname.startsWith('/api/content/') && url.pathname.endsWith('/rate-limit') && request.method === 'GET') {\n    const cid = url.pathname.split('/')[3];\n    return handleGetRateLimit(request, env, cid);\n  }\n\n  // Donation API route (public - no auth required)\n  if (url.pathname.startsWith('/api/donate/cid/') && request.method === 'POST') {\n    const cid = url.pathname.split('/')[4];\n    return handleCreateDonation(request, env, cid);\n  }\n\n  // User API routes\n  if (url.pathname === '/api/user/uploads' && request.method === 'GET') {\n    return handleGetUserUploads(request, env);\n  }\n\n  // Dispute API routes\n  if (url.pathname === '/api/disputes' && request.method === 'POST') {\n    return handleCreateDispute(request, env);\n  }\n\n  if (url.pathname === '/api/disputes' && request.method === 'GET') {\n    return handleListDisputes(request, env);\n  }\n\n  if (url.pathname.match(/^\\/api\\/disputes\\/[^\\/]+$/) && request.method === 'GET') {\n    const disputeId = url.pathname.split('/')[3];\n    // Get user ID from request if authenticated\n    const userId = request.user?.userId || null;\n    return handleGetDispute(request, env, disputeId, userId);\n  }\n\n  if (url.pathname.match(/^\\/api\\/content\\/[^\\/]+\\/disputes$/) && request.method === 'GET') {\n    const cid = url.pathname.split('/')[3];\n    // Get user ID from request if authenticated\n    const userId = request.user?.userId || null;\n    return handleGetContentDisputes(request, env, cid, userId);\n  }\n\n  // Content deletion API routes\n  if (url.pathname.match(/^\\/api\\/content\\/[^\\/]+$/) && request.method === 'DELETE') {\n    const cid = url.pathname.split('/')[3];\n    return handleDeleteContent(request, env, cid);\n  }\n\n  // Admin API routes\n  if (url.pathname === '/api/admin/disputes' && request.method === 'GET') {\n    return handleAdminListDisputes(request, env);\n  }\n\n  if (url.pathname.match(/^\\/api\\/admin\\/disputes\\/[^\\/]+$/) && request.method === 'PATCH') {\n    const cid = url.pathname.split('/')[4];\n    return handleAdminUpdateDispute(request, env, cid);\n  }\n\n  if (url.pathname.match(/^\\/api\\/admin\\/content\\/[^\\/]+\\/delete$/) && request.method === 'POST') {\n    const cid = url.pathname.split('/')[4];\n    return handleAdminDeleteContent(request, env, cid);\n  }\n\n  if (url.pathname === '/api/admin/actions' && request.method === 'GET') {\n    return handleGetAdminActions(request, env);\n  }\n\n  // Supplier API routes\n  if (url.pathname === '/api/suppliers' && request.method === 'POST') {\n    return handleCreateSupplier(request, env);\n  }\n\n  if (url.pathname === '/api/suppliers' && request.method === 'GET') {\n    return handleListSuppliers(request, env);\n  }\n\n  if (url.pathname.match(/^\\/api\\/suppliers\\/[^\\/]+$/) && request.method === 'GET') {\n    const supplierId = url.pathname.split('/')[3];\n    return handleGetSupplier(request, env, supplierId);\n  }\n\n  if (url.pathname.match(/^\\/api\\/suppliers\\/[^\\/]+$/) && request.method === 'DELETE') {\n    const supplierId = url.pathname.split('/')[3];\n    return handleDeleteSupplier(request, env, supplierId);\n  }\n\n  if (url.pathname.match(/^\\/api\\/suppliers\\/[^\\/]+$/) && request.method === 'PATCH') {\n    const supplierId = url.pathname.split('/')[3];\n    return handleUpdateSupplier(request, env, supplierId);\n  }\n\n  if (url.pathname.match(/^\\/api\\/suppliers\\/[^\\/]+\\/scan$/) && request.method === 'POST') {\n    const supplierId = url.pathname.split('/')[3];\n    return handleRescanSupplier(request, env, supplierId);\n  }\n\n  if (url.pathname.match(/^\\/api\\/content\\/[^\\/]+\\/suppliers$/) && request.method === 'GET') {\n    const cid = url.pathname.split('/')[3];\n    return handleGetCIDSuppliers(request, env, cid);\n  }\n\n  // Admin API routes (no rate limiting or auth - admin token checked in handlers)\n  if (url.pathname === '/api/admin/stats' && request.method === 'GET') {\n    return handleGetStats(request, env);\n  }\n\n  if (url.pathname === '/api/admin/stats/financial' && request.method === 'GET') {\n    return handleGetFinancialStats(request, env);\n  }\n\n  if (url.pathname === '/api/admin/stats/content' && request.method === 'GET') {\n    return handleGetContentStats(request, env);\n  }\n\n  if (url.pathname === '/api/admin/stats/users' && request.method === 'GET') {\n    return handleGetUserStats(request, env);\n  }\n\n  if (url.pathname === '/api/admin/health' && request.method === 'GET') {\n    return handleGetAdminHealth(request, env);\n  }\n\n  if (url.pathname === '/api/admin/alerts' && request.method === 'GET') {\n    return handleGetAlerts(request, env);\n  }\n\n  if (url.pathname.match(/^\\/api\\/admin\\/alerts\\/[^\\/]+\\/acknowledge$/) && request.method === 'POST') {\n    const alertId = url.pathname.split('/')[4];\n    return handleAcknowledgeAlert(request, env, alertId);\n  }\n\n  if (url.pathname === '/api/admin/audit-log' && request.method === 'GET') {\n    return handleGetAuditLog(request, env);\n  }\n\n  if (url.pathname === '/api/admin/export' && request.method === 'GET') {\n    return handleExportData(request, env);\n  }\n\n  // Admin cost and profitability endpoints\n  if (url.pathname === '/api/admin/costs' && request.method === 'GET') {\n    return handleGetCosts(request, env);\n  }\n\n  if (url.pathname === '/api/admin/costs/by-service' && request.method === 'GET') {\n    return handleGetCostsByService(request, env);\n  }\n\n  if (url.pathname === '/api/admin/costs/record' && request.method === 'POST') {\n    return handleRecordCost(request, env);\n  }\n\n  if (url.pathname === '/api/admin/profitability' && request.method === 'GET') {\n    return handleGetProfitability(request, env);\n  }\n\n  // Public deletion records API routes (no auth required for transparency)\n  if (url.pathname === '/api/public/deletions/stats' && request.method === 'GET') {\n    return handleGetDeletionStats(request, env);\n  }\n\n  if (url.pathname.match(/^\\/api\\/public\\/deletions\\/[^\\/]+$/) && request.method === 'GET') {\n    const hash = url.pathname.split('/')[4];\n    return handleGetDeletion(request, env, hash);\n  }\n\n  if (url.pathname === '/api/public/deletions' && request.method === 'GET') {\n    return handleListDeletions(request, env);\n  }\n\n  // TODO: Add API routes for:\n  // - Contests\n\n  return new Response(\n    JSON.stringify({\n      error: 'Not Found',\n      message: 'API endpoint not found'\n    }),\n    {\n      status: 404,\n      headers: { 'content-type': 'application/json' }\n    }\n  );\n}\n\nfunction injectGitShaIntoHtml(html, gitSha) {\n  const comment = `<!-- git-sha: ${gitSha} -->`;\n  const commentRegex = /<!--\\s*git-sha:[^>]*-->/i;\n\n  if (commentRegex.test(html)) {\n    return html.replace(commentRegex, comment);\n  }\n\n  const doctypeMatch = html.match(/<!doctype[^>]*>/i);\n  if (doctypeMatch) {\n    return html.replace(doctypeMatch[0], `${doctypeMatch[0]}\\n${comment}`);\n  }\n\n  return `${comment}\\n${html}`;\n}\n\nasync function withGitShaComment(response, env) {\n  const contentType = response.headers.get('content-type') || '';\n  if (!contentType.includes('text/html')) {\n    return response;\n  }\n\n  // Prefer bundled SHA (embedded at build time), fall back to secret, then placeholder\n  const bundledShaIsValid = BUNDLED_GIT_SHA && BUNDLED_GIT_SHA !== '__DEPLOY_GIT_SHA__';\n  const gitSha = bundledShaIsValid ? BUNDLED_GIT_SHA : (env.GIT_SHA || GIT_SHA_PLACEHOLDER);\n  const html = await response.text();\n  const updatedHtml = injectGitShaIntoHtml(html, gitSha);\n  const headers = new Headers(response.headers);\n  headers.delete('content-length');\n  headers.set('x-git-sha', gitSha);\n  headers.set('x-git-sha-present', String(bundledShaIsValid || Boolean(env.GIT_SHA)));\n\n  return new Response(updatedHtml, {\n    status: response.status,\n    statusText: response.statusText,\n    headers\n  });\n}\n\n/**\n * Root endpoint - Basic info\n */\nfunction handleRoot(env) {\n  const info = {\n    service: 'HashBin.org API',\n    version: '0.1.0',\n    environment: env.ENVIRONMENT || 'unknown',\n    status: 'operational',\n    phase: 'Phase 1 - Infrastructure Setup',\n    endpoints: {\n      health: '/health',\n      // More endpoints will be added in future phases\n    }\n  };\n\n  return new Response(JSON.stringify(info, null, 2), {\n    headers: {\n      'content-type': 'application/json',\n      'access-control-allow-origin': '*'\n    }\n  });\n}\n\n/**\n * Public configuration endpoint\n * Returns non-secret configuration needed by the frontend\n */\nfunction handleConfig(env) {\n  const isLocalMode = env.ENVIRONMENT === 'local';\n  const config = {\n    clerkPublishableKey: isLocalMode ? null : (env.CLERK_PUBLISHABLE_KEY || null),\n    isLocalMode,\n    authMode: isLocalMode ? 'local' : 'clerk',\n    content_domain: getContentDomain(env)\n  };\n\n  return new Response(JSON.stringify(config), {\n    status: 200,\n    headers: {\n      'Content-Type': 'application/json',\n      'Cache-Control': 'public, max-age=300' // Cache for 5 minutes\n    }\n  });\n}\n\n/**\n * Health check endpoint with comprehensive validation\n */\nasync function handleHealth(env) {\n  const checks = {\n    worker: await checkWorker(env),\n    environment: await checkEnvironment(env),\n    durableObjects: await checkDurableObjects(env),\n    r2: await checkR2Buckets(env),\n    clerk: await checkClerk(env),\n    stripe: await checkStripe(env)\n  };\n\n  // Determine overall status\n  const allOperational = Object.values(checks).every(check => check.status === 'operational');\n  const anyDegraded = Object.values(checks).some(check => check.status === 'degraded');\n  const anyDown = Object.values(checks).some(check => check.status === 'down');\n\n  let overallStatus = 'healthy';\n  if (anyDown) {\n    overallStatus = 'unhealthy';\n  } else if (anyDegraded) {\n    overallStatus = 'degraded';\n  }\n\n  // Create alerts for degraded or unhealthy components\n  try {\n    if (anyDown || anyDegraded) {\n      const alertStoreId = env.ALERT_STORE.idFromName('global');\n      const alertStoreStub = env.ALERT_STORE.get(alertStoreId);\n      \n      for (const [componentName, check] of Object.entries(checks)) {\n        if (check.status === 'down') {\n          await alertStoreStub.fetch(new Request('https://dummy/create', {\n            method: 'POST',\n            headers: { 'Content-Type': 'application/json' },\n            body: JSON.stringify({\n              type: 'health_unhealthy',\n              severity: 'critical',\n              title: `Component Down: ${componentName}`,\n              message: `${componentName} health check failed: ${check.error || 'Unknown error'}`,\n              metadata: {\n                component: componentName,\n                error: check.error || null,\n                timestamp: new Date().toISOString()\n              }\n            })\n          }));\n        } else if (check.status === 'degraded') {\n          await alertStoreStub.fetch(new Request('https://dummy/create', {\n            method: 'POST',\n            headers: { 'Content-Type': 'application/json' },\n            body: JSON.stringify({\n              type: 'health_degraded',\n              severity: 'warning',\n              title: `Component Degraded: ${componentName}`,\n              message: `${componentName} health check shows degraded performance`,\n              metadata: {\n                component: componentName,\n                timestamp: new Date().toISOString()\n              }\n            })\n          }));\n        }\n      }\n      \n      // Auto-resolve alerts if all components are now healthy\n      if (allOperational && !anyDown && !anyDegraded) {\n        // Resolve health_degraded and health_unhealthy alerts\n        const alertsResponse = await alertStoreStub.fetch(new Request('https://dummy/list'));\n        const alertsData = await alertsResponse.json();\n        \n        for (const alert of alertsData.alerts || []) {\n          if ((alert.type === 'health_degraded' || alert.type === 'health_unhealthy') && !alert.resolved_at) {\n            await alertStoreStub.fetch(new Request(`https://dummy/resolve/${alert.id}`, {\n              method: 'POST'\n            }));\n          }\n        }\n      }\n    }\n  } catch (alertError) {\n    console.error('Error creating health alerts:', alertError);\n    // Don't fail the health check if alert creation fails\n  }\n\n  // Determine git SHA: prefer bundled value (embedded at build time), fall back to secret, then placeholder\n  const bundledShaIsValid = BUNDLED_GIT_SHA && BUNDLED_GIT_SHA !== '__DEPLOY_GIT_SHA__';\n  const effectiveGitSha = bundledShaIsValid ? BUNDLED_GIT_SHA : (env.GIT_SHA || GIT_SHA_PLACEHOLDER);\n\n  if (!bundledShaIsValid && !env.GIT_SHA) {\n    console.warn('Health check: Neither BUNDLED_GIT_SHA nor GIT_SHA secret is configured.');\n  }\n\n  const health = {\n    status: overallStatus,\n    timestamp: new Date().toISOString(),\n    environment: env.ENVIRONMENT || 'unknown',\n    gitSha: effectiveGitSha,\n    gitShaPresent: bundledShaIsValid || Boolean(env.GIT_SHA),\n    checks: checks,\n    summary: {\n      total: Object.keys(checks).length,\n      operational: Object.values(checks).filter(c => c.status === 'operational').length,\n      degraded: Object.values(checks).filter(c => c.status === 'degraded').length,\n      down: Object.values(checks).filter(c => c.status === 'down').length\n    }\n  };\n\n  const statusCode = overallStatus === 'unhealthy' ? 503 : 200;\n\n  return new Response(JSON.stringify(health, null, 2), {\n    status: statusCode,\n    headers: {\n      'content-type': 'application/json',\n      'access-control-allow-origin': '*',\n      'x-git-sha': effectiveGitSha,\n      'x-git-sha-present': String(bundledShaIsValid || Boolean(env.GIT_SHA))\n    }\n  });\n}\n\n/**\n * Check worker configuration and basic functionality\n */\nasync function checkWorker(env) {\n  try {\n    return {\n      status: 'operational',\n      message: 'Worker is responding',\n      details: {\n        hasEnvironment: !!env.ENVIRONMENT,\n        hasLogLevel: !!env.LOG_LEVEL\n      }\n    };\n  } catch (error) {\n    return {\n      status: 'down',\n      message: 'Worker check failed',\n      error: error.message\n    };\n  }\n}\n\n/**\n * Check environment configuration\n */\nasync function checkEnvironment(env) {\n  try {\n    const envName = env.ENVIRONMENT || 'unknown';\n    const logLevel = env.LOG_LEVEL || 'unknown';\n    \n    const envValid = VALID_ENVIRONMENTS.includes(envName);\n    const logLevelValid = VALID_LOG_LEVELS.includes(logLevel);\n    const oauthSigningKeyConfigured = envName === 'local' ? true : !!env.OAUTH_SIGNING_KEY;\n    \n    const allValid = envValid && logLevelValid && oauthSigningKeyConfigured;\n    \n    return {\n      status: allValid ? 'operational' : 'degraded',\n      message: allValid ? 'Environment configuration valid' : 'Environment configuration issues detected',\n      details: {\n        environment: envName,\n        environmentValid: envValid,\n        logLevel: logLevel,\n        logLevelValid: logLevelValid,\n        oauthSigningKeyConfigured\n      }\n    };\n  } catch (error) {\n    return {\n      status: 'down',\n      message: 'Environment check failed',\n      error: error.message\n    };\n  }\n}\n\n/**\n * Check Durable Objects bindings and accessibility\n */\nasync function checkDurableObjects(env) {\n  const doTypes = [\n    { name: 'CONTENT_METADATA', binding: env.CONTENT_METADATA },\n    { name: 'USER_PROFILES', binding: env.USER_PROFILES },\n    { name: 'PAYMENT_RECORDS', binding: env.PAYMENT_RECORDS },\n    { name: 'CONTEST_RECORDS', binding: env.CONTEST_RECORDS },\n    { name: 'MESSAGE_THREADS', binding: env.MESSAGE_THREADS },\n    { name: 'KEY_REGISTRY', binding: env.KEY_REGISTRY }\n  ];\n\n  const results = {};\n  let allOperational = true;\n  let anyAccessible = false;\n\n  for (const doType of doTypes) {\n    try {\n      if (!doType.binding) {\n        results[doType.name] = {\n          available: false,\n          accessible: false,\n          error: 'Binding not found'\n        };\n        allOperational = false;\n      } else {\n        // Try to get an ID and stub - this validates the binding works\n        const id = doType.binding.idFromName(HEALTH_CHECK_ID);\n        const stub = doType.binding.get(id);\n        results[doType.name] = {\n          available: true,\n          accessible: !!stub,\n          error: null\n        };\n        anyAccessible = true;\n      }\n    } catch (error) {\n      results[doType.name] = {\n        available: !!doType.binding,\n        accessible: false,\n        error: error.message\n      };\n      allOperational = false;\n    }\n  }\n\n  return {\n    status: allOperational ? 'operational' : (anyAccessible ? 'degraded' : 'down'),\n    message: allOperational ? 'All Durable Objects accessible' : 'Some Durable Objects unavailable',\n    details: results\n  };\n}\n\n/**\n * Check R2 bucket accessibility\n */\nasync function checkR2Buckets(env) {\n  const buckets = [\n    { name: 'CONTENT_BUCKET', binding: env.CONTENT_BUCKET },\n    { name: 'BACKUP_BUCKET', binding: env.BACKUP_BUCKET }\n  ];\n\n  const results = {};\n  let allOperational = true;\n  let anyAccessible = false;\n\n  for (const bucket of buckets) {\n    try {\n      if (!bucket.binding) {\n        results[bucket.name] = {\n          available: false,\n          accessible: false,\n          error: 'Binding not found'\n        };\n        allOperational = false;\n      } else {\n        // Try to list with limit 1 to verify bucket is accessible\n        await bucket.binding.list({ limit: 1 });\n        results[bucket.name] = {\n          available: true,\n          accessible: true,\n          error: null\n        };\n        anyAccessible = true;\n      }\n    } catch (error) {\n      results[bucket.name] = {\n        available: !!bucket.binding,\n        accessible: false,\n        error: error.message\n      };\n      allOperational = false;\n    }\n  }\n\n  return {\n    status: allOperational ? 'operational' : (anyAccessible ? 'degraded' : 'down'),\n    message: allOperational ? 'All R2 buckets accessible' : 'Some R2 buckets unavailable',\n    details: results\n  };\n}\n\n/**\n * Check Clerk integration health\n */\nasync function checkClerk(env) {\n  if (env.ENVIRONMENT === 'local') {\n    return {\n      status: 'operational',\n      message: 'Clerk disabled in local mode',\n      details: {\n        secretKeyConfigured: false,\n        publishableKeyConfigured: false\n      }\n    };\n  }\n\n  const checks = {\n    secretKeyConfigured: false,\n    publishableKeyConfigured: false,\n    usingTestKeysInProduction: false\n  };\n\n  try {\n    // Check required secrets are configured\n    checks.secretKeyConfigured = !!env.CLERK_SECRET_KEY;\n    checks.publishableKeyConfigured = !!env.CLERK_PUBLISHABLE_KEY;\n    checks.usingTestKeysInProduction = env.ENVIRONMENT === 'production' && (\n      (env.CLERK_SECRET_KEY || '').startsWith('sk_test_') ||\n      (env.CLERK_PUBLISHABLE_KEY || '').startsWith('pk_test_')\n    );\n\n    const allConfigured = checks.secretKeyConfigured &&\n                          checks.publishableKeyConfigured;\n    const someConfigured = checks.secretKeyConfigured ||\n                           checks.publishableKeyConfigured;\n    const healthy = allConfigured && !checks.usingTestKeysInProduction;\n\n    return {\n      status: healthy ? 'operational' : (someConfigured ? 'degraded' : 'down'),\n      message: healthy\n        ? 'Clerk secrets configured'\n        : (checks.usingTestKeysInProduction\n            ? 'Clerk is using test keys in production'\n            : (someConfigured ? 'Some Clerk secrets missing' : 'Clerk not configured')),\n      details: checks\n    };\n  } catch (error) {\n    return {\n      status: 'down',\n      message: 'Clerk check failed',\n      error: error.message,\n      details: checks\n    };\n  }\n}\n\n/**\n * Check Stripe integration health\n */\nasync function checkStripe(env) {\n  if (env.ENVIRONMENT === 'local') {\n    return {\n      status: 'operational',\n      message: 'Stripe disabled in local mode',\n      details: {\n        secretKeyConfigured: false,\n        webhookSecretConfigured: false\n      }\n    };\n  }\n\n  const checks = {\n    secretKeyConfigured: false,\n    webhookSecretConfigured: false\n  };\n\n  try {\n    // Check required secrets are configured\n    checks.secretKeyConfigured = !!env.STRIPE_SECRET_KEY;\n    checks.webhookSecretConfigured = !!env.STRIPE_WEBHOOK_SECRET;\n\n    const allConfigured = checks.secretKeyConfigured &&\n                          checks.webhookSecretConfigured;\n    const someConfigured = checks.secretKeyConfigured ||\n                           checks.webhookSecretConfigured;\n\n    return {\n      status: allConfigured ? 'operational' : (someConfigured ? 'degraded' : 'down'),\n      message: allConfigured ? 'Stripe secrets configured' :\n               (someConfigured ? 'Some Stripe secrets missing' : 'Stripe not configured'),\n      details: checks\n    };\n  } catch (error) {\n    return {\n      status: 'down',\n      message: 'Stripe check failed',\n      error: error.message,\n      details: checks\n    };\n  }\n}\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/integration/content-lifecycle.test.js","messages":[{"ruleId":"no-unused-vars","severity":1,"message":"'beforeEach' is defined but never used.","line":6,"column":32,"nodeType":"Identifier","messageId":"unusedVar","endLine":6,"endColumn":42},{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 18 to the 15 allowed.","line":17,"column":28,"nodeType":null,"messageId":"refactorFunction","endLine":17,"endColumn":30},{"ruleId":"security/detect-non-literal-regexp","severity":1,"message":"Found non-literal argument to RegExp Constructor","line":37,"column":27,"nodeType":"NewExpression","endLine":37,"endColumn":65},{"ruleId":"no-unused-vars","severity":1,"message":"'id' is defined but never used. Allowed unused args must match /^_/u.","line":95,"column":13,"nodeType":"Identifier","messageId":"unusedVar","endLine":95,"endColumn":15},{"ruleId":"no-unused-vars","severity":1,"message":"'hash' is defined but never used. Allowed unused args must match /^_/u.","line":150,"column":13,"nodeType":"Identifier","messageId":"unusedVar","endLine":150,"endColumn":17}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":5,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Content Lifecycle Integration Tests\n * Tests for complete content lifecycle workflows\n */\n\nimport { describe, it, expect, beforeEach } from 'vitest';\nimport { deleteContent, getContentMetadata } from '../services/content-deletion.js';\n\n// Constants\nconst BATCH_LIMIT = 5000; // Cloudflare Workers batch processing limit\n\n/**\n * Create mock Durable Object stub with multiple endpoints\n */\nfunction createMockDurableObjectStub(responses) {\n  return {\n    fetch: async (request) => {\n      const url = new URL(request.url);\n      const method = request.method;\n      const pathname = url.pathname;\n      \n      // Try exact match first\n      const exactKey = `${method}:${pathname}`;\n      if (responses[exactKey]) {\n        return typeof responses[exactKey] === 'function' \n          ? responses[exactKey](request) \n          : responses[exactKey];\n      }\n      \n      // Try pattern matching for dynamic routes\n      for (const [key, response] of Object.entries(responses)) {\n        if (key.includes('*')) {\n          const [keyMethod, keyPattern] = key.split(':');\n          if (keyMethod === method) {\n            // Escape special regex characters and replace * with .*\n            const escapedPattern = keyPattern.replace(/[.+?^${}()|[\\]\\\\]/g, '\\\\$&').replace(/\\*/g, '.*');\n            const regex = new RegExp('^' + escapedPattern + '$');\n            if (regex.test(pathname)) {\n              return typeof response === 'function' ? response(request) : response;\n            }\n          }\n        }\n      }\n      \n      return new Response('Not Found', { status: 404 });\n    }\n  };\n}\n\n/**\n * Create mock environment for integration testing\n */\nfunction createMockEnv(options = {}) {\n  const {\n    contentMetadata = {},\n    expirationIndex = {},\n    deletionRecords = {},\n    r2Objects = {}\n  } = options;\n\n  // Track state across calls\n  const state = {\n    metadata: { ...contentMetadata },\n    expirationIndex: { ...expirationIndex },\n    deletionRecords: { ...deletionRecords },\n    r2Objects: { ...r2Objects }\n  };\n\n  return {\n    CONTENT_METADATA: {\n      idFromName: (hash) => hash,\n      get: (hash) => {\n        return createMockDurableObjectStub({\n          'GET:/content': () => {\n            if (state.metadata[hash]) {\n              return new Response(JSON.stringify(state.metadata[hash]), {\n                status: 200,\n                headers: { 'content-type': 'application/json' }\n              });\n            }\n            return new Response('Not Found', { status: 404 });\n          },\n          'DELETE:/content': () => {\n            delete state.metadata[hash];\n            return new Response(JSON.stringify({ success: true }), {\n              status: 200,\n              headers: { 'content-type': 'application/json' }\n            });\n          }\n        });\n      }\n    },\n    EXPIRATION_INDEX: {\n      idFromName: (name) => name,\n      get: (id) => {\n        return createMockDurableObjectStub({\n          'POST:/register': async (request) => {\n            const body = await request.json();\n            const date = body.expires_at.split('T')[0];\n            if (!state.expirationIndex[date]) {\n              state.expirationIndex[date] = [];\n            }\n            if (!state.expirationIndex[date].includes(body.hash_256t)) {\n              state.expirationIndex[date].push(body.hash_256t);\n            }\n            return new Response(JSON.stringify({ success: true }), {\n              status: 200,\n              headers: { 'Content-Type': 'application/json' }\n            });\n          },\n          'POST:/update': async (request) => {\n            const body = await request.json();\n            const oldDate = body.old_expires_at.split('T')[0];\n            const newDate = body.new_expires_at.split('T')[0];\n            \n            // Remove from old date\n            if (state.expirationIndex[oldDate]) {\n              state.expirationIndex[oldDate] = state.expirationIndex[oldDate].filter(\n                h => h !== body.hash_256t\n              );\n            }\n            \n            // Add to new date\n            if (!state.expirationIndex[newDate]) {\n              state.expirationIndex[newDate] = [];\n            }\n            if (!state.expirationIndex[newDate].includes(body.hash_256t)) {\n              state.expirationIndex[newDate].push(body.hash_256t);\n            }\n            \n            return new Response(JSON.stringify({ success: true }), {\n              status: 200,\n              headers: { 'Content-Type': 'application/json' }\n            });\n          },\n          'GET:*/expired*': (request) => {\n            const url = new URL(request.url);\n            const date = url.searchParams.get('date');\n            const hashes = state.expirationIndex[date] || [];\n            return new Response(JSON.stringify({ hashes }), {\n              status: 200,\n              headers: { 'content-type': 'application/json' }\n            });\n          }\n        });\n      }\n    },\n    DELETION_RECORD: {\n      idFromName: (hash) => hash,\n      get: (hash) => {\n        return createMockDurableObjectStub({\n          'POST:/record': async (request) => {\n            const body = await request.json();\n            state.deletionRecords[body.hash_256t] = body;\n            return new Response(JSON.stringify({\n              hash_256t: body.hash_256t,\n              reason: body.reason,\n              deleted_at: body.deleted_at || new Date().toISOString()\n            }), {\n              status: 201,\n              headers: { 'content-type': 'application/json' }\n            });\n          }\n        });\n      }\n    },\n    CONTENT_BUCKET: {\n      delete: async (key) => {\n        delete state.r2Objects[key];\n        return undefined;\n      }\n    },\n    _state: state // Expose state for testing\n  };\n}\n\ndescribe('Content Lifecycle Integration Tests', () => {\n  describe('Upload to Expiration Flow', () => {\n    it('should register content with ExpirationIndex on upload', async () => {\n      const env = createMockEnv({\n        contentMetadata: {\n          'test-hash': {\n            hash_256t: 'test-hash',\n            size_bytes: 1024,\n            content_type: 'text/plain',\n            uploader_id: 'user_123',\n            created_at: '2026-01-23T00:00:00Z',\n            expires_at: '2026-02-23T00:00:00Z'\n          }\n        }\n      });\n\n      // Simulate upload registration with ExpirationIndex\n      const indexStub = env.EXPIRATION_INDEX.get(env.EXPIRATION_INDEX.idFromName('global'));\n      const response = await indexStub.fetch(\n        new Request('http://internal/register', {\n          method: 'POST',\n          headers: { 'content-type': 'application/json' },\n          body: JSON.stringify({\n            hash_256t: 'test-hash',\n            expires_at: '2026-02-23T00:00:00Z'\n          })\n        })\n      );\n\n      expect(response.status).toBe(200);\n      expect(env._state.expirationIndex['2026-02-23']).toContain('test-hash');\n    });\n\n    it('should update ExpirationIndex when content is extended', async () => {\n      const env = createMockEnv({\n        contentMetadata: {\n          'test-hash': {\n            hash_256t: 'test-hash',\n            size_bytes: 1024,\n            content_type: 'text/plain',\n            uploader_id: 'user_123',\n            created_at: '2026-01-23T00:00:00Z',\n            expires_at: '2026-02-23T00:00:00Z'\n          }\n        },\n        expirationIndex: {\n          '2026-02-23': ['test-hash']\n        }\n      });\n\n      // Simulate extension (old date: 2026-02-23, new date: 2026-03-23)\n      const indexStub = env.EXPIRATION_INDEX.get(env.EXPIRATION_INDEX.idFromName('global'));\n      const response = await indexStub.fetch(\n        new Request('http://internal/update', {\n          method: 'POST',\n          headers: { 'content-type': 'application/json' },\n          body: JSON.stringify({\n            hash_256t: 'test-hash',\n            old_expires_at: '2026-02-23T00:00:00Z',\n            new_expires_at: '2026-03-23T00:00:00Z'\n          })\n        })\n      );\n\n      expect(response.status).toBe(200);\n      expect(env._state.expirationIndex['2026-02-23']).not.toContain('test-hash');\n      expect(env._state.expirationIndex['2026-03-23']).toContain('test-hash');\n    });\n\n    it('should delete content when expiration date arrives', async () => {\n      const env = createMockEnv({\n        contentMetadata: {\n          'test-hash': {\n            hash_256t: 'test-hash',\n            size_bytes: 1024,\n            content_type: 'text/plain',\n            uploader_id: 'user_123',\n            created_at: '2026-01-23T00:00:00Z',\n            expires_at: '2026-01-23T00:00:00Z'\n          }\n        },\n        expirationIndex: {\n          '2026-01-23': ['test-hash']\n        },\n        r2Objects: {\n          'test-hash': 'content data'\n        }\n      });\n\n      // Simulate scheduled job fetching expired content\n      const indexStub = env.EXPIRATION_INDEX.get(env.EXPIRATION_INDEX.idFromName('global'));\n      const expiredResponse = await indexStub.fetch(\n        new Request('http://internal/expired?date=2026-01-23', { method: 'GET' })\n      );\n      const expiredData = await expiredResponse.json();\n\n      expect(expiredData.hashes).toContain('test-hash');\n\n      // Delete the content\n      const result = await deleteContent(env, 'test-hash', env._state.metadata['test-hash'], 'expired');\n\n      expect(result.success).toBe(true);\n      expect(env._state.metadata['test-hash']).toBeUndefined();\n      expect(env._state.r2Objects['test-hash']).toBeUndefined();\n      expect(env._state.deletionRecords['test-hash']).toBeDefined();\n      expect(env._state.deletionRecords['test-hash'].reason).toBe('expired');\n    });\n  });\n\n  describe('Extension Wins Strategy', () => {\n    it('should skip deletion if content was extended after indexing', async () => {\n      const today = '2026-01-23';\n      const env = createMockEnv({\n        contentMetadata: {\n          'test-hash': {\n            hash_256t: 'test-hash',\n            size_bytes: 1024,\n            content_type: 'text/plain',\n            uploader_id: 'user_123',\n            created_at: '2026-01-23T00:00:00Z',\n            expires_at: '2026-02-23T00:00:00Z' // Extended after indexing\n          }\n        },\n        expirationIndex: {\n          '2026-01-23': ['test-hash'] // Still in today's index\n        }\n      });\n\n      // Get metadata to check expiration\n      const metadata = await getContentMetadata(env, 'test-hash');\n      const expiresDate = metadata.expires_at.split('T')[0];\n\n      // \"Extension wins\" check\n      expect(expiresDate > today).toBe(true);\n      \n      // Content should not be deleted\n      expect(env._state.metadata['test-hash']).toBeDefined();\n    });\n\n    it('should delete content if extension date has also passed', async () => {\n      const today = '2026-02-23';\n      const env = createMockEnv({\n        contentMetadata: {\n          'test-hash': {\n            hash_256t: 'test-hash',\n            size_bytes: 1024,\n            content_type: 'text/plain',\n            uploader_id: 'user_123',\n            created_at: '2026-01-23T00:00:00Z',\n            expires_at: '2026-02-23T00:00:00Z'\n          }\n        },\n        expirationIndex: {\n          '2026-02-23': ['test-hash']\n        },\n        r2Objects: {\n          'test-hash': 'content data'\n        }\n      });\n\n      const metadata = await getContentMetadata(env, 'test-hash');\n      const expiresDate = metadata.expires_at.split('T')[0];\n\n      // Content has expired (date matches)\n      expect(expiresDate <= today).toBe(true);\n\n      // Delete the content\n      const result = await deleteContent(env, 'test-hash', metadata, 'expired');\n\n      expect(result.success).toBe(true);\n      expect(env._state.metadata['test-hash']).toBeUndefined();\n    });\n  });\n\n  describe('Batch Processing', () => {\n    it('should process multiple expired items', async () => {\n      const env = createMockEnv({\n        contentMetadata: {\n          'hash-1': {\n            hash_256t: 'hash-1',\n            size_bytes: 1024,\n            uploader_id: 'user_123',\n            expires_at: '2026-01-23T00:00:00Z'\n          },\n          'hash-2': {\n            hash_256t: 'hash-2',\n            size_bytes: 2048,\n            uploader_id: 'user_456',\n            expires_at: '2026-01-23T00:00:00Z'\n          },\n          'hash-3': {\n            hash_256t: 'hash-3',\n            size_bytes: 4096,\n            uploader_id: 'user_789',\n            expires_at: '2026-01-23T00:00:00Z'\n          }\n        },\n        expirationIndex: {\n          '2026-01-23': ['hash-1', 'hash-2', 'hash-3']\n        },\n        r2Objects: {\n          'hash-1': 'data1',\n          'hash-2': 'data2',\n          'hash-3': 'data3'\n        }\n      });\n\n      const indexStub = env.EXPIRATION_INDEX.get(env.EXPIRATION_INDEX.idFromName('global'));\n      const expiredResponse = await indexStub.fetch(\n        new Request('http://internal/expired?date=2026-01-23', { method: 'GET' })\n      );\n      const expiredData = await expiredResponse.json();\n\n      expect(expiredData.hashes).toHaveLength(3);\n\n      // Delete all expired content\n      const results = await Promise.all(\n        expiredData.hashes.map(hash =>\n          deleteContent(env, hash, env._state.metadata[hash], 'expired')\n        )\n      );\n\n      expect(results.every(r => r.success)).toBe(true);\n      expect(env._state.metadata['hash-1']).toBeUndefined();\n      expect(env._state.metadata['hash-2']).toBeUndefined();\n      expect(env._state.metadata['hash-3']).toBeUndefined();\n      expect(Object.keys(env._state.deletionRecords)).toHaveLength(3);\n    });\n\n    it('should handle batch limit correctly', async () => {\n      // Create 5,001 expired items\n      const hashes = Array.from({ length: 5001 }, (_, i) => `hash-${i}`);\n      const metadata = {};\n      hashes.forEach(hash => {\n        metadata[hash] = {\n          hash_256t: hash,\n          size_bytes: 1024,\n          uploader_id: 'user_123',\n          expires_at: '2026-01-23T00:00:00Z'\n        };\n      });\n\n      const env = createMockEnv({\n        contentMetadata: metadata,\n        expirationIndex: {\n          '2026-01-23': hashes\n        }\n      });\n\n      const indexStub = env.EXPIRATION_INDEX.get(env.EXPIRATION_INDEX.idFromName('global'));\n      const expiredResponse = await indexStub.fetch(\n        new Request('http://internal/expired?date=2026-01-23', { method: 'GET' })\n      );\n      const expiredData = await expiredResponse.json();\n\n      // Should only process up to BATCH_LIMIT\n      const hashesToProcess = expiredData.hashes.slice(0, BATCH_LIMIT);\n      \n      expect(hashesToProcess.length).toBe(BATCH_LIMIT);\n      expect(expiredData.hashes.length).toBe(5001);\n    });\n  });\n\n  describe('Idempotency', () => {\n    it('should handle deletion of already-deleted content gracefully', async () => {\n      const env = createMockEnv({\n        contentMetadata: {}, // Content already deleted\n        expirationIndex: {\n          '2026-01-23': ['test-hash']\n        }\n      });\n\n      // Try to get metadata for non-existent content\n      const metadata = await getContentMetadata(env, 'test-hash');\n      expect(metadata).toBeNull();\n\n      // Should handle gracefully (skip in real implementation)\n    });\n  });\n\n  describe('Inline Content Handling', () => {\n    it('should not attempt R2 deletion for inline content', async () => {\n      const env = createMockEnv({\n        contentMetadata: {\n          'inline-hash': {\n            hash_256t: 'inline-hash',\n            size_bytes: 32, // Small enough for inline\n            content_type: 'text/plain',\n            uploader_id: 'user_123',\n            expires_at: '2026-01-23T00:00:00Z'\n          }\n        },\n        expirationIndex: {\n          '2026-01-23': ['inline-hash']\n        }\n      });\n\n      const result = await deleteContent(\n        env,\n        'inline-hash',\n        env._state.metadata['inline-hash'],\n        'expired'\n      );\n\n      expect(result.success).toBe(true);\n      expect(result.deleted_from_r2).toBe(false); // No R2 deletion for inline content\n      expect(env._state.metadata['inline-hash']).toBeUndefined();\n      expect(env._state.deletionRecords['inline-hash']).toBeDefined();\n    });\n  });\n\n  describe('Donation Extension Flow', () => {\n    it('should update ExpirationIndex when donation extends retention', async () => {\n      const env = createMockEnv({\n        contentMetadata: {\n          'donated-hash': {\n            hash_256t: 'donated-hash',\n            size_bytes: 1024,\n            content_type: 'text/plain',\n            uploader_id: 'user_recipient',\n            expires_at: '2026-02-23T00:00:00Z'\n          }\n        },\n        expirationIndex: {\n          '2026-02-23': ['donated-hash']\n        }\n      });\n\n      // Simulate donation extension\n      const indexStub = env.EXPIRATION_INDEX.get(env.EXPIRATION_INDEX.idFromName('global'));\n      const response = await indexStub.fetch(\n        new Request('http://internal/update', {\n          method: 'POST',\n          headers: { 'content-type': 'application/json' },\n          body: JSON.stringify({\n            hash_256t: 'donated-hash',\n            old_expires_at: '2026-02-23T00:00:00Z',\n            new_expires_at: '2026-04-23T00:00:00Z' // Extended 2 more months\n          })\n        })\n      );\n\n      expect(response.status).toBe(200);\n      expect(env._state.expirationIndex['2026-02-23']).not.toContain('donated-hash');\n      expect(env._state.expirationIndex['2026-04-23']).toContain('donated-hash');\n    });\n  });\n});\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/integration/contested-content.test.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/services/content-deletion.js","messages":[{"ruleId":"jsdoc/tag-lines","severity":1,"message":"Expected only 0 line after block description","line":8,"column":1,"nodeType":"Block","endLine":8,"endColumn":1,"fix":{"range":[153,534],"text":"/**\n * Delete content completely from the system\n * @param {Object} env - Environment bindings\n * @param {string} hash_256t - Content hash to delete\n * @param {Object} metadata - Content metadata (if already fetched)\n * @param {string} reason - Deletion reason ('expired', 'contested', 'admin_action', etc.)\n * @returns {Promise<Object>} Deletion result with success status\n */"}},{"ruleId":"jsdoc/tag-lines","severity":1,"message":"Expected only 0 line after block description","line":107,"column":1,"nodeType":"Block","endLine":107,"endColumn":1,"fix":{"range":[3389,3594],"text":"/**\n * Get content metadata helper\n * @param {Object} env - Environment bindings\n * @param {string} hash_256t - Content hash\n * @returns {Promise<Object|null>} Content metadata or null if not found\n */"}}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":2,"source":"/**\n * Content Deletion Service\n * Handles hard deletion of content from R2 and ContentMetadata\n * Creates public deletion records for transparency\n */\n\n/**\n * Delete content completely from the system\n * \n * @param {Object} env - Environment bindings\n * @param {string} hash_256t - Content hash to delete\n * @param {Object} metadata - Content metadata (if already fetched)\n * @param {string} reason - Deletion reason ('expired', 'contested', 'admin_action', etc.)\n * @returns {Promise<Object>} Deletion result with success status\n */\nexport async function deleteContent(env, hash_256t, metadata = null, reason = 'expired') {\n  try {\n    // Fetch metadata if not provided\n    if (!metadata) {\n      const metadataId = env.CONTENT_METADATA.idFromName(hash_256t);\n      const metadataStub = env.CONTENT_METADATA.get(metadataId);\n      \n      const metadataResponse = await metadataStub.fetch(\n        new Request('https://dummy/content', { method: 'GET' })\n      );\n      \n      if (metadataResponse.status === 404) {\n        // Content already deleted or never existed\n        return {\n          success: true,\n          alreadyDeleted: true,\n          hash_256t\n        };\n      }\n      \n      metadata = await metadataResponse.json();\n    }\n\n    // Determine if content is inline (≤64 bytes)\n    const isInline = metadata.size_bytes && metadata.size_bytes <= 64;\n\n    // Delete from R2 if not inline content\n    if (!isInline) {\n      try {\n        await env.CONTENT_BUCKET.delete(hash_256t);\n        await env.CONTENT_BUCKET.delete(`${hash_256t}.meta`);\n        await env.CONTENT_BUCKET.delete(`${hash_256t}.disputed`);\n        await env.CONTENT_BUCKET.delete(`${hash_256t}.deleted`);\n        console.log(`Deleted R2 object: ${hash_256t}`);\n      } catch (error) {\n        // Log but don't fail - content might already be deleted from R2\n        console.warn(`Failed to delete R2 object ${hash_256t}:`, error.message);\n      }\n    }\n\n    // Delete ContentMetadata DO entry\n    try {\n      const metadataId = env.CONTENT_METADATA.idFromName(hash_256t);\n      const metadataStub = env.CONTENT_METADATA.get(metadataId);\n      \n      await metadataStub.fetch(\n        new Request('https://dummy/content', { method: 'DELETE' })\n      );\n      console.log(`Deleted ContentMetadata: ${hash_256t}`);\n    } catch (error) {\n      console.warn(`Failed to delete ContentMetadata ${hash_256t}:`, error.message);\n    }\n\n    // Create deletion record for transparency\n    const deletionRecordId = env.DELETION_RECORD.idFromName('global');\n    const deletionRecordStub = env.DELETION_RECORD.get(deletionRecordId);\n    \n    await deletionRecordStub.fetch(\n      new Request('https://dummy/record', {\n        method: 'POST',\n        headers: { 'content-type': 'application/json' },\n        body: JSON.stringify({\n          hash_256t,\n          reason,\n          uploader_id: metadata.uploader_id,\n          size_bytes: metadata.size_bytes,\n          content_type: metadata.content_type\n        })\n      })\n    );\n\n    console.log(`Content deleted: ${hash_256t}, reason: ${reason}`);\n\n    return {\n      success: true,\n      hash_256t,\n      reason,\n      deleted_from_r2: !isInline,\n      deletion_record_created: true\n    };\n  } catch (error) {\n    console.error(`Error deleting content ${hash_256t}:`, error);\n    return {\n      success: false,\n      hash_256t,\n      error: error.message\n    };\n  }\n}\n\n/**\n * Get content metadata helper\n * \n * @param {Object} env - Environment bindings\n * @param {string} hash_256t - Content hash\n * @returns {Promise<Object|null>} Content metadata or null if not found\n */\nexport async function getContentMetadata(env, hash_256t) {\n  try {\n    const metadataId = env.CONTENT_METADATA.idFromName(hash_256t);\n    const metadataStub = env.CONTENT_METADATA.get(metadataId);\n    \n    const response = await metadataStub.fetch(\n      new Request('https://dummy/content', { method: 'GET' })\n    );\n    \n    if (response.status === 404) {\n      return null;\n    }\n    \n    return await response.json();\n  } catch (error) {\n    console.error(`Error fetching metadata for ${hash_256t}:`, error);\n    return null;\n  }\n}\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/services/content-deletion.test.js","messages":[{"ruleId":"no-unused-vars","severity":1,"message":"'beforeEach' is defined but never used.","line":6,"column":32,"nodeType":"Identifier","messageId":"unusedVar","endLine":6,"endColumn":42},{"ruleId":"no-unused-vars","severity":1,"message":"'id' is defined but never used. Allowed unused args must match /^_/u.","line":41,"column":13,"nodeType":"Identifier","messageId":"unusedVar","endLine":41,"endColumn":15},{"ruleId":"no-unused-vars","severity":1,"message":"'id' is defined but never used. Allowed unused args must match /^_/u.","line":61,"column":13,"nodeType":"Identifier","messageId":"unusedVar","endLine":61,"endColumn":15},{"ruleId":"no-unused-vars","severity":1,"message":"'key' is defined but never used. Allowed unused args must match /^_/u.","line":72,"column":22,"nodeType":"Identifier","messageId":"unusedVar","endLine":72,"endColumn":25}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":4,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Content Deletion Service Tests\n * Tests for content deletion logic\n */\n\nimport { describe, it, expect, beforeEach } from 'vitest';\nimport { deleteContent, getContentMetadata } from './content-deletion.js';\n\n/**\n * Create mock Durable Object stub\n */\nfunction createMockDurableObjectStub(responses) {\n  return {\n    fetch: async (request) => {\n      const url = new URL(request.url);\n      const method = request.method;\n      const key = `${method}:${url.pathname}`;\n      \n      if (responses[key]) {\n        return responses[key];\n      }\n      \n      return new Response('Not Found', { status: 404 });\n    }\n  };\n}\n\n/**\n * Create mock environment\n */\nfunction createMockEnv(options = {}) {\n  const {\n    metadataExists = true,\n    metadataData = {},\n    r2DeleteSuccess = true\n  } = options;\n\n  return {\n    CONTENT_METADATA: {\n      idFromName: (name) => name,\n      get: (id) => {\n        return createMockDurableObjectStub({\n          'GET:/content': metadataExists\n            ? new Response(JSON.stringify({\n                hash_256t: 'test-hash',\n                size_bytes: 1024,\n                content_type: 'text/plain',\n                uploader_id: 'user_123',\n                ...metadataData\n              }), { status: 200, headers: { 'content-type': 'application/json' } })\n            : new Response('Not Found', { status: 404 }),\n          'DELETE:/content': new Response(JSON.stringify({ success: true }), {\n            status: 200,\n            headers: { 'content-type': 'application/json' }\n          })\n        });\n      }\n    },\n    DELETION_RECORD: {\n      idFromName: (name) => name,\n      get: (id) => {\n        return createMockDurableObjectStub({\n          'POST:/record': new Response(JSON.stringify({\n            hash_256t: 'test-hash',\n            reason: 'expired',\n            deleted_at: new Date().toISOString()\n          }), { status: 201, headers: { 'content-type': 'application/json' } })\n        });\n      }\n    },\n    CONTENT_BUCKET: {\n      delete: async (key) => {\n        if (!r2DeleteSuccess) {\n          throw new Error('R2 delete failed');\n        }\n        return undefined;\n      }\n    }\n  };\n}\n\ndescribe('Content Deletion Service', () => {\n  describe('deleteContent', () => {\n    it('should delete content from R2 and metadata store', async () => {\n      const env = createMockEnv();\n      \n      const result = await deleteContent(env, 'test-hash', null, 'expired');\n      \n      expect(result.success).toBe(true);\n      expect(result.hash_256t).toBe('test-hash');\n      expect(result.reason).toBe('expired');\n      expect(result.deleted_from_r2).toBe(true);\n      expect(result.deletion_record_created).toBe(true);\n    });\n\n    it('should handle inline content (no R2 deletion)', async () => {\n      const env = createMockEnv({\n        metadataData: {\n          size_bytes: 32 // Inline content (≤64 bytes)\n        }\n      });\n      \n      const result = await deleteContent(env, 'test-hash', null, 'expired');\n      \n      expect(result.success).toBe(true);\n      expect(result.deleted_from_r2).toBe(false); // Inline content doesn't need R2 deletion\n    });\n\n    it('should be idempotent - handle already deleted content', async () => {\n      const env = createMockEnv({ metadataExists: false });\n      \n      const result = await deleteContent(env, 'test-hash', null, 'expired');\n      \n      expect(result.success).toBe(true);\n      expect(result.alreadyDeleted).toBe(true);\n    });\n\n    it('should handle R2 deletion failure gracefully', async () => {\n      const env = createMockEnv({ r2DeleteSuccess: false });\n      \n      const result = await deleteContent(env, 'test-hash', null, 'expired');\n      \n      // Should still succeed even if R2 deletion fails\n      expect(result.success).toBe(true);\n    });\n\n    it('should accept pre-fetched metadata', async () => {\n      const env = createMockEnv();\n      \n      const metadata = {\n        hash_256t: 'test-hash',\n        size_bytes: 1024,\n        content_type: 'text/plain',\n        uploader_id: 'user_123'\n      };\n      \n      const result = await deleteContent(env, 'test-hash', metadata, 'expired');\n      \n      expect(result.success).toBe(true);\n      expect(result.hash_256t).toBe('test-hash');\n    });\n\n    it('should support different deletion reasons', async () => {\n      const env = createMockEnv();\n      \n      const reasons = ['expired', 'contested', 'admin_action'];\n      \n      for (const reason of reasons) {\n        const result = await deleteContent(env, `test-hash-${reason}`, null, reason);\n        expect(result.success).toBe(true);\n        expect(result.reason).toBe(reason);\n      }\n    });\n  });\n\n  describe('getContentMetadata', () => {\n    it('should fetch content metadata', async () => {\n      const env = createMockEnv();\n      \n      const metadata = await getContentMetadata(env, 'test-hash');\n      \n      expect(metadata).toBeDefined();\n      expect(metadata.hash_256t).toBe('test-hash');\n      expect(metadata.size_bytes).toBe(1024);\n    });\n\n    it('should return null for non-existent content', async () => {\n      const env = createMockEnv({ metadataExists: false });\n      \n      const metadata = await getContentMetadata(env, 'nonexistent');\n      \n      expect(metadata).toBeNull();\n    });\n  });\n});\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/utils/content-domain.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/utils/content-domain.test.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/utils/cost-estimation.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/utils/hash256t.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/utils/ip-hash.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/utils/mime-types.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/utils/platform-stats.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/utils/pricing.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/utils/pricing.test.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/utils/rate-limit-pricing.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/utils/rate-limit-pricing.test.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/utils/supplier-fallback.js","messages":[{"ruleId":"no-unused-vars","severity":1,"message":"'ROLLING_WINDOW_SIZE' is assigned a value but never used.","line":10,"column":7,"nodeType":"Identifier","messageId":"unusedVar","endLine":10,"endColumn":26},{"ruleId":"complexity","severity":1,"message":"Async function 'fetchFromAlternate' has a complexity of 13. Maximum allowed is 10.","line":63,"column":8,"nodeType":"FunctionDeclaration","messageId":"complex","endLine":150,"endColumn":2},{"ruleId":"complexity","severity":1,"message":"Async function 'tryAlternateSuppliers' has a complexity of 20. Maximum allowed is 10.","line":160,"column":8,"nodeType":"FunctionDeclaration","messageId":"complex","endLine":323,"endColumn":2},{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 40 to the 15 allowed.","line":160,"column":23,"nodeType":null,"messageId":"refactorFunction","endLine":160,"endColumn":44}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":4,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Alternate Supplier Fallback Utilities\n * Handles fetching content from alternate suppliers when unavailable on hashbin.org\n */\n\nimport { generate256tHash, getContentSize } from './hash256t.js';\nimport { getMimeType } from './mime-types.js';\n\n// Configuration constants\nconst ROLLING_WINDOW_SIZE = 100; // Rolling window size for statistics\nconst SUCCESS_RATE_THRESHOLD = 0.95; // 95% success rate required for redirect\nconst WARMUP_REQUEST_COUNT = 100; // Requests before redirect is allowed\nconst PERIODIC_PROXY_INTERVAL = 100; // Proxy every Nth request for verification\n\n/**\n * Fisher-Yates shuffle for uniform random distribution\n * @param {Array} array - Array to shuffle\n * @returns {Array} Shuffled array (in-place)\n */\nfunction shuffleArray(array) {\n  for (let i = array.length - 1; i > 0; i--) {\n    const j = Math.floor(Math.random() * (i + 1));\n    [array[i], array[j]] = [array[j], array[i]];\n  }\n  return array;\n}\n\n/**\n * Decide whether to proxy or redirect based on supplier statistics\n * @param {Object} stats - Supplier statistics\n * @returns {string} 'proxy' or 'redirect'\n */\nexport function decideProxyOrRedirect(stats) {\n  // Warmup period: first N requests must be proxied\n  if (stats.total_requests < WARMUP_REQUEST_COUNT) {\n    return 'proxy';\n  }\n\n  // Low success rate: proxy for verification\n  const recentTotal = stats.recent_success_count + stats.recent_failure_count;\n  const recentSuccessRate = recentTotal > 0 ? stats.recent_success_count / recentTotal : 0;\n  \n  if (recentSuccessRate < SUCCESS_RATE_THRESHOLD) {\n    return 'proxy';\n  }\n\n  // Periodic verification: every Nth request\n  if (stats.requests_since_last_proxy >= PERIODIC_PROXY_INTERVAL) {\n    return 'proxy';\n  }\n\n  // All conditions met: use redirect\n  return 'redirect';\n}\n\n/**\n * Fetch content from alternate supplier (proxy mode)\n * @param {string} supplierUrl - Full URL to content\n * @param {string} cid - Expected CID for verification\n * @param {Object} request - Original request (for Range header)\n * @returns {Promise<{success: boolean, content?: ArrayBuffer, headers?: Object, error?: string}>}\n */\nexport async function fetchFromAlternate(supplierUrl, cid, request) {\n  try {\n    const startTime = Date.now();\n    \n    // Build headers for alternate request\n    const fetchHeaders = new Headers();\n    \n    // Forward Range header if present (with validation)\n    const rangeHeader = request.headers.get('Range');\n    if (rangeHeader) {\n      // Validate Range header format (basic validation)\n      if (/^bytes=\\d+-\\d*$/.test(rangeHeader) || /^bytes=\\d+-$/.test(rangeHeader)) {\n        fetchHeaders.set('Range', rangeHeader);\n      }\n    }\n\n    // Fetch from alternate\n    const response = await fetch(supplierUrl, {\n      headers: fetchHeaders\n    });\n\n    if (!response.ok) {\n      return {\n        success: false,\n        error: `HTTP ${response.status}: ${response.statusText}`,\n        response_time_ms: Date.now() - startTime\n      };\n    }\n\n    const content = await response.arrayBuffer();\n    const responseTimeMs = Date.now() - startTime;\n\n    // For full content (not range), verify hash\n    const isPartialContent = response.status === 206;\n    \n    if (!isPartialContent) {\n      // Verify size\n      const expectedSize = getContentSize(cid);\n      if (content.byteLength !== expectedSize) {\n        return {\n          success: false,\n          error: `Size mismatch: got ${content.byteLength} bytes, expected ${expectedSize} bytes`,\n          response_time_ms: responseTimeMs,\n          verification_failed: true\n        };\n      }\n\n      // Verify hash\n      const computedHash = await generate256tHash(content);\n      if (computedHash !== cid) {\n        return {\n          success: false,\n          error: 'Content hash does not match CID',\n          response_time_ms: responseTimeMs,\n          verification_failed: true\n        };\n      }\n    }\n\n    // Extract relevant response headers\n    const headers = {};\n    if (response.headers.has('Content-Type')) {\n      headers['Content-Type'] = response.headers.get('Content-Type');\n    }\n    if (response.headers.has('Content-Length')) {\n      headers['Content-Length'] = response.headers.get('Content-Length');\n    }\n    if (response.headers.has('Content-Range')) {\n      headers['Content-Range'] = response.headers.get('Content-Range');\n    }\n    if (response.headers.has('Accept-Ranges')) {\n      headers['Accept-Ranges'] = response.headers.get('Accept-Ranges');\n    }\n\n    return {\n      success: true,\n      content,\n      headers,\n      response_time_ms: responseTimeMs,\n      is_partial: isPartialContent\n    };\n  } catch (error) {\n    return {\n      success: false,\n      error: error.message\n    };\n  }\n}\n\n/**\n * Try fetching content from alternate suppliers\n * @param {Object} env - Environment bindings\n * @param {string} cid - Content identifier\n * @param {Object} request - Original request\n * @param {string} extension - Optional file extension for MIME type\n * @returns {Promise<Response|null>} Response or null if no alternates work\n */\nexport async function tryAlternateSuppliers(env, cid, request, extension = null) {\n  try {\n    // Get alternate suppliers for this CID\n    const contentMetadataId = env.CONTENT_METADATA.idFromName(cid);\n    const contentMetadataStub = env.CONTENT_METADATA.get(contentMetadataId);\n    \n    const suppliersResponse = await contentMetadataStub.fetch(\n      new Request('http://internal/suppliers')\n    );\n\n    if (!suppliersResponse.ok) {\n      return null;\n    }\n\n    const suppliersData = await suppliersResponse.json();\n    const alternateSuppliers = suppliersData.alternate_suppliers || [];\n\n    if (alternateSuppliers.length === 0) {\n      return null;\n    }\n\n    // Use Fisher-Yates shuffle for uniform random distribution\n    const shuffled = shuffleArray([...alternateSuppliers]);\n\n    // Try each supplier\n    for (const supplier of shuffled) {\n      try {\n        // Get supplier details and statistics\n        const supplierRegistryId = env.SUPPLIER_REGISTRY.idFromName(supplier.supplier_id);\n        const supplierRegistryStub = env.SUPPLIER_REGISTRY.get(supplierRegistryId);\n        \n        const supplierResponse = await supplierRegistryStub.fetch(\n          new Request('http://internal/supplier')\n        );\n\n        if (!supplierResponse.ok) {\n          continue;\n        }\n\n        const supplierData = await supplierResponse.json();\n\n        // Skip inactive suppliers\n        if (!supplierData.is_active) {\n          continue;\n        }\n\n        // Get statistics\n        const statsResponse = await supplierRegistryStub.fetch(\n          new Request('http://internal/stats')\n        );\n\n        let stats = null;\n        if (statsResponse.ok) {\n          stats = await statsResponse.json();\n        }\n\n        // Decide proxy or redirect\n        const mode = stats ? decideProxyOrRedirect(stats) : 'proxy';\n\n        if (mode === 'redirect') {\n          // Record redirect (no verification possible)\n          await supplierRegistryStub.fetch(\n            new Request('http://internal/stats/record', {\n              method: 'POST',\n              headers: { 'content-type': 'application/json' },\n              body: JSON.stringify({\n                success: true, // Assume success for redirects\n                was_proxy: false\n              })\n            })\n          );\n\n          // Increment download count\n          await contentMetadataStub.fetch(\n            new Request('http://internal/increment-download', {\n              method: 'POST'\n            })\n          );\n\n          // Return redirect response\n          return new Response(null, {\n            status: 302,\n            headers: {\n              'Location': supplier.supplier_url,\n              'X-HashBin-Source': 'redirect',\n              'X-HashBin-Supplier': supplierData.name,\n              'Cache-Control': 'no-cache'\n            }\n          });\n        } else {\n          // Proxy mode\n          const result = await fetchFromAlternate(supplier.supplier_url, cid, request);\n\n          // Record statistics\n          await supplierRegistryStub.fetch(\n            new Request('http://internal/stats/record', {\n              method: 'POST',\n              headers: { 'content-type': 'application/json' },\n              body: JSON.stringify({\n                success: result.success,\n                response_time_ms: result.response_time_ms,\n                was_proxy: true,\n                verification_failed: result.verification_failed || false\n              })\n            })\n          );\n\n          if (result.success) {\n            // Increment download count\n            await contentMetadataStub.fetch(\n              new Request('http://internal/increment-download', {\n                method: 'POST'\n              })\n            );\n\n            // Determine MIME type\n            const mimeType = extension ? getMimeType(extension) : \n                            (result.headers['Content-Type'] || 'application/octet-stream');\n\n            // Build response headers\n            const headers = {\n              'Content-Type': mimeType,\n              'Cache-Control': 'public, max-age=31536000, immutable',\n              'ETag': `\"${cid}\"`,\n              'Accept-Ranges': result.headers['Accept-Ranges'] || 'bytes',\n              'Access-Control-Allow-Origin': '*',\n              'X-Content-Type-Options': 'nosniff',\n              'X-HashBin-Source': 'alternate',\n              'X-HashBin-Supplier': supplierData.name,\n              'X-HashBin-Supplier-URL': supplier.supplier_url\n            };\n\n            if (result.headers['Content-Length']) {\n              headers['Content-Length'] = result.headers['Content-Length'];\n            }\n\n            if (result.headers['Content-Range']) {\n              headers['Content-Range'] = result.headers['Content-Range'];\n            }\n\n            const status = result.is_partial ? 206 : 200;\n\n            return new Response(result.content, {\n              status,\n              headers\n            });\n          }\n          \n          // This supplier failed, try next one\n          continue;\n        }\n      } catch (error) {\n        console.error(`Error trying supplier ${supplier.supplier_id}:`, error);\n        continue;\n      }\n    }\n\n    // All suppliers failed\n    return null;\n  } catch (error) {\n    console.error('Error in tryAlternateSuppliers:', error);\n    return null;\n  }\n}\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/utils/supplier-scanning.js","messages":[],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]},{"filePath":"/home/runner/work/hashbin.org/hashbin.org/src/utils/supplier-validation.js","messages":[{"ruleId":"complexity","severity":1,"message":"Function 'validateSupplierURL' has a complexity of 21. Maximum allowed is 10.","line":11,"column":8,"nodeType":"FunctionDeclaration","messageId":"complex","endLine":91,"endColumn":2},{"ruleId":"sonarjs/cognitive-complexity","severity":1,"message":"Refactor this function to reduce its Cognitive Complexity from 20 to the 15 allowed.","line":11,"column":17,"nodeType":null,"messageId":"refactorFunction","endLine":11,"endColumn":36}],"suppressedMessages":[],"errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":"/**\n * Supplier Validation Utilities\n * Provides validation for supplier URLs, CIDs, and SSRF protection\n */\n\n/**\n * Validate supplier URL for security and format\n * @param {string} url - URL to validate\n * @returns {{valid: boolean, error?: string}} Validation result\n */\nexport function validateSupplierURL(url) {\n  if (!url || typeof url !== 'string') {\n    return { valid: false, error: 'URL is required' };\n  }\n\n  if (url.trim() === '') {\n    return { valid: false, error: 'URL cannot be empty' };\n  }\n\n  // Parse URL\n  let parsed;\n  try {\n    parsed = new URL(url);\n  } catch (error) {\n    return { valid: false, error: 'Malformed URL' };\n  }\n\n  // Only HTTPS allowed\n  if (parsed.protocol !== 'https:') {\n    return { valid: false, error: 'Only HTTPS URLs are allowed' };\n  }\n\n  // Check for SSRF attempts - block internal/private IPs\n  const hostname = parsed.hostname.toLowerCase();\n  \n  // Block localhost variations\n  if (hostname === 'localhost' || \n      hostname === '127.0.0.1' ||\n      hostname.startsWith('127.') ||\n      hostname === '::1' ||\n      hostname === '0.0.0.0') {\n    return { valid: false, error: 'Localhost URLs are not allowed' };\n  }\n\n  // Block private IP ranges (IPv4)\n  // 10.0.0.0/8\n  if (hostname.startsWith('10.')) {\n    return { valid: false, error: 'Private IP addresses are not allowed' };\n  }\n\n  // 172.16.0.0/12\n  const parts = hostname.split('.');\n  if (parts.length === 4 && parts[0] === '172') {\n    const second = parseInt(parts[1], 10);\n    // Ensure all parts are valid numbers\n    const allValid = parts.every(p => {\n      const num = parseInt(p, 10);\n      return !isNaN(num) && num >= 0 && num <= 255 && p === num.toString();\n    });\n    \n    if (!allValid) {\n      return { valid: false, error: 'Malformed IP address' };\n    }\n    \n    if (second >= 16 && second <= 31) {\n      return { valid: false, error: 'Private IP addresses are not allowed' };\n    }\n  }\n\n  // 192.168.0.0/16\n  if (hostname.startsWith('192.168.')) {\n    return { valid: false, error: 'Private IP addresses are not allowed' };\n  }\n\n  // Block AWS metadata endpoint\n  if (hostname === '169.254.169.254') {\n    return { valid: false, error: 'Cloud metadata endpoints are not allowed' };\n  }\n\n  // Block link-local addresses\n  if (hostname.startsWith('169.254.')) {\n    return { valid: false, error: 'Link-local addresses are not allowed' };\n  }\n\n  // Block .local domains (internal DNS)\n  if (hostname.endsWith('.local')) {\n    return { valid: false, error: 'Internal DNS domains are not allowed' };\n  }\n\n  return { valid: true };\n}\n\n/**\n * Validate supplier type\n * @param {string} type - Supplier type\n * @returns {{valid: boolean, error?: string}} Validation result\n */\nexport function validateSupplierType(type) {\n  const validTypes = ['SINGLE_CID', 'CID_GROUP'];\n  \n  if (!type) {\n    return { valid: false, error: 'Supplier type is required' };\n  }\n\n  if (!validTypes.includes(type)) {\n    return { valid: false, error: `Supplier type must be one of: ${validTypes.join(', ')}` };\n  }\n\n  return { valid: true };\n}\n\n/**\n * Validate supplier name\n * @param {string} name - Supplier name\n * @returns {{valid: boolean, error?: string}} Validation result\n */\nexport function validateSupplierName(name) {\n  if (!name || typeof name !== 'string') {\n    return { valid: false, error: 'Supplier name is required' };\n  }\n\n  if (name.trim() === '') {\n    return { valid: false, error: 'Supplier name cannot be empty' };\n  }\n\n  if (name.length > 100) {\n    return { valid: false, error: 'Supplier name must be 100 characters or less' };\n  }\n\n  return { valid: true };\n}\n\n/**\n * Sanitize supplier name for safe display (prevent XSS)\n * @param {string} name - Supplier name\n * @returns {string} Sanitized name\n */\nexport function sanitizeSupplierName(name) {\n  // HTML entity encoding to prevent XSS\n  return name\n    .replace(/\\\\/g, '&#x5C;')  // Backslash\n    .replace(/&/g, '&amp;')\n    .replace(/</g, '&lt;')\n    .replace(/>/g, '&gt;')\n    .replace(/\"/g, '&quot;')\n    .replace(/'/g, '&#x27;')\n    .replace(/\\//g, '&#x2F;');\n}\n","usedDeprecatedRules":[{"ruleId":"no-process-exit","replacedBy":[]},{"ruleId":"no-extra-semi","replacedBy":[]},{"ruleId":"no-mixed-spaces-and-tabs","replacedBy":[]}]}]