polyrob 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agents/README.md +453 -0
- agents/__init__.py +164 -0
- agents/base_agent.py +743 -0
- agents/personality/character.py +119 -0
- agents/personality/character_manager.py +309 -0
- agents/personality/characters/rob.character.json +56 -0
- agents/personality/characters/trump.character.json +63 -0
- agents/personality/persona_render.py +78 -0
- agents/personality/persona_resolver.py +115 -0
- agents/prompt/__init__.py +58 -0
- agents/prompt/base_prompt.py +92 -0
- agents/prompt/system.py +417 -0
- agents/task/README_PATH_MANAGEMENT.md +141 -0
- agents/task/__init__.py +162 -0
- agents/task/agent/__init__.py +70 -0
- agents/task/agent/agent_state.py +408 -0
- agents/task/agent/async_delegation.py +274 -0
- agents/task/agent/autonomy_state.py +275 -0
- agents/task/agent/conversation.py +75 -0
- agents/task/agent/core/__init__.py +6 -0
- agents/task/agent/core/aux_metering.py +61 -0
- agents/task/agent/core/background_review.py +157 -0
- agents/task/agent/core/construction.py +1179 -0
- agents/task/agent/core/conversational_exit.py +49 -0
- agents/task/agent/core/correspondent_gate.py +227 -0
- agents/task/agent/core/curator.py +246 -0
- agents/task/agent/core/episodic_digest.py +103 -0
- agents/task/agent/core/error_recovery.py +480 -0
- agents/task/agent/core/history_io.py +257 -0
- agents/task/agent/core/llm_provisioning.py +287 -0
- agents/task/agent/core/llm_runner.py +547 -0
- agents/task/agent/core/logging_io.py +276 -0
- agents/task/agent/core/loop_detection.py +262 -0
- agents/task/agent/core/memory_prefetch.py +325 -0
- agents/task/agent/core/memory_writer.py +667 -0
- agents/task/agent/core/model_introspection.py +82 -0
- agents/task/agent/core/model_swap.py +177 -0
- agents/task/agent/core/next_action_internal.py +1331 -0
- agents/task/agent/core/output_validation.py +303 -0
- agents/task/agent/core/project_context.py +272 -0
- agents/task/agent/core/resources.py +93 -0
- agents/task/agent/core/result_processing.py +389 -0
- agents/task/agent/core/run_loop.py +661 -0
- agents/task/agent/core/safety_lifecycle.py +228 -0
- agents/task/agent/core/secret_guard.py +293 -0
- agents/task/agent/core/self_wake.py +280 -0
- agents/task/agent/core/session_metadata.py +104 -0
- agents/task/agent/core/step.py +781 -0
- agents/task/agent/core/step_execution.py +398 -0
- agents/task/agent/core/step_telemetry.py +222 -0
- agents/task/agent/core/test_aux_metering.py +50 -0
- agents/task/agent/core/test_billing_failover.py +68 -0
- agents/task/agent/core/test_fatal_error_classifier.py +38 -0
- agents/task/agent/core/test_judge_validation.py +275 -0
- agents/task/agent/core/test_model_swap.py +190 -0
- agents/task/agent/core/test_reflection_offload.py +54 -0
- agents/task/agent/core/turn_input.py +8 -0
- agents/task/agent/core/untrusted_wrap.py +103 -0
- agents/task/agent/core/user_ingress.py +228 -0
- agents/task/agent/hitl_manager.py +396 -0
- agents/task/agent/log_sanitize.py +36 -0
- agents/task/agent/message_manager/config.py +97 -0
- agents/task/agent/message_manager/service.py +758 -0
- agents/task/agent/message_manager/tests.py +305 -0
- agents/task/agent/message_manager/tool_call_builder.py +539 -0
- agents/task/agent/message_manager/tool_message_repair.py +364 -0
- agents/task/agent/message_manager/views.py +143 -0
- agents/task/agent/messages/__init__.py +8 -0
- agents/task/agent/messages/builders.py +309 -0
- agents/task/agent/messages/compactor.py +611 -0
- agents/task/agent/messages/context_references.py +271 -0
- agents/task/agent/messages/filters.py +333 -0
- agents/task/agent/messages/guidance.py +328 -0
- agents/task/agent/messages/persistence.py +448 -0
- agents/task/agent/messages/retrieval.py +506 -0
- agents/task/agent/messages/sqlite_persistence.py +85 -0
- agents/task/agent/messages/test_compaction_upgrade.py +349 -0
- agents/task/agent/messages/test_persistence_subdir_fix.py +28 -0
- agents/task/agent/messages/test_sqlite_persistence.py +54 -0
- agents/task/agent/messages/token_counter.py +532 -0
- agents/task/agent/orchestrator.py +776 -0
- agents/task/agent/profile_manager.py +105 -0
- agents/task/agent/profile_registry.py +173 -0
- agents/task/agent/prompts.py +1090 -0
- agents/task/agent/scenario_registry.py +226 -0
- agents/task/agent/service.py +509 -0
- agents/task/agent/session.py +984 -0
- agents/task/agent/skill_discovery.py +110 -0
- agents/task/agent/skill_frontmatter.py +94 -0
- agents/task/agent/skill_manager.py +1201 -0
- agents/task/agent/skill_store.py +627 -0
- agents/task/agent/skill_validation.py +56 -0
- agents/task/agent/skill_writer.py +492 -0
- agents/task/agent/sub_agent_manager.py +1137 -0
- agents/task/agent/test_session_disk_recovery.py +34 -0
- agents/task/agent/tests.py +513 -0
- agents/task/agent/tool_call_tracker.py +511 -0
- agents/task/agent/views.py +469 -0
- agents/task/config.py +432 -0
- agents/task/constants.py +1384 -0
- agents/task/flag_defaults.py +63 -0
- agents/task/goals/__init__.py +1 -0
- agents/task/goals/autonomy_marker.py +28 -0
- agents/task/goals/board.py +690 -0
- agents/task/goals/completion_judge.py +247 -0
- agents/task/goals/context.py +62 -0
- agents/task/goals/dispatcher.py +848 -0
- agents/task/goals/escalation.py +102 -0
- agents/task/goals/planner.py +114 -0
- agents/task/logging_config.py +155 -0
- agents/task/path.py +991 -0
- agents/task/robust_parse_config.py +400 -0
- agents/task/runtime/__init__.py +0 -0
- agents/task/runtime/run_as_session.py +130 -0
- agents/task/runtime_safety.py +308 -0
- agents/task/session/__init__.py +5 -0
- agents/task/session/browser_pool.py +130 -0
- agents/task/session/cleanup.py +612 -0
- agents/task/session/execution.py +397 -0
- agents/task/session/feed.py +200 -0
- agents/task/session/hitl_ingress.py +201 -0
- agents/task/session/hooks.py +83 -0
- agents/task/session/multi_agent.py +243 -0
- agents/task/session/workspace.py +91 -0
- agents/task/session_registry.py +91 -0
- agents/task/session_route.py +35 -0
- agents/task/sqlite_session_registry.py +261 -0
- agents/task/surface_config.py +185 -0
- agents/task/telemetry/__init__.py +58 -0
- agents/task/telemetry/event_log.py +180 -0
- agents/task/telemetry/formatters.py +642 -0
- agents/task/telemetry/manager.py +571 -0
- agents/task/telemetry/memory_events.py +66 -0
- agents/task/telemetry/self_events.py +53 -0
- agents/task/telemetry/sequence.py +160 -0
- agents/task/telemetry/service.py +1265 -0
- agents/task/telemetry/views.py +882 -0
- agents/task/templates.py +98 -0
- agents/task/test_aux_chain.py +151 -0
- agents/task/test_aux_router.py +41 -0
- agents/task/tests/test_parse_robustness.py +170 -0
- agents/task/tool_defaults.py +110 -0
- agents/task/utils.py +762 -0
- agents/task/utils_json.py +768 -0
- agents/task/utils_webview.py +86 -0
- agents/task/workspace_context.py +229 -0
- agents/task_agent_lite.py +2398 -0
- api/README.md +683 -0
- api/TASK_API_DOCS.md +151 -0
- api/__init__.py +0 -0
- api/a2a/__init__.py +37 -0
- api/a2a/agent_card.py +337 -0
- api/a2a/client.py +657 -0
- api/a2a/endpoints.py +375 -0
- api/a2a/models.py +268 -0
- api/a2a/streaming.py +419 -0
- api/a2a/task_handler.py +929 -0
- api/admin_endpoints.py +1584 -0
- api/app.py +923 -0
- api/auth_constants.py +28 -0
- api/auth_endpoints.py +402 -0
- api/auth_state.py +36 -0
- api/chat_via_task.py +42 -0
- api/conversation_manager.py +231 -0
- api/dependencies.py +186 -0
- api/eip8004_endpoints.py +452 -0
- api/hyperliquid_models.py +174 -0
- api/hyperliquid_routes.py +450 -0
- api/interfaces.py +207 -0
- api/jwt_middleware.py +83 -0
- api/kb/__init__.py +1 -0
- api/kb/endpoints.py +314 -0
- api/mcp_models.py +279 -0
- api/mcp_routes.py +438 -0
- api/middleware.py +526 -0
- api/models.py +176 -0
- api/openai_compat/__init__.py +0 -0
- api/openai_compat/model_map.py +25 -0
- api/openai_compat/models.py +59 -0
- api/openai_compat/router.py +140 -0
- api/payment_endpoints.py +444 -0
- api/payment_verification.py +138 -0
- api/polymarket_models.py +155 -0
- api/polymarket_routes.py +501 -0
- api/pricing_endpoints.py +89 -0
- api/session_routing.py +54 -0
- api/skill_endpoints.py +453 -0
- api/task_http_api.py +1856 -0
- api/webhooks.py +52 -0
- api/x402_endpoints.py +280 -0
- avatar/README.md +44 -0
- avatar/__init__.py +1 -0
- avatar/config/rob.json +7 -0
- avatar/mindprint.js +395 -0
- avatar/renders/rob.meta.json +20 -0
- avatar/renders/rob.png +0 -0
- avatar/studio.html +373 -0
- avatar/webview/avatar-live.js +44 -0
- cli/README.md +89 -0
- cli/__init__.py +0 -0
- cli/cli_surface.py +67 -0
- cli/commands/__init__.py +0 -0
- cli/commands/_bootstrap.py +49 -0
- cli/commands/_errors.py +53 -0
- cli/commands/chat.py +973 -0
- cli/commands/config.py +100 -0
- cli/commands/dashboard.py +103 -0
- cli/commands/doctor.py +222 -0
- cli/commands/email.py +174 -0
- cli/commands/gateway.py +346 -0
- cli/commands/goals.py +509 -0
- cli/commands/init.py +276 -0
- cli/commands/journey.py +30 -0
- cli/commands/kb.py +280 -0
- cli/commands/model.py +107 -0
- cli/commands/owner.py +431 -0
- cli/commands/pfp.py +232 -0
- cli/commands/run.py +414 -0
- cli/commands/serve.py +41 -0
- cli/commands/session.py +684 -0
- cli/commands/skill_install.py +786 -0
- cli/commands/skills.py +128 -0
- cli/commands/subagents.py +100 -0
- cli/commands/surface.py +81 -0
- cli/commands/telegram.py +185 -0
- cli/commands/todos.py +209 -0
- cli/commands/tools.py +102 -0
- cli/commands/update.py +316 -0
- cli/commands/whatsapp.py +175 -0
- cli/config_store.py +276 -0
- cli/gitignore.py +43 -0
- cli/inventory.py +46 -0
- cli/keys.py +101 -0
- cli/persona.py +17 -0
- cli/polyrob.py +137 -0
- cli/ui/__init__.py +64 -0
- cli/ui/activity.py +141 -0
- cli/ui/app.py +538 -0
- cli/ui/banner.py +233 -0
- cli/ui/blocks.py +534 -0
- cli/ui/bootstrap_notice.py +47 -0
- cli/ui/commands/__init__.py +41 -0
- cli/ui/commands/h_cron.py +64 -0
- cli/ui/commands/h_journey.py +164 -0
- cli/ui/commands/h_kb.py +121 -0
- cli/ui/commands/h_learn.py +100 -0
- cli/ui/commands/h_mcp.py +215 -0
- cli/ui/commands/h_self.py +113 -0
- cli/ui/commands/h_skills.py +314 -0
- cli/ui/commands/handlers.py +1672 -0
- cli/ui/commands/registry.py +426 -0
- cli/ui/dialog.py +425 -0
- cli/ui/event_registry.py +110 -0
- cli/ui/event_specs.py +45 -0
- cli/ui/events.py +433 -0
- cli/ui/identity.py +15 -0
- cli/ui/lifecycle.py +159 -0
- cli/ui/live_hooks.py +43 -0
- cli/ui/model_selector.py +532 -0
- cli/ui/persistent_loop.py +181 -0
- cli/ui/pick.py +113 -0
- cli/ui/plain_renderer.py +351 -0
- cli/ui/renderer.py +384 -0
- cli/ui/rich_renderer.py +533 -0
- cli/ui/secrets.py +115 -0
- cli/ui/state.py +376 -0
- cli/ui/statusbar.py +231 -0
- cli/ui/streaming.py +254 -0
- cli/ui/terminal_render.py +105 -0
- cli/ui/theme.py +132 -0
- cli/update/__init__.py +6 -0
- cli/update/context.py +121 -0
- cli/update/detect.py +176 -0
- cli/update/engine.py +95 -0
- cli/update/migrate_guarded.py +55 -0
- cli/update/process_guard.py +215 -0
- cli/update/runners.py +78 -0
- cli/update/snapshot.py +270 -0
- cli/update/versions.py +205 -0
- core/README.md +377 -0
- core/__init__.py +174 -0
- core/assets.py +87 -0
- core/async_bridge.py +76 -0
- core/autonomy_runtime.py +259 -0
- core/base_component.py +302 -0
- core/bootstrap.py +681 -0
- core/bot.py +310 -0
- core/config.py +833 -0
- core/constants.py +126 -0
- core/container.py +462 -0
- core/db_manifest.py +101 -0
- core/embedding.py +57 -0
- core/env.py +39 -0
- core/exceptions.py +287 -0
- core/flags.py +144 -0
- core/flags_catalog.py +345 -0
- core/home_migration.py +62 -0
- core/identity.py +118 -0
- core/initialization.py +1004 -0
- core/instance.py +537 -0
- core/interactive_gate.py +96 -0
- core/logging.py +436 -0
- core/owner_doc_writer.py +176 -0
- core/pairing.py +193 -0
- core/path_safety.py +20 -0
- core/paths.py +27 -0
- core/payment_config.py +21 -0
- core/permissions.py +322 -0
- core/runtime_config.py +96 -0
- core/runtime_paths.py +135 -0
- core/seams.py +35 -0
- core/secret_scan.py +25 -0
- core/secret_scrub.py +71 -0
- core/secrets.py +35 -0
- core/security_logging_filter.py +148 -0
- core/self_context_writer.py +333 -0
- core/self_evolution.py +294 -0
- core/session_context.py +47 -0
- core/sqlite_util.py +59 -0
- core/surfaces/__init__.py +16 -0
- core/surfaces/access.py +118 -0
- core/surfaces/binding.py +88 -0
- core/surfaces/bootstrap.py +78 -0
- core/surfaces/circuit.py +168 -0
- core/surfaces/continuity.py +77 -0
- core/surfaces/correspondents.py +228 -0
- core/surfaces/dispatcher.py +216 -0
- core/surfaces/envelopes.py +75 -0
- core/surfaces/gc.py +30 -0
- core/surfaces/idempotency.py +57 -0
- core/surfaces/inbound_webhook.py +124 -0
- core/surfaces/media.py +32 -0
- core/surfaces/message_router.py +91 -0
- core/surfaces/outbound_allowlist.py +80 -0
- core/surfaces/outbound_dispatcher.py +110 -0
- core/surfaces/outbound_mirror.py +37 -0
- core/surfaces/outbound_queue.py +107 -0
- core/surfaces/outbound_target.py +12 -0
- core/surfaces/owner_admin.py +29 -0
- core/surfaces/proactive.py +78 -0
- core/surfaces/progress.py +108 -0
- core/surfaces/rate_bucket.py +21 -0
- core/surfaces/registry.py +51 -0
- core/surfaces/rendering.py +53 -0
- core/surfaces/send_policy.py +14 -0
- core/surfaces/serialize.py +24 -0
- core/surfaces/session_chat_registry.py +112 -0
- core/surfaces/session_policy.py +60 -0
- core/surfaces/surface.py +288 -0
- core/surfaces/transcription.py +88 -0
- core/surfaces/voice_echo.py +26 -0
- core/surfaces/voice_guard.py +23 -0
- core/tickers.py +174 -0
- core/tool_catalog.py +192 -0
- core/version.py +75 -0
- core/wallet/__init__.py +5 -0
- core/wallet/agent_wallet.py +59 -0
- core/wallet/audit_sink.py +61 -0
- core/wallet/config.py +70 -0
- core/wallet/factory.py +87 -0
- core/wallet/policy.py +104 -0
- core/wallet/signer.py +51 -0
- cron/README.md +65 -0
- cron/__init__.py +0 -0
- cron/delivery.py +277 -0
- cron/digest.py +124 -0
- cron/jobs.py +207 -0
- cron/runner.py +297 -0
- cron/schedule.py +194 -0
- cron/scheduler.py +201 -0
- cron/service.py +57 -0
- cron/wake_gate.py +223 -0
- data/__init__.py +1 -0
- data/prompts/autov2_prompts.json +87 -0
- data/prompts/skills/browser-automation/SKILL.md +43 -0
- data/prompts/skills/coding-workflow/SKILL.md +36 -0
- data/prompts/skills/crypto-trading-safety/SKILL.md +53 -0
- data/prompts/skills/document-writing/SKILL.md +33 -0
- data/prompts/skills/email-comms/SKILL.md +33 -0
- data/prompts/skills/file-data-ops/SKILL.md +35 -0
- data/prompts/skills/hyperliquid-account-review/SKILL.md +48 -0
- data/prompts/skills/hyperliquid-market-data/SKILL.md +52 -0
- data/prompts/skills/hyperliquid-trading/SKILL.md +59 -0
- data/prompts/skills/lead-research/SKILL.md +86 -0
- data/prompts/skills/market-research-brief/SKILL.md +104 -0
- data/prompts/skills/person-analyzer/SKILL.md +124 -0
- data/prompts/skills/polymarket-market-research/SKILL.md +54 -0
- data/prompts/skills/polymarket-portfolio-review/SKILL.md +50 -0
- data/prompts/skills/polymarket-trading/SKILL.md +55 -0
- data/prompts/skills/presentation-creator/SKILL.md +36 -0
- data/prompts/skills/project-analyzer/SKILL.md +169 -0
- data/prompts/skills/rules.json +268 -0
- data/prompts/skills/secret-handling/SKILL.md +20 -0
- data/prompts/skills/skill-authoring/SKILL.md +31 -0
- data/prompts/skills/skill-security-review/SKILL.md +27 -0
- data/prompts/skills/social-discovery/SKILL.md +57 -0
- data/prompts/skills/task-planning/SKILL.md +33 -0
- data/prompts/skills/web-research/SKILL.md +39 -0
- data/prompts/skills/web-scraping/SKILL.md +73 -0
- data/prompts/skills/x-engagement/SKILL.md +69 -0
- data/prompts/system_prompts.json +4 -0
- data/subscription_channels.json +5 -0
- modules/README.md +503 -0
- modules/__init__.py +124 -0
- modules/auth/__init__.py +13 -0
- modules/auth/api_key_manager.py +157 -0
- modules/auth/identity_mapper.py +380 -0
- modules/auth/siwe_auth.py +273 -0
- modules/auth/tier_manager.py +173 -0
- modules/base_module.py +190 -0
- modules/credits/__init__.py +29 -0
- modules/credits/balance_manager.py +237 -0
- modules/credits/cost_utils.py +129 -0
- modules/credits/pricing.py +139 -0
- modules/credits/unified_ledger.py +148 -0
- modules/credits/usage_meter.py +310 -0
- modules/credits/usage_tracker.py +674 -0
- modules/database/__init__.py +33 -0
- modules/database/audit_log.py +322 -0
- modules/database/auth_tables.py +276 -0
- modules/database/connection.py +644 -0
- modules/database/connection_pool.py +305 -0
- modules/database/conversation_contexts.py +446 -0
- modules/database/database_manager.py +237 -0
- modules/database/hyperliquid.py +375 -0
- modules/database/polymarket.py +574 -0
- modules/database/user_mcp_servers.py +728 -0
- modules/database/user_profiles.py +562 -0
- modules/database/utils.py +107 -0
- modules/database/x402_tables.py +92 -0
- modules/eip8004/README.md +1069 -0
- modules/eip8004/__init__.py +65 -0
- modules/eip8004/contracts.py +498 -0
- modules/eip8004/models.py +271 -0
- modules/eip8004/registration.py +148 -0
- modules/eip8004/reputation.py +367 -0
- modules/eip8004/validation.py +277 -0
- modules/llm/__init__.py +97 -0
- modules/llm/adapters.py +963 -0
- modules/llm/anthropic_client.py +831 -0
- modules/llm/available_models.py +106 -0
- modules/llm/brain_scrubber.py +137 -0
- modules/llm/cache_hints.py +162 -0
- modules/llm/deepseek_client.py +562 -0
- modules/llm/gemini_client.py +1367 -0
- modules/llm/llm_client.py +1139 -0
- modules/llm/llm_client_registry.py +188 -0
- modules/llm/llm_factory.py +237 -0
- modules/llm/llm_manager.py +1036 -0
- modules/llm/messages.py +374 -0
- modules/llm/model_registry.py +1935 -0
- modules/llm/nvidia_client.py +125 -0
- modules/llm/openai_client.py +800 -0
- modules/llm/openrouter_client.py +969 -0
- modules/llm/profiles.py +178 -0
- modules/llm/test_anthropic_cache_capture.py +35 -0
- modules/llm/test_cached_pricing_derivation.py +24 -0
- modules/llm/think_scrubber.py +444 -0
- modules/llm/token_counter.py +268 -0
- modules/memory/__init__.py +35 -0
- modules/memory/backend_factory.py +83 -0
- modules/memory/cache_manager.py +186 -0
- modules/memory/episodic.py +108 -0
- modules/memory/local_vector_memory_provider.py +552 -0
- modules/memory/memory_manager.py +225 -0
- modules/memory/models.py +312 -0
- modules/memory/provider.py +210 -0
- modules/memory/registry.py +205 -0
- modules/memory/sqlite_memory_provider.py +767 -0
- modules/memory/task/__init__.py +52 -0
- modules/memory/task/compaction_manager.py +157 -0
- modules/memory/task/context_retriever.py +809 -0
- modules/memory/task/hierarchical_memory.py +1002 -0
- modules/memory/task/lexical_retriever.py +71 -0
- modules/memory/task/null_context_manager.py +213 -0
- modules/memory/task/phase_manager.py +625 -0
- modules/memory/task/reflection_service.py +157 -0
- modules/memory/task/semantic_retriever.py +218 -0
- modules/memory/task/task_context_manager.py +1165 -0
- modules/memory/task/test_forgetting_engages.py +24 -0
- modules/memory/task/test_reflection_llm.py +39 -0
- modules/memory/task/test_threat_scan.py +53 -0
- modules/memory/task/threat_scan.py +90 -0
- modules/memory/test_sqlite_memory_provider.py +106 -0
- modules/memory/user_profile_manager.py +527 -0
- modules/payments/__init__.py +11 -0
- modules/payments/deposit_monitor.py +478 -0
- modules/payments/price_oracle.py +70 -0
- modules/payments/treasury_sweeper.py +401 -0
- modules/payments/wallet_generator.py +145 -0
- modules/pfp/__init__.py +1 -0
- modules/pfp/config.py +92 -0
- modules/pfp/mesh.py +565 -0
- modules/pfp/push.py +69 -0
- modules/pfp/renderer.py +85 -0
- modules/pfp/store.py +82 -0
- modules/skills/__init__.py +1 -0
- modules/skills/skill_usage.py +248 -0
- modules/transcription/__init__.py +24 -0
- modules/transcription/base.py +20 -0
- modules/transcription/faster_whisper_transcriber.py +55 -0
- modules/x402/README.md +249 -0
- modules/x402/__init__.py +22 -0
- modules/x402/invoicing.py +416 -0
- modules/x402/middleware.py +364 -0
- modules/x402/settlement_watcher.py +145 -0
- modules/x402/x402_integration.py +344 -0
- polyrob-0.5.0.dist-info/METADATA +482 -0
- polyrob-0.5.0.dist-info/RECORD +745 -0
- polyrob-0.5.0.dist-info/WHEEL +5 -0
- polyrob-0.5.0.dist-info/entry_points.txt +2 -0
- polyrob-0.5.0.dist-info/licenses/LICENSE +21 -0
- polyrob-0.5.0.dist-info/top_level.txt +12 -0
- surfaces/README.md +70 -0
- surfaces/__init__.py +0 -0
- surfaces/email/__init__.py +0 -0
- surfaces/email/dedup.py +44 -0
- surfaces/email/harness.py +187 -0
- surfaces/email/inbound.py +159 -0
- surfaces/email/seed.py +60 -0
- surfaces/email/surface.py +105 -0
- surfaces/telegram/__init__.py +0 -0
- surfaces/telegram/dedup.py +83 -0
- surfaces/telegram/harness.py +886 -0
- surfaces/telegram/inbound.py +145 -0
- surfaces/telegram/interactive_tools.py +49 -0
- surfaces/telegram/markdown.py +347 -0
- surfaces/telegram/rate_limit.py +167 -0
- surfaces/telegram/surface.py +187 -0
- surfaces/telegram/voice.py +52 -0
- surfaces/whatsapp/__init__.py +0 -0
- surfaces/whatsapp/client.py +51 -0
- surfaces/whatsapp/harness.py +62 -0
- surfaces/whatsapp/inbound.py +132 -0
- surfaces/whatsapp/surface.py +64 -0
- surfaces/whatsapp/window.py +34 -0
- tools/README.md +578 -0
- tools/__init__.py +424 -0
- tools/alchemy/__init__.py +5 -0
- tools/alchemy/alchemy_tool.py +389 -0
- tools/anysite/__init__.py +17 -0
- tools/anysite/client.py +88 -0
- tools/anysite/tool.py +89 -0
- tools/base_tool.py +468 -0
- tools/browser/__init__.py +22 -0
- tools/browser/actions.py +118 -0
- tools/browser/browser.py +1457 -0
- tools/browser/browser_manager.py +690 -0
- tools/browser/context.py +1748 -0
- tools/browser/playwright_utils.py +137 -0
- tools/browser/views.py +56 -0
- tools/code_exec/SANDBOX_SECURITY.md +144 -0
- tools/code_exec/__init__.py +178 -0
- tools/code_exec/backend.py +73 -0
- tools/code_exec/backends/__init__.py +1 -0
- tools/code_exec/backends/docker.py +779 -0
- tools/code_exec/backends/local_subprocess.py +154 -0
- tools/code_exec/env_policy.py +45 -0
- tools/code_exec/result.py +42 -0
- tools/code_exec/sandbox_guard.py +59 -0
- tools/code_exec/tool.py +227 -0
- tools/coding/__init__.py +50 -0
- tools/coding/edit.py +107 -0
- tools/coding/search.py +113 -0
- tools/coding/tool.py +353 -0
- tools/collabland/__init__.py +5 -0
- tools/collabland/collabland_tool.py +471 -0
- tools/controller/__init__.py +0 -0
- tools/controller/_helpers.py +165 -0
- tools/controller/action_registration.py +1765 -0
- tools/controller/approval.py +244 -0
- tools/controller/approval_interactive.py +90 -0
- tools/controller/delegation.py +195 -0
- tools/controller/execution.py +797 -0
- tools/controller/execution_context.py +141 -0
- tools/controller/hooks.py +135 -0
- tools/controller/introspection.py +344 -0
- tools/controller/mcp_registrar.py +190 -0
- tools/controller/message_send.py +31 -0
- tools/controller/registry/__init__.py +4 -0
- tools/controller/registry/schema_generators.py +395 -0
- tools/controller/registry/schema_sanitizer.py +406 -0
- tools/controller/registry/service.py +1266 -0
- tools/controller/registry/views.py +151 -0
- tools/controller/service.py +409 -0
- tools/controller/tool_management.py +334 -0
- tools/controller/types.py +28 -0
- tools/controller/views.py +450 -0
- tools/cronjob_tools.py +166 -0
- tools/crypto_trade_gate.py +59 -0
- tools/descriptors.py +452 -0
- tools/dom/__init__.py +0 -0
- tools/dom/history_tree_processor/service.py +244 -0
- tools/dom/history_tree_processor/view.py +99 -0
- tools/dom/service.py +471 -0
- tools/dom/views.py +346 -0
- tools/email_tool.py +403 -0
- tools/exceptions.py +169 -0
- tools/filesystem.py +1038 -0
- tools/filesystem_docproc.py +331 -0
- tools/filesystem_pdf.py +755 -0
- tools/git/__init__.py +42 -0
- tools/git/tool.py +258 -0
- tools/github/__init__.py +33 -0
- tools/github/client.py +94 -0
- tools/github/tool.py +237 -0
- tools/goal_tools.py +309 -0
- tools/hyperliquid/__init__.py +56 -0
- tools/hyperliquid/models.py +233 -0
- tools/hyperliquid/service.py +1680 -0
- tools/knowledge_ingest.py +950 -0
- tools/mcp/README.md +535 -0
- tools/mcp/__init__.py +52 -0
- tools/mcp/catalog.py +115 -0
- tools/mcp/config.py +337 -0
- tools/mcp/mcp_tool.py +1415 -0
- tools/mcp/param_coercion.py +289 -0
- tools/mcp/protocol.py +1520 -0
- tools/mcp/rate_limit.py +50 -0
- tools/mcp/security.py +424 -0
- tools/mcp/self_install.py +111 -0
- tools/mcp/server_manager.py +829 -0
- tools/mcp/subscriptions.py +72 -0
- tools/mcp/user_mcp_service.py +899 -0
- tools/mcp/validation_tracker.py +89 -0
- tools/mcp/views.py +346 -0
- tools/oauth/__init__.py +17 -0
- tools/oauth/manager.py +75 -0
- tools/oauth/provider.py +69 -0
- tools/oauth/providers/__init__.py +1 -0
- tools/oauth/providers/generic_oauth2.py +96 -0
- tools/perplexity_tool.py +280 -0
- tools/polymarket/__init__.py +41 -0
- tools/polymarket/clob_adapter.py +76 -0
- tools/polymarket/models.py +182 -0
- tools/polymarket/service.py +2082 -0
- tools/self_env/__init__.py +61 -0
- tools/self_env/tool.py +274 -0
- tools/shell/__init__.py +80 -0
- tools/shell/backend_pool.py +118 -0
- tools/shell/discipline.py +62 -0
- tools/shell/executor.py +157 -0
- tools/shell/loopback_allow.py +87 -0
- tools/shell/process_registry.py +113 -0
- tools/shell/process_tool.py +131 -0
- tools/shell/state.py +129 -0
- tools/shell/tool.py +151 -0
- tools/task_tool.py +637 -0
- tools/twitter_tool.py +2086 -0
- tools/user_directory.py +179 -0
- tools/web_fetch/__init__.py +3 -0
- tools/web_fetch/fetcher.py +144 -0
- tools/web_fetch/render.py +44 -0
- tools/web_fetch/tool.py +60 -0
- tools/x402/__init__.py +60 -0
- tools/x402/client.py +47 -0
- tools/x402/invoice_tool.py +143 -0
- tools/x402/real_client.py +250 -0
- tools/x402/service.py +131 -0
- utils/README.md +498 -0
- utils/__init__.py +46 -0
- utils/auth_utils.py +114 -0
- utils/bounded_collections.py +162 -0
- utils/circuit_breaker.py +389 -0
- utils/gif_utils.py +435 -0
- utils/markdown_utils.py +291 -0
- utils/message_utils.py +147 -0
- utils/metrics.py +36 -0
- utils/path_validator.py +227 -0
- utils/rate_limit_manager.py +399 -0
- utils/result_size.py +296 -0
- utils/time_utils.py +206 -0
- utils/user_utils.py +188 -0
- webview/README.md +111 -0
- webview/RENAME_AND_ALIGN_HANDOFF.md +200 -0
- webview/__init__.py +8 -0
- webview/activity.py +636 -0
- webview/owner_auth.py +108 -0
- webview/pages.py +448 -0
- webview/repair_sessions.py +399 -0
- webview/server.py +4323 -0
- webview/server_launcher.py +112 -0
- webview/static/css/activity.css +89 -0
- webview/static/css/chat.css +1772 -0
- webview/static/css/components.css +1190 -0
- webview/static/css/config-panel.css +651 -0
- webview/static/css/pages/admin.css +766 -0
- webview/static/css/pages/profile.css +231 -0
- webview/static/css/pages/settings.css +297 -0
- webview/static/css/pages/signin.css +316 -0
- webview/static/css/style.css +4250 -0
- webview/static/css/variables.css +147 -0
- webview/static/css/workspace-fullwidth-fix.css +109 -0
- webview/static/img/favicon.ico +0 -0
- webview/static/js/activity.js +394 -0
- webview/static/js/admin/activity.js +186 -0
- webview/static/js/admin/dashboard.js +163 -0
- webview/static/js/admin/user_detail.js +400 -0
- webview/static/js/admin/users.js +174 -0
- webview/static/js/admin/utils.js +214 -0
- webview/static/js/chat.js +3178 -0
- webview/static/js/config-panel.js +522 -0
- webview/static/js/constants.js +20 -0
- webview/static/js/error-handler.js +226 -0
- webview/static/js/ethers.min.js +1 -0
- webview/static/js/event-filter.js +137 -0
- webview/static/js/event-store.js +351 -0
- webview/static/js/file-attachments.js +374 -0
- webview/static/js/file-loader.js +163 -0
- webview/static/js/index.js +103 -0
- webview/static/js/performance-utils.js +196 -0
- webview/static/js/profile.js +396 -0
- webview/static/js/screenshot.js +386 -0
- webview/static/js/session.js +1527 -0
- webview/static/js/settings.js +1288 -0
- webview/static/js/sidebar-data.js +766 -0
- webview/static/js/sidebar-toggle.js +296 -0
- webview/static/js/socket.io.min.js +7 -0
- webview/static/js/stats.js +355 -0
- webview/static/js/ui-utils.js +420 -0
- webview/static/js/workspace.js +966 -0
- webview/stats_service.py +613 -0
- webview/templates/__init__.py +1 -0
- webview/templates/__pycache__/__init__.cpython-311.pyc +0 -0
- webview/templates/activity.html +36 -0
- webview/templates/admin/activity.html +122 -0
- webview/templates/admin/dashboard.html +112 -0
- webview/templates/admin/user_detail.html +228 -0
- webview/templates/admin/users.html +105 -0
- webview/templates/autonomy.html +60 -0
- webview/templates/error.html +133 -0
- webview/templates/finance.html +90 -0
- webview/templates/identity.html +73 -0
- webview/templates/index.html +42 -0
- webview/templates/layout.html +125 -0
- webview/templates/memory.html +59 -0
- webview/templates/owner_login.html +15 -0
- webview/templates/profile.html +121 -0
- webview/templates/session.html +312 -0
- webview/templates/settings.html +534 -0
- webview/templates/sidebar.html +112 -0
- webview/templates/signin.html +213 -0
- webview/templates/status.html +14 -0
- webview/templates/system.html +45 -0
- webview/webgate.py +196 -0
api/admin_endpoints.py
ADDED
|
@@ -0,0 +1,1584 @@
|
|
|
1
|
+
"""Admin management endpoints for user and credit management.
|
|
2
|
+
|
|
3
|
+
Phase 1 MVP endpoints:
|
|
4
|
+
- User search: GET /api/admin/users/search
|
|
5
|
+
- Token verification: POST /api/admin/users/{user_id}/verify-token
|
|
6
|
+
- User audit trail: GET /api/admin/users/{user_id}/audit
|
|
7
|
+
- User sessions: GET /api/admin/users/{user_id}/sessions
|
|
8
|
+
- Block/unblock: POST /api/admin/users/{user_id}/block, /unblock
|
|
9
|
+
- Enhanced stats: GET /api/admin/stats/dashboard
|
|
10
|
+
- Activity feed: GET /api/admin/activity
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter, HTTPException, Request, Depends, Query
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
from typing import List, Optional, Dict, Any, Literal
|
|
16
|
+
import logging
|
|
17
|
+
from datetime import datetime, timedelta
|
|
18
|
+
from api.auth_constants import (
|
|
19
|
+
VALID_ROLES,
|
|
20
|
+
VALID_TIERS,
|
|
21
|
+
validate_role,
|
|
22
|
+
validate_assignable_role,
|
|
23
|
+
validate_tier,
|
|
24
|
+
extract_admin_info,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_client_ip(request: Request) -> str:
|
|
31
|
+
"""Get client IP from request, handling proxies."""
|
|
32
|
+
# Check X-Forwarded-For header (set by nginx/proxies)
|
|
33
|
+
forwarded_for = request.headers.get("X-Forwarded-For")
|
|
34
|
+
if forwarded_for:
|
|
35
|
+
# Take the first IP in the chain (original client)
|
|
36
|
+
return forwarded_for.split(",")[0].strip()
|
|
37
|
+
# Fall back to direct client IP
|
|
38
|
+
return request.client.host if request.client else "unknown"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def get_audit_logger():
|
|
42
|
+
"""Get audit logger instance."""
|
|
43
|
+
try:
|
|
44
|
+
from core.container import DependencyContainer
|
|
45
|
+
container = DependencyContainer.get_instance()
|
|
46
|
+
db = container.get_service('database_manager')
|
|
47
|
+
if db:
|
|
48
|
+
from modules.database.audit_log import AuditLogger
|
|
49
|
+
return AuditLogger(db)
|
|
50
|
+
except Exception as e:
|
|
51
|
+
logger.warning(f"Could not get audit logger: {e}")
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
router = APIRouter(prefix="/admin", tags=["admin"])
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# Request/Response Models
|
|
58
|
+
|
|
59
|
+
class AddCreditsRequest(BaseModel):
|
|
60
|
+
"""Request to add credits to user."""
|
|
61
|
+
amount: int
|
|
62
|
+
reason: str
|
|
63
|
+
transaction_type: str = "admin_grant"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SetRoleRequest(BaseModel):
|
|
67
|
+
"""Request to set user role."""
|
|
68
|
+
role: str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class SetTierRequest(BaseModel):
|
|
72
|
+
"""Request to set user tier."""
|
|
73
|
+
tier: str
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class UserInfo(BaseModel):
|
|
77
|
+
"""User information response."""
|
|
78
|
+
user_id: str
|
|
79
|
+
wallet_address: Optional[str]
|
|
80
|
+
email: Optional[str]
|
|
81
|
+
tier: Optional[str]
|
|
82
|
+
role: str
|
|
83
|
+
den_token_count: int
|
|
84
|
+
balance: int
|
|
85
|
+
lifetime_earned: int
|
|
86
|
+
lifetime_spent: int
|
|
87
|
+
created_at: str
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class CreditTransaction(BaseModel):
|
|
91
|
+
"""Credit transaction info."""
|
|
92
|
+
amount: int
|
|
93
|
+
transaction_type: str
|
|
94
|
+
reason: str
|
|
95
|
+
balance_before: int
|
|
96
|
+
balance_after: int
|
|
97
|
+
timestamp: str
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class BlockUserRequest(BaseModel):
|
|
101
|
+
"""Request to block a user."""
|
|
102
|
+
reason: str = Field(..., min_length=1, description="Reason for blocking")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class UserSearchResult(BaseModel):
|
|
106
|
+
"""User search result."""
|
|
107
|
+
user_id: str
|
|
108
|
+
wallet_address: Optional[str]
|
|
109
|
+
email: Optional[str]
|
|
110
|
+
tier: Optional[str]
|
|
111
|
+
role: str
|
|
112
|
+
den_token_count: int
|
|
113
|
+
balance: int
|
|
114
|
+
created_at: str
|
|
115
|
+
is_blocked: bool = False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class TokenVerificationResult(BaseModel):
|
|
119
|
+
"""Token verification result."""
|
|
120
|
+
previous_count: int
|
|
121
|
+
new_count: int
|
|
122
|
+
previous_tier: str
|
|
123
|
+
new_tier: str
|
|
124
|
+
tier_changed: bool
|
|
125
|
+
token_ids: List[str]
|
|
126
|
+
verified_at: str
|
|
127
|
+
bonuses_granted: int = 0
|
|
128
|
+
bonuses_already_claimed: int = 0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class AuditEvent(BaseModel):
|
|
132
|
+
"""Audit event."""
|
|
133
|
+
id: int
|
|
134
|
+
timestamp: str
|
|
135
|
+
event_type: str
|
|
136
|
+
actor_id: Optional[str]
|
|
137
|
+
actor_wallet: Optional[str]
|
|
138
|
+
actor_ip: Optional[str]
|
|
139
|
+
target_id: Optional[str]
|
|
140
|
+
action: str
|
|
141
|
+
old_value: Optional[str]
|
|
142
|
+
new_value: Optional[str]
|
|
143
|
+
success: bool
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class SessionUsageSummary(BaseModel):
|
|
147
|
+
"""Session usage summary."""
|
|
148
|
+
session_id: str
|
|
149
|
+
total_cost: int
|
|
150
|
+
total_calls: int
|
|
151
|
+
timestamp: str
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class UserSessionsResponse(BaseModel):
|
|
155
|
+
"""User sessions response."""
|
|
156
|
+
total_sessions: int
|
|
157
|
+
total_spent: int
|
|
158
|
+
sessions: List[SessionUsageSummary]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class DashboardStats(BaseModel):
|
|
162
|
+
"""Enhanced dashboard statistics."""
|
|
163
|
+
users: Dict[str, Any]
|
|
164
|
+
credits: Dict[str, Any]
|
|
165
|
+
sessions: Dict[str, Any]
|
|
166
|
+
revenue: Dict[str, Any]
|
|
167
|
+
alerts: List[Dict[str, Any]]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class BlockStatusResponse(BaseModel):
|
|
171
|
+
"""Block status response."""
|
|
172
|
+
is_blocked: bool
|
|
173
|
+
blocked_reason: Optional[str]
|
|
174
|
+
blocked_at: Optional[str]
|
|
175
|
+
blocked_by: Optional[str]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class BillingFailure(BaseModel):
|
|
179
|
+
"""Billing failure record."""
|
|
180
|
+
id: int
|
|
181
|
+
user_id: str
|
|
182
|
+
session_id: str
|
|
183
|
+
request_id: str
|
|
184
|
+
credits_owed: int
|
|
185
|
+
api_cost_usd: float
|
|
186
|
+
model: Optional[str]
|
|
187
|
+
created_at: str
|
|
188
|
+
resolved_at: Optional[str]
|
|
189
|
+
status: str
|
|
190
|
+
resolution_notes: Optional[str]
|
|
191
|
+
current_balance: Optional[int] = None
|
|
192
|
+
wallet_address: Optional[str] = None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class BillingFailuresResponse(BaseModel):
|
|
196
|
+
"""Billing failures list response."""
|
|
197
|
+
failures: List[BillingFailure]
|
|
198
|
+
total: int
|
|
199
|
+
status_filter: str
|
|
200
|
+
total_credits_owed: int
|
|
201
|
+
total_api_cost_usd: float
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class ResolveBillingFailureRequest(BaseModel):
|
|
205
|
+
"""Request to resolve a billing failure."""
|
|
206
|
+
resolution: Literal["charged", "written_off", "refunded"]
|
|
207
|
+
notes: str = ""
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# Dependency: Check admin access
|
|
211
|
+
|
|
212
|
+
async def require_admin(request: Request):
|
|
213
|
+
"""Require admin access for endpoint.
|
|
214
|
+
|
|
215
|
+
Reads the SINGLE canonical admin-truth source: `role` (+ `wallet_address`)
|
|
216
|
+
on request.state, via the same extract_admin_info()/is_admin() helper
|
|
217
|
+
verify_payment_for_request uses. Deliberately does NOT read
|
|
218
|
+
request.state.is_admin — that was a second, independently-set truth
|
|
219
|
+
source (some middlewares set it, some didn't) that could diverge from
|
|
220
|
+
`role` (H1 fix: an owner-login session had role="owner", tier="admin" but
|
|
221
|
+
was denied here pre-fix because "owner" wasn't recognized as admin).
|
|
222
|
+
|
|
223
|
+
Admin access is granted via:
|
|
224
|
+
1. role in ADMIN_ROLES (currently 'admin' or 'owner')
|
|
225
|
+
2. wallet address in ADMIN_WALLETS env var
|
|
226
|
+
"""
|
|
227
|
+
admin_status, _role, _wallet = extract_admin_info(request.state)
|
|
228
|
+
if not admin_status:
|
|
229
|
+
raise HTTPException(
|
|
230
|
+
status_code=403,
|
|
231
|
+
detail="Admin access required"
|
|
232
|
+
)
|
|
233
|
+
return True
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# Endpoints
|
|
237
|
+
|
|
238
|
+
@router.get("/users", dependencies=[Depends(require_admin)])
|
|
239
|
+
async def list_users(
|
|
240
|
+
request: Request,
|
|
241
|
+
limit: int = 50,
|
|
242
|
+
offset: int = 0,
|
|
243
|
+
tier: Optional[str] = None,
|
|
244
|
+
role: Optional[str] = None
|
|
245
|
+
) -> List[UserInfo]:
|
|
246
|
+
"""List all users (admin only).
|
|
247
|
+
|
|
248
|
+
Query Parameters:
|
|
249
|
+
limit: Max results (default 50)
|
|
250
|
+
offset: Pagination offset (default 0)
|
|
251
|
+
tier: Filter by tier (optional)
|
|
252
|
+
role: Filter by role (optional)
|
|
253
|
+
"""
|
|
254
|
+
from core.container import DependencyContainer
|
|
255
|
+
container = DependencyContainer.get_instance()
|
|
256
|
+
db = container.get_service('database_manager')
|
|
257
|
+
|
|
258
|
+
if not db:
|
|
259
|
+
raise HTTPException(status_code=503, detail="Database unavailable")
|
|
260
|
+
|
|
261
|
+
# Build query with filters
|
|
262
|
+
where_clauses = []
|
|
263
|
+
params = []
|
|
264
|
+
|
|
265
|
+
if tier:
|
|
266
|
+
where_clauses.append("u.tier = ?")
|
|
267
|
+
params.append(tier)
|
|
268
|
+
|
|
269
|
+
if role:
|
|
270
|
+
where_clauses.append("u.role = ?")
|
|
271
|
+
params.append(role)
|
|
272
|
+
|
|
273
|
+
where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else ""
|
|
274
|
+
|
|
275
|
+
params.extend([limit, offset])
|
|
276
|
+
|
|
277
|
+
users = await db.fetch_all(f"""
|
|
278
|
+
SELECT
|
|
279
|
+
u.user_id,
|
|
280
|
+
u.wallet_address,
|
|
281
|
+
u.email,
|
|
282
|
+
u.tier,
|
|
283
|
+
u.role,
|
|
284
|
+
u.den_token_count,
|
|
285
|
+
u.created_at,
|
|
286
|
+
c.balance,
|
|
287
|
+
c.lifetime_earned,
|
|
288
|
+
c.lifetime_spent
|
|
289
|
+
FROM user_profiles u
|
|
290
|
+
LEFT JOIN user_credits c ON u.user_id = c.user_id
|
|
291
|
+
{where_sql}
|
|
292
|
+
ORDER BY u.created_at DESC
|
|
293
|
+
LIMIT ? OFFSET ?
|
|
294
|
+
""", tuple(params))
|
|
295
|
+
|
|
296
|
+
return [
|
|
297
|
+
UserInfo(
|
|
298
|
+
user_id=u['user_id'],
|
|
299
|
+
wallet_address=u['wallet_address'],
|
|
300
|
+
email=u['email'],
|
|
301
|
+
tier=u['tier'],
|
|
302
|
+
role=u['role'] or 'user',
|
|
303
|
+
den_token_count=u['den_token_count'] or 0,
|
|
304
|
+
balance=u['balance'] or 0,
|
|
305
|
+
lifetime_earned=u['lifetime_earned'] or 0,
|
|
306
|
+
lifetime_spent=u['lifetime_spent'] or 0,
|
|
307
|
+
created_at=u['created_at']
|
|
308
|
+
)
|
|
309
|
+
for u in users
|
|
310
|
+
]
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@router.get("/users/{user_id}", dependencies=[Depends(require_admin)])
|
|
314
|
+
async def get_user(request: Request, user_id: str) -> UserInfo:
|
|
315
|
+
"""Get user details (admin only)."""
|
|
316
|
+
from core.container import DependencyContainer
|
|
317
|
+
container = DependencyContainer.get_instance()
|
|
318
|
+
db = container.get_service('database_manager')
|
|
319
|
+
|
|
320
|
+
if not db:
|
|
321
|
+
raise HTTPException(status_code=503, detail="Database unavailable")
|
|
322
|
+
|
|
323
|
+
user = await db.fetch_one("""
|
|
324
|
+
SELECT
|
|
325
|
+
u.user_id,
|
|
326
|
+
u.wallet_address,
|
|
327
|
+
u.email,
|
|
328
|
+
u.tier,
|
|
329
|
+
u.role,
|
|
330
|
+
u.den_token_count,
|
|
331
|
+
u.created_at,
|
|
332
|
+
c.balance,
|
|
333
|
+
c.lifetime_earned,
|
|
334
|
+
c.lifetime_spent
|
|
335
|
+
FROM user_profiles u
|
|
336
|
+
LEFT JOIN user_credits c ON u.user_id = c.user_id
|
|
337
|
+
WHERE u.user_id = ?
|
|
338
|
+
""", (user_id,))
|
|
339
|
+
|
|
340
|
+
if not user:
|
|
341
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
342
|
+
|
|
343
|
+
return UserInfo(
|
|
344
|
+
user_id=user['user_id'],
|
|
345
|
+
wallet_address=user['wallet_address'],
|
|
346
|
+
email=user['email'],
|
|
347
|
+
tier=user['tier'],
|
|
348
|
+
role=user['role'] or 'user',
|
|
349
|
+
den_token_count=user['den_token_count'] or 0,
|
|
350
|
+
balance=user['balance'] or 0,
|
|
351
|
+
lifetime_earned=user['lifetime_earned'] or 0,
|
|
352
|
+
lifetime_spent=user['lifetime_spent'] or 0,
|
|
353
|
+
created_at=user['created_at']
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@router.post("/users/{user_id}/credits/add", dependencies=[Depends(require_admin)])
|
|
358
|
+
async def add_user_credits(
|
|
359
|
+
request: Request,
|
|
360
|
+
user_id: str,
|
|
361
|
+
credits: AddCreditsRequest
|
|
362
|
+
):
|
|
363
|
+
"""Add credits to user account (admin only)."""
|
|
364
|
+
from core.container import DependencyContainer
|
|
365
|
+
container = DependencyContainer.get_instance()
|
|
366
|
+
balance_mgr = container.get_service('balance_manager')
|
|
367
|
+
|
|
368
|
+
if not balance_mgr:
|
|
369
|
+
raise HTTPException(status_code=503, detail="Balance manager unavailable")
|
|
370
|
+
|
|
371
|
+
admin_id = getattr(request.state, 'user_id', 'unknown')
|
|
372
|
+
client_ip = get_client_ip(request)
|
|
373
|
+
|
|
374
|
+
# Get balance before for audit
|
|
375
|
+
balance_before_info = await balance_mgr.get_balance(user_id)
|
|
376
|
+
balance_before = balance_before_info.get('balance', 0) if balance_before_info else 0
|
|
377
|
+
|
|
378
|
+
success = await balance_mgr.add_credits(
|
|
379
|
+
user_id=user_id,
|
|
380
|
+
amount=credits.amount,
|
|
381
|
+
reason=f"{credits.reason} (granted by admin {admin_id})",
|
|
382
|
+
transaction_type=credits.transaction_type
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
if not success:
|
|
386
|
+
raise HTTPException(status_code=500, detail="Failed to add credits")
|
|
387
|
+
|
|
388
|
+
# Get updated balance
|
|
389
|
+
balance_info = await balance_mgr.get_balance(user_id)
|
|
390
|
+
balance_after = balance_info.get('balance', 0)
|
|
391
|
+
|
|
392
|
+
# SECURITY: Audit log credit addition
|
|
393
|
+
audit = await get_audit_logger()
|
|
394
|
+
if audit:
|
|
395
|
+
await audit.log_credit_change(
|
|
396
|
+
admin_id=admin_id,
|
|
397
|
+
target_user_id=user_id,
|
|
398
|
+
amount=credits.amount,
|
|
399
|
+
is_addition=True,
|
|
400
|
+
reason=credits.reason,
|
|
401
|
+
balance_before=balance_before,
|
|
402
|
+
balance_after=balance_after,
|
|
403
|
+
ip_address=client_ip
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
return {
|
|
407
|
+
"success": True,
|
|
408
|
+
"user_id": user_id,
|
|
409
|
+
"added_amount": credits.amount,
|
|
410
|
+
"new_balance": balance_after,
|
|
411
|
+
"transaction_type": credits.transaction_type,
|
|
412
|
+
"reason": credits.reason
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@router.post("/users/{user_id}/credits/deduct", dependencies=[Depends(require_admin)])
|
|
417
|
+
async def deduct_user_credits(
|
|
418
|
+
request: Request,
|
|
419
|
+
user_id: str,
|
|
420
|
+
credits: AddCreditsRequest
|
|
421
|
+
):
|
|
422
|
+
"""Deduct credits from user account (admin only)."""
|
|
423
|
+
from core.container import DependencyContainer
|
|
424
|
+
container = DependencyContainer.get_instance()
|
|
425
|
+
balance_mgr = container.get_service('balance_manager')
|
|
426
|
+
|
|
427
|
+
if not balance_mgr:
|
|
428
|
+
raise HTTPException(status_code=503, detail="Balance manager unavailable")
|
|
429
|
+
|
|
430
|
+
admin_id = getattr(request.state, 'user_id', 'unknown')
|
|
431
|
+
client_ip = get_client_ip(request)
|
|
432
|
+
|
|
433
|
+
# Get balance before for audit
|
|
434
|
+
balance_before_info = await balance_mgr.get_balance(user_id)
|
|
435
|
+
balance_before = balance_before_info.get('balance', 0) if balance_before_info else 0
|
|
436
|
+
|
|
437
|
+
success = await balance_mgr.deduct_credits(
|
|
438
|
+
user_id=user_id,
|
|
439
|
+
amount=credits.amount,
|
|
440
|
+
reason=f"{credits.reason} (deducted by admin {admin_id})"
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
if not success:
|
|
444
|
+
raise HTTPException(status_code=400, detail="Insufficient balance or deduction failed")
|
|
445
|
+
|
|
446
|
+
# Get updated balance
|
|
447
|
+
balance_info = await balance_mgr.get_balance(user_id)
|
|
448
|
+
balance_after = balance_info.get('balance', 0)
|
|
449
|
+
|
|
450
|
+
# SECURITY: Audit log credit deduction
|
|
451
|
+
audit = await get_audit_logger()
|
|
452
|
+
if audit:
|
|
453
|
+
await audit.log_credit_change(
|
|
454
|
+
admin_id=admin_id,
|
|
455
|
+
target_user_id=user_id,
|
|
456
|
+
amount=credits.amount,
|
|
457
|
+
is_addition=False,
|
|
458
|
+
reason=credits.reason,
|
|
459
|
+
balance_before=balance_before,
|
|
460
|
+
balance_after=balance_after,
|
|
461
|
+
ip_address=client_ip
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
return {
|
|
465
|
+
"success": True,
|
|
466
|
+
"user_id": user_id,
|
|
467
|
+
"deducted_amount": credits.amount,
|
|
468
|
+
"new_balance": balance_after
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
@router.get("/users/{user_id}/credits", dependencies=[Depends(require_admin)])
|
|
473
|
+
async def get_user_credits(request: Request, user_id: str):
|
|
474
|
+
"""Get user's credit balance and history (admin only)."""
|
|
475
|
+
from core.container import DependencyContainer
|
|
476
|
+
container = DependencyContainer.get_instance()
|
|
477
|
+
balance_mgr = container.get_service('balance_manager')
|
|
478
|
+
|
|
479
|
+
if not balance_mgr:
|
|
480
|
+
raise HTTPException(status_code=503, detail="Balance manager unavailable")
|
|
481
|
+
|
|
482
|
+
balance_info = await balance_mgr.get_balance(user_id)
|
|
483
|
+
transactions = await balance_mgr.get_transaction_history(user_id, limit=50)
|
|
484
|
+
monthly_stats = await balance_mgr.get_monthly_stats(user_id)
|
|
485
|
+
|
|
486
|
+
return {
|
|
487
|
+
"user_id": user_id,
|
|
488
|
+
"balance": balance_info['balance'],
|
|
489
|
+
"lifetime_earned": balance_info['lifetime_earned'],
|
|
490
|
+
"lifetime_spent": balance_info['lifetime_spent'],
|
|
491
|
+
"month_spent": monthly_stats['month_spent'],
|
|
492
|
+
"month_earned": monthly_stats['month_earned'],
|
|
493
|
+
"recent_transactions": [
|
|
494
|
+
CreditTransaction(
|
|
495
|
+
amount=t['amount'],
|
|
496
|
+
transaction_type=t['transaction_type'],
|
|
497
|
+
reason=t['reason'],
|
|
498
|
+
balance_before=t['balance_before'],
|
|
499
|
+
balance_after=t['balance_after'],
|
|
500
|
+
timestamp=t['timestamp']
|
|
501
|
+
)
|
|
502
|
+
for t in transactions
|
|
503
|
+
]
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
@router.post("/users/{user_id}/role", dependencies=[Depends(require_admin)])
|
|
508
|
+
async def set_user_role(
|
|
509
|
+
request: Request,
|
|
510
|
+
user_id: str,
|
|
511
|
+
role_request: SetRoleRequest
|
|
512
|
+
):
|
|
513
|
+
"""Set user role (admin only).
|
|
514
|
+
|
|
515
|
+
Assignable roles: user, admin.
|
|
516
|
+
"owner" is NOT assignable here — it is reserved for the password-gated
|
|
517
|
+
owner-login (webview/owner_auth.py::issue_owner_session_cookie) and must
|
|
518
|
+
never be mintable via admin action.
|
|
519
|
+
"""
|
|
520
|
+
from core.container import DependencyContainer
|
|
521
|
+
container = DependencyContainer.get_instance()
|
|
522
|
+
db = container.get_service('database_manager')
|
|
523
|
+
|
|
524
|
+
if not db:
|
|
525
|
+
raise HTTPException(status_code=503, detail="Database unavailable")
|
|
526
|
+
|
|
527
|
+
# Validate role using centralized ASSIGNMENT-boundary validation. This is
|
|
528
|
+
# stricter than validate_role(): "owner" is a VALID_ROLE (read-side admin
|
|
529
|
+
# checks need it) but must never be admin-assignable.
|
|
530
|
+
try:
|
|
531
|
+
validate_assignable_role(role_request.role)
|
|
532
|
+
except ValueError as e:
|
|
533
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
534
|
+
|
|
535
|
+
# Get current role for audit log and verify user exists
|
|
536
|
+
current_user = await db.fetch_one(
|
|
537
|
+
"SELECT role FROM user_profiles WHERE user_id = ?", (user_id,)
|
|
538
|
+
)
|
|
539
|
+
if not current_user:
|
|
540
|
+
raise HTTPException(status_code=404, detail=f"User {user_id} not found")
|
|
541
|
+
|
|
542
|
+
old_role = current_user['role']
|
|
543
|
+
|
|
544
|
+
# Update role
|
|
545
|
+
await db.execute("""
|
|
546
|
+
UPDATE user_profiles
|
|
547
|
+
SET role = ?, updated_at = CURRENT_TIMESTAMP
|
|
548
|
+
WHERE user_id = ?
|
|
549
|
+
""", (role_request.role, user_id))
|
|
550
|
+
|
|
551
|
+
admin_id = getattr(request.state, 'user_id', 'unknown')
|
|
552
|
+
client_ip = get_client_ip(request)
|
|
553
|
+
|
|
554
|
+
# SECURITY: Audit log role change
|
|
555
|
+
audit = await get_audit_logger()
|
|
556
|
+
if audit:
|
|
557
|
+
await audit.log_role_change(
|
|
558
|
+
admin_id=admin_id,
|
|
559
|
+
target_user_id=user_id,
|
|
560
|
+
old_role=old_role,
|
|
561
|
+
new_role=role_request.role,
|
|
562
|
+
ip_address=client_ip
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
logger.info(f"User {user_id} role changed from {old_role} to {role_request.role} by admin {admin_id}")
|
|
566
|
+
|
|
567
|
+
return {
|
|
568
|
+
"success": True,
|
|
569
|
+
"user_id": user_id,
|
|
570
|
+
"new_role": role_request.role
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
@router.post("/users/{user_id}/tier", dependencies=[Depends(require_admin)])
|
|
575
|
+
async def set_user_tier(
|
|
576
|
+
request: Request,
|
|
577
|
+
user_id: str,
|
|
578
|
+
tier_request: SetTierRequest
|
|
579
|
+
):
|
|
580
|
+
"""Manually set user tier (admin only).
|
|
581
|
+
|
|
582
|
+
Tiers:
|
|
583
|
+
- free: No access (blocked from sessions)
|
|
584
|
+
- free_access: Admin-granted access without DEN token (uses credits)
|
|
585
|
+
- holder: DEN token holder (automatic via token verification)
|
|
586
|
+
- x402: Pay-per-request
|
|
587
|
+
- admin: Unlimited access
|
|
588
|
+
|
|
589
|
+
Use 'free_access' to grant someone access for demo/showcase purposes.
|
|
590
|
+
"""
|
|
591
|
+
from core.container import DependencyContainer
|
|
592
|
+
container = DependencyContainer.get_instance()
|
|
593
|
+
db = container.get_service('database_manager')
|
|
594
|
+
|
|
595
|
+
if not db:
|
|
596
|
+
raise HTTPException(status_code=503, detail="Database unavailable")
|
|
597
|
+
|
|
598
|
+
# Validate tier using centralized validation
|
|
599
|
+
try:
|
|
600
|
+
validate_tier(tier_request.tier)
|
|
601
|
+
except ValueError as e:
|
|
602
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
603
|
+
|
|
604
|
+
# Get current tier for audit log and verify user exists
|
|
605
|
+
current_user = await db.fetch_one(
|
|
606
|
+
"SELECT tier FROM user_profiles WHERE user_id = ?", (user_id,)
|
|
607
|
+
)
|
|
608
|
+
if not current_user:
|
|
609
|
+
raise HTTPException(status_code=404, detail=f"User {user_id} not found")
|
|
610
|
+
|
|
611
|
+
old_tier = current_user['tier']
|
|
612
|
+
|
|
613
|
+
# Update tier
|
|
614
|
+
await db.execute("""
|
|
615
|
+
UPDATE user_profiles
|
|
616
|
+
SET tier = ?, updated_at = CURRENT_TIMESTAMP
|
|
617
|
+
WHERE user_id = ?
|
|
618
|
+
""", (tier_request.tier, user_id))
|
|
619
|
+
|
|
620
|
+
admin_id = getattr(request.state, 'user_id', 'unknown')
|
|
621
|
+
client_ip = get_client_ip(request)
|
|
622
|
+
|
|
623
|
+
# SECURITY: Audit log tier change
|
|
624
|
+
audit = await get_audit_logger()
|
|
625
|
+
if audit:
|
|
626
|
+
await audit.log_tier_change(
|
|
627
|
+
admin_id=admin_id,
|
|
628
|
+
target_user_id=user_id,
|
|
629
|
+
old_tier=old_tier,
|
|
630
|
+
new_tier=tier_request.tier,
|
|
631
|
+
ip_address=client_ip
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
logger.info(f"User {user_id} tier changed from {old_tier} to {tier_request.tier} by admin {admin_id}")
|
|
635
|
+
|
|
636
|
+
return {
|
|
637
|
+
"success": True,
|
|
638
|
+
"user_id": user_id,
|
|
639
|
+
"new_tier": tier_request.tier
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
@router.get("/stats", dependencies=[Depends(require_admin)])
|
|
644
|
+
async def get_system_stats(request: Request):
|
|
645
|
+
"""Get system statistics (admin only)."""
|
|
646
|
+
from core.container import DependencyContainer
|
|
647
|
+
container = DependencyContainer.get_instance()
|
|
648
|
+
db = container.get_service('database_manager')
|
|
649
|
+
|
|
650
|
+
if not db:
|
|
651
|
+
raise HTTPException(status_code=503, detail="Database unavailable")
|
|
652
|
+
|
|
653
|
+
# User stats
|
|
654
|
+
total_users = await db.fetch_one("SELECT COUNT(*) as count FROM user_profiles")
|
|
655
|
+
users_by_tier = await db.fetch_all("""
|
|
656
|
+
SELECT tier, COUNT(*) as count
|
|
657
|
+
FROM user_profiles
|
|
658
|
+
GROUP BY tier
|
|
659
|
+
""")
|
|
660
|
+
users_by_role = await db.fetch_all("""
|
|
661
|
+
SELECT role, COUNT(*) as count
|
|
662
|
+
FROM user_profiles
|
|
663
|
+
GROUP BY role
|
|
664
|
+
""")
|
|
665
|
+
|
|
666
|
+
# Credit stats
|
|
667
|
+
total_credits_issued = await db.fetch_one("""
|
|
668
|
+
SELECT COALESCE(SUM(balance), 0) as total
|
|
669
|
+
FROM user_credits
|
|
670
|
+
""")
|
|
671
|
+
total_credits_spent = await db.fetch_one("""
|
|
672
|
+
SELECT COALESCE(SUM(ABS(amount)), 0) as total
|
|
673
|
+
FROM credit_transactions
|
|
674
|
+
WHERE transaction_type = 'usage'
|
|
675
|
+
""")
|
|
676
|
+
|
|
677
|
+
# x402 stats (if enabled)
|
|
678
|
+
x402_payments = await db.fetch_one("""
|
|
679
|
+
SELECT
|
|
680
|
+
COUNT(*) as total_requests,
|
|
681
|
+
COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0) as completed,
|
|
682
|
+
COALESCE(SUM(CASE WHEN status = 'completed' THEN amount_usd ELSE 0 END), 0) as total_revenue
|
|
683
|
+
FROM x402_payment_requests
|
|
684
|
+
""")
|
|
685
|
+
|
|
686
|
+
return {
|
|
687
|
+
"users": {
|
|
688
|
+
"total": total_users['count'] if total_users else 0,
|
|
689
|
+
"by_tier": {row['tier']: row['count'] for row in users_by_tier},
|
|
690
|
+
"by_role": {row['role'] or 'user': row['count'] for row in users_by_role}
|
|
691
|
+
},
|
|
692
|
+
"credits": {
|
|
693
|
+
"total_issued": total_credits_issued['total'] if total_credits_issued else 0,
|
|
694
|
+
"total_spent": total_credits_spent['total'] if total_credits_spent else 0
|
|
695
|
+
},
|
|
696
|
+
"x402": {
|
|
697
|
+
"total_requests": x402_payments['total_requests'] if x402_payments else 0,
|
|
698
|
+
"completed_payments": x402_payments['completed'] if x402_payments else 0,
|
|
699
|
+
"total_revenue_usd": x402_payments['total_revenue'] if x402_payments else 0
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
@router.get("/users/search", dependencies=[Depends(require_admin)])
|
|
705
|
+
async def search_users(
|
|
706
|
+
request: Request,
|
|
707
|
+
q: str = Query(..., min_length=1, description="Search query"),
|
|
708
|
+
field: Literal["wallet", "email", "user_id", "all"] = Query("all", description="Field to search"),
|
|
709
|
+
limit: int = Query(50, ge=1, le=200),
|
|
710
|
+
offset: int = Query(0, ge=0)
|
|
711
|
+
) -> List[UserSearchResult]:
|
|
712
|
+
"""Search users by wallet address, email, or user_id.
|
|
713
|
+
|
|
714
|
+
- Wallet search is case-insensitive and supports partial match
|
|
715
|
+
- Email search is case-insensitive and supports partial match
|
|
716
|
+
- User ID search is exact match prefix
|
|
717
|
+
"""
|
|
718
|
+
from core.container import DependencyContainer
|
|
719
|
+
container = DependencyContainer.get_instance()
|
|
720
|
+
db = container.get_service('database_manager')
|
|
721
|
+
|
|
722
|
+
if not db:
|
|
723
|
+
raise HTTPException(status_code=503, detail="Database unavailable")
|
|
724
|
+
|
|
725
|
+
# Build search query based on field
|
|
726
|
+
search_term = q.lower().strip()
|
|
727
|
+
where_clauses = []
|
|
728
|
+
params = []
|
|
729
|
+
|
|
730
|
+
if field == "wallet" or field == "all":
|
|
731
|
+
where_clauses.append("LOWER(u.wallet_address) LIKE ?")
|
|
732
|
+
params.append(f"%{search_term}%")
|
|
733
|
+
|
|
734
|
+
if field == "email" or field == "all":
|
|
735
|
+
where_clauses.append("LOWER(u.email) LIKE ?")
|
|
736
|
+
params.append(f"%{search_term}%")
|
|
737
|
+
|
|
738
|
+
if field == "user_id" or field == "all":
|
|
739
|
+
where_clauses.append("u.user_id LIKE ?")
|
|
740
|
+
params.append(f"{search_term}%")
|
|
741
|
+
|
|
742
|
+
where_sql = f"WHERE ({' OR '.join(where_clauses)})"
|
|
743
|
+
params.extend([limit, offset])
|
|
744
|
+
|
|
745
|
+
users = await db.fetch_all(f"""
|
|
746
|
+
SELECT
|
|
747
|
+
u.user_id,
|
|
748
|
+
u.wallet_address,
|
|
749
|
+
u.email,
|
|
750
|
+
u.tier,
|
|
751
|
+
u.role,
|
|
752
|
+
u.den_token_count,
|
|
753
|
+
u.created_at,
|
|
754
|
+
COALESCE(c.balance, 0) as balance,
|
|
755
|
+
COALESCE(b.is_blocked, 0) as is_blocked
|
|
756
|
+
FROM user_profiles u
|
|
757
|
+
LEFT JOIN user_credits c ON u.user_id = c.user_id
|
|
758
|
+
LEFT JOIN blocked_users b ON u.user_id = b.user_id
|
|
759
|
+
{where_sql}
|
|
760
|
+
ORDER BY u.created_at DESC
|
|
761
|
+
LIMIT ? OFFSET ?
|
|
762
|
+
""", tuple(params))
|
|
763
|
+
|
|
764
|
+
return [
|
|
765
|
+
UserSearchResult(
|
|
766
|
+
user_id=u['user_id'],
|
|
767
|
+
wallet_address=u['wallet_address'],
|
|
768
|
+
email=u['email'],
|
|
769
|
+
tier=u['tier'],
|
|
770
|
+
role=u['role'] or 'user',
|
|
771
|
+
den_token_count=u['den_token_count'] or 0,
|
|
772
|
+
balance=u['balance'] or 0,
|
|
773
|
+
created_at=str(u['created_at']),
|
|
774
|
+
is_blocked=bool(u['is_blocked'])
|
|
775
|
+
)
|
|
776
|
+
for u in users
|
|
777
|
+
]
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
@router.post("/users/{user_id}/verify-token", dependencies=[Depends(require_admin)])
|
|
781
|
+
async def verify_user_token(
|
|
782
|
+
request: Request,
|
|
783
|
+
user_id: str
|
|
784
|
+
) -> TokenVerificationResult:
|
|
785
|
+
"""Re-verify DEN token ownership for a user and update tier if needed.
|
|
786
|
+
|
|
787
|
+
Uses AlchemyTool to check current token ownership and:
|
|
788
|
+
1. Updates den_token_count and den_token_verified_at
|
|
789
|
+
2. Upgrades tier to 'holder' if user has tokens and is currently 'free'
|
|
790
|
+
3. Grants DEN Sign Up Allowance for any unclaimed token IDs
|
|
791
|
+
"""
|
|
792
|
+
from core.container import DependencyContainer
|
|
793
|
+
container = DependencyContainer.get_instance()
|
|
794
|
+
db = container.get_service('database_manager')
|
|
795
|
+
|
|
796
|
+
if not db:
|
|
797
|
+
raise HTTPException(status_code=503, detail="Database unavailable")
|
|
798
|
+
|
|
799
|
+
# Get user info
|
|
800
|
+
user = await db.fetch_one("""
|
|
801
|
+
SELECT user_id, wallet_address, tier, den_token_count
|
|
802
|
+
FROM user_profiles WHERE user_id = ?
|
|
803
|
+
""", (user_id,))
|
|
804
|
+
|
|
805
|
+
if not user:
|
|
806
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
807
|
+
|
|
808
|
+
wallet_address = user['wallet_address']
|
|
809
|
+
previous_tier = user['tier']
|
|
810
|
+
previous_count = user['den_token_count'] or 0
|
|
811
|
+
|
|
812
|
+
# Try to get AlchemyTool from container services
|
|
813
|
+
alchemy_tool = container.get_service('alchemy_tool')
|
|
814
|
+
if not alchemy_tool:
|
|
815
|
+
# Try to get 'alchemy' service (the registered name)
|
|
816
|
+
alchemy_tool = container.get_service('alchemy')
|
|
817
|
+
|
|
818
|
+
if not alchemy_tool:
|
|
819
|
+
# Try to create one on the fly as last resort
|
|
820
|
+
try:
|
|
821
|
+
from tools.alchemy.alchemy_tool import AlchemyTool
|
|
822
|
+
from core.config import BotConfig
|
|
823
|
+
config = BotConfig() # BaseSettings auto-loads from env
|
|
824
|
+
alchemy_tool = AlchemyTool(name="alchemy", config=config, container=container)
|
|
825
|
+
await alchemy_tool.initialize()
|
|
826
|
+
except Exception as e:
|
|
827
|
+
logger.error(f"Could not initialize AlchemyTool: {e}")
|
|
828
|
+
raise HTTPException(
|
|
829
|
+
status_code=503,
|
|
830
|
+
detail=f"Token verification service unavailable: {e}"
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
# Call Alchemy to verify token
|
|
834
|
+
try:
|
|
835
|
+
from tools.alchemy.alchemy_tool import CheckTokenParams
|
|
836
|
+
result = await alchemy_tool.alchemy_check_token(
|
|
837
|
+
CheckTokenParams(address=wallet_address)
|
|
838
|
+
)
|
|
839
|
+
except Exception as e:
|
|
840
|
+
logger.error(f"Token verification failed: {e}")
|
|
841
|
+
raise HTTPException(status_code=500, detail=f"Token verification failed: {e}")
|
|
842
|
+
|
|
843
|
+
if result.get('status') != 'success':
|
|
844
|
+
raise HTTPException(
|
|
845
|
+
status_code=500,
|
|
846
|
+
detail=result.get('message', 'Token verification failed')
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
# Extract results
|
|
850
|
+
new_count = result.get('token_count', 0)
|
|
851
|
+
token_ids = result.get('token_ids', [])
|
|
852
|
+
verified_at = datetime.utcnow().isoformat() + "Z"
|
|
853
|
+
|
|
854
|
+
# Determine new tier
|
|
855
|
+
new_tier = previous_tier
|
|
856
|
+
tier_changed = False
|
|
857
|
+
|
|
858
|
+
# Only auto-update tier between 'free' and 'holder'
|
|
859
|
+
# Preserve admin-granted tiers: free_access, x402, admin
|
|
860
|
+
if new_count > 0 and previous_tier == 'free':
|
|
861
|
+
new_tier = 'holder'
|
|
862
|
+
tier_changed = True
|
|
863
|
+
elif new_count == 0 and previous_tier == 'holder':
|
|
864
|
+
# Only downgrade from holder to free
|
|
865
|
+
# (Don't touch free_access, x402, or admin)
|
|
866
|
+
new_tier = 'free'
|
|
867
|
+
tier_changed = True
|
|
868
|
+
|
|
869
|
+
# Update user profile
|
|
870
|
+
await db.execute("""
|
|
871
|
+
UPDATE user_profiles
|
|
872
|
+
SET den_token_count = ?,
|
|
873
|
+
den_token_verified_at = CURRENT_TIMESTAMP,
|
|
874
|
+
tier = ?,
|
|
875
|
+
updated_at = CURRENT_TIMESTAMP
|
|
876
|
+
WHERE user_id = ?
|
|
877
|
+
""", (new_count, new_tier, user_id))
|
|
878
|
+
|
|
879
|
+
# Check and grant bonuses for new token IDs
|
|
880
|
+
bonuses_granted = 0
|
|
881
|
+
bonuses_already_claimed = 0
|
|
882
|
+
|
|
883
|
+
if token_ids:
|
|
884
|
+
# Get contract address from config
|
|
885
|
+
from core.config import BotConfig
|
|
886
|
+
config = BotConfig() # BaseSettings auto-loads from env
|
|
887
|
+
contract_address = getattr(config, 'den_token_contract_address', '')
|
|
888
|
+
|
|
889
|
+
if contract_address:
|
|
890
|
+
for token_id in token_ids:
|
|
891
|
+
# Check if bonus already granted for this token
|
|
892
|
+
existing = await db.fetch_one("""
|
|
893
|
+
SELECT 1 FROM den_token_bonuses
|
|
894
|
+
WHERE token_id = ? AND contract_address = ?
|
|
895
|
+
""", (token_id, contract_address))
|
|
896
|
+
|
|
897
|
+
if existing:
|
|
898
|
+
bonuses_already_claimed += 1
|
|
899
|
+
else:
|
|
900
|
+
# Grant bonus
|
|
901
|
+
from modules.credits.pricing import DEN_SIGNUP_ALLOWANCE
|
|
902
|
+
balance_mgr = container.get_service('balance_manager')
|
|
903
|
+
if balance_mgr:
|
|
904
|
+
await balance_mgr.add_credits(
|
|
905
|
+
user_id=user_id,
|
|
906
|
+
amount=DEN_SIGNUP_ALLOWANCE,
|
|
907
|
+
reason=f"DEN Sign Up Allowance for token #{token_id}",
|
|
908
|
+
transaction_type="den_bonus"
|
|
909
|
+
)
|
|
910
|
+
# Record bonus granted
|
|
911
|
+
await db.execute("""
|
|
912
|
+
INSERT INTO den_token_bonuses (token_id, contract_address)
|
|
913
|
+
VALUES (?, ?)
|
|
914
|
+
""", (token_id, contract_address))
|
|
915
|
+
bonuses_granted += 1
|
|
916
|
+
|
|
917
|
+
# Audit log
|
|
918
|
+
admin_id = getattr(request.state, 'user_id', 'unknown')
|
|
919
|
+
client_ip = get_client_ip(request)
|
|
920
|
+
audit = await get_audit_logger()
|
|
921
|
+
|
|
922
|
+
if audit and tier_changed:
|
|
923
|
+
await audit.log_tier_change(
|
|
924
|
+
admin_id=admin_id,
|
|
925
|
+
target_user_id=user_id,
|
|
926
|
+
old_tier=previous_tier,
|
|
927
|
+
new_tier=new_tier,
|
|
928
|
+
ip_address=client_ip
|
|
929
|
+
)
|
|
930
|
+
|
|
931
|
+
return TokenVerificationResult(
|
|
932
|
+
previous_count=previous_count,
|
|
933
|
+
new_count=new_count,
|
|
934
|
+
previous_tier=previous_tier,
|
|
935
|
+
new_tier=new_tier,
|
|
936
|
+
tier_changed=tier_changed,
|
|
937
|
+
token_ids=token_ids,
|
|
938
|
+
verified_at=verified_at,
|
|
939
|
+
bonuses_granted=bonuses_granted,
|
|
940
|
+
bonuses_already_claimed=bonuses_already_claimed
|
|
941
|
+
)
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
@router.get("/users/{user_id}/audit", dependencies=[Depends(require_admin)])
|
|
945
|
+
async def get_user_audit_trail(
|
|
946
|
+
request: Request,
|
|
947
|
+
user_id: str,
|
|
948
|
+
limit: int = Query(50, ge=1, le=500),
|
|
949
|
+
offset: int = Query(0, ge=0)
|
|
950
|
+
) -> List[AuditEvent]:
|
|
951
|
+
"""Get audit trail for a specific user."""
|
|
952
|
+
from core.container import DependencyContainer
|
|
953
|
+
container = DependencyContainer.get_instance()
|
|
954
|
+
db = container.get_service('database_manager')
|
|
955
|
+
|
|
956
|
+
if not db:
|
|
957
|
+
raise HTTPException(status_code=503, detail="Database unavailable")
|
|
958
|
+
|
|
959
|
+
# Check user exists
|
|
960
|
+
user = await db.fetch_one(
|
|
961
|
+
"SELECT 1 FROM user_profiles WHERE user_id = ?", (user_id,)
|
|
962
|
+
)
|
|
963
|
+
if not user:
|
|
964
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
965
|
+
|
|
966
|
+
# Get audit events where user is either actor or target
|
|
967
|
+
events = await db.fetch_all("""
|
|
968
|
+
SELECT
|
|
969
|
+
id, timestamp, event_type, actor_id, actor_wallet, actor_ip,
|
|
970
|
+
target_id, action, old_value, new_value, success
|
|
971
|
+
FROM audit_log
|
|
972
|
+
WHERE actor_id = ? OR target_id = ?
|
|
973
|
+
ORDER BY timestamp DESC
|
|
974
|
+
LIMIT ? OFFSET ?
|
|
975
|
+
""", (user_id, user_id, limit, offset))
|
|
976
|
+
|
|
977
|
+
return [
|
|
978
|
+
AuditEvent(
|
|
979
|
+
id=e['id'],
|
|
980
|
+
timestamp=str(e['timestamp']),
|
|
981
|
+
event_type=e['event_type'],
|
|
982
|
+
actor_id=e['actor_id'],
|
|
983
|
+
actor_wallet=e['actor_wallet'],
|
|
984
|
+
actor_ip=e['actor_ip'],
|
|
985
|
+
target_id=e['target_id'],
|
|
986
|
+
action=e['action'],
|
|
987
|
+
old_value=e['old_value'],
|
|
988
|
+
new_value=e['new_value'],
|
|
989
|
+
success=bool(e['success'])
|
|
990
|
+
)
|
|
991
|
+
for e in events
|
|
992
|
+
]
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
@router.get("/users/{user_id}/sessions", dependencies=[Depends(require_admin)])
|
|
996
|
+
async def get_user_sessions(
|
|
997
|
+
request: Request,
|
|
998
|
+
user_id: str,
|
|
999
|
+
limit: int = Query(50, ge=1, le=200)
|
|
1000
|
+
) -> UserSessionsResponse:
|
|
1001
|
+
"""Get user's session usage summary."""
|
|
1002
|
+
from core.container import DependencyContainer
|
|
1003
|
+
container = DependencyContainer.get_instance()
|
|
1004
|
+
db = container.get_service('database_manager')
|
|
1005
|
+
|
|
1006
|
+
if not db:
|
|
1007
|
+
raise HTTPException(status_code=503, detail="Database unavailable")
|
|
1008
|
+
|
|
1009
|
+
# Check user exists
|
|
1010
|
+
user = await db.fetch_one(
|
|
1011
|
+
"SELECT 1 FROM user_profiles WHERE user_id = ?", (user_id,)
|
|
1012
|
+
)
|
|
1013
|
+
if not user:
|
|
1014
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
1015
|
+
|
|
1016
|
+
# Get session summaries from usage_records
|
|
1017
|
+
sessions = await db.fetch_all("""
|
|
1018
|
+
SELECT
|
|
1019
|
+
session_id,
|
|
1020
|
+
SUM(cost) as total_cost,
|
|
1021
|
+
COUNT(*) as total_calls,
|
|
1022
|
+
MAX(timestamp) as last_timestamp
|
|
1023
|
+
FROM usage_records
|
|
1024
|
+
WHERE user_id = ?
|
|
1025
|
+
GROUP BY session_id
|
|
1026
|
+
ORDER BY last_timestamp DESC
|
|
1027
|
+
LIMIT ?
|
|
1028
|
+
""", (user_id, limit))
|
|
1029
|
+
|
|
1030
|
+
# Get total stats
|
|
1031
|
+
totals = await db.fetch_one("""
|
|
1032
|
+
SELECT
|
|
1033
|
+
COUNT(DISTINCT session_id) as total_sessions,
|
|
1034
|
+
COALESCE(SUM(cost), 0) as total_spent
|
|
1035
|
+
FROM usage_records
|
|
1036
|
+
WHERE user_id = ?
|
|
1037
|
+
""", (user_id,))
|
|
1038
|
+
|
|
1039
|
+
return UserSessionsResponse(
|
|
1040
|
+
total_sessions=totals['total_sessions'] if totals else 0,
|
|
1041
|
+
total_spent=totals['total_spent'] if totals else 0,
|
|
1042
|
+
sessions=[
|
|
1043
|
+
SessionUsageSummary(
|
|
1044
|
+
session_id=s['session_id'],
|
|
1045
|
+
total_cost=s['total_cost'] or 0,
|
|
1046
|
+
total_calls=s['total_calls'] or 0,
|
|
1047
|
+
timestamp=str(s['last_timestamp'])
|
|
1048
|
+
)
|
|
1049
|
+
for s in sessions
|
|
1050
|
+
]
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
@router.post("/users/{user_id}/block", dependencies=[Depends(require_admin)])
|
|
1055
|
+
async def block_user(
|
|
1056
|
+
request: Request,
|
|
1057
|
+
user_id: str,
|
|
1058
|
+
block_request: BlockUserRequest
|
|
1059
|
+
):
|
|
1060
|
+
"""Block a user."""
|
|
1061
|
+
from core.container import DependencyContainer
|
|
1062
|
+
container = DependencyContainer.get_instance()
|
|
1063
|
+
db = container.get_service('database_manager')
|
|
1064
|
+
|
|
1065
|
+
if not db:
|
|
1066
|
+
raise HTTPException(status_code=503, detail="Database unavailable")
|
|
1067
|
+
|
|
1068
|
+
# Check user exists
|
|
1069
|
+
user = await db.fetch_one(
|
|
1070
|
+
"SELECT 1 FROM user_profiles WHERE user_id = ?", (user_id,)
|
|
1071
|
+
)
|
|
1072
|
+
if not user:
|
|
1073
|
+
raise HTTPException(status_code=404, detail="User not found")
|
|
1074
|
+
|
|
1075
|
+
admin_id = getattr(request.state, 'user_id', 'unknown')
|
|
1076
|
+
|
|
1077
|
+
# Insert or update blocked_users record
|
|
1078
|
+
await db.execute("""
|
|
1079
|
+
INSERT INTO blocked_users (user_id, is_blocked, blocked_reason, blocked_at, blocked_by)
|
|
1080
|
+
VALUES (?, 1, ?, CURRENT_TIMESTAMP, ?)
|
|
1081
|
+
ON CONFLICT(user_id) DO UPDATE SET
|
|
1082
|
+
is_blocked = 1,
|
|
1083
|
+
blocked_reason = excluded.blocked_reason,
|
|
1084
|
+
blocked_at = CURRENT_TIMESTAMP,
|
|
1085
|
+
blocked_by = excluded.blocked_by,
|
|
1086
|
+
unblocked_at = NULL
|
|
1087
|
+
""", (user_id, block_request.reason, admin_id))
|
|
1088
|
+
|
|
1089
|
+
# Audit log
|
|
1090
|
+
client_ip = get_client_ip(request)
|
|
1091
|
+
audit = await get_audit_logger()
|
|
1092
|
+
if audit:
|
|
1093
|
+
await audit.log(
|
|
1094
|
+
event_type="user_blocked",
|
|
1095
|
+
action=f"User blocked: {block_request.reason}",
|
|
1096
|
+
actor_id=admin_id,
|
|
1097
|
+
actor_ip=client_ip,
|
|
1098
|
+
target_id=user_id,
|
|
1099
|
+
target_type="user",
|
|
1100
|
+
metadata={"reason": block_request.reason}
|
|
1101
|
+
)
|
|
1102
|
+
|
|
1103
|
+
return {
|
|
1104
|
+
"success": True,
|
|
1105
|
+
"user_id": user_id,
|
|
1106
|
+
"blocked_at": datetime.utcnow().isoformat() + "Z",
|
|
1107
|
+
"blocked_by": admin_id
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
@router.post("/users/{user_id}/unblock", dependencies=[Depends(require_admin)])
|
|
1112
|
+
async def unblock_user(
|
|
1113
|
+
request: Request,
|
|
1114
|
+
user_id: str
|
|
1115
|
+
):
|
|
1116
|
+
"""Unblock a user."""
|
|
1117
|
+
from core.container import DependencyContainer
|
|
1118
|
+
container = DependencyContainer.get_instance()
|
|
1119
|
+
db = container.get_service('database_manager')
|
|
1120
|
+
|
|
1121
|
+
if not db:
|
|
1122
|
+
raise HTTPException(status_code=503, detail="Database unavailable")
|
|
1123
|
+
|
|
1124
|
+
# Check user exists and is blocked
|
|
1125
|
+
blocked = await db.fetch_one("""
|
|
1126
|
+
SELECT is_blocked FROM blocked_users WHERE user_id = ?
|
|
1127
|
+
""", (user_id,))
|
|
1128
|
+
|
|
1129
|
+
if not blocked or not blocked['is_blocked']:
|
|
1130
|
+
raise HTTPException(status_code=400, detail="User is not blocked")
|
|
1131
|
+
|
|
1132
|
+
admin_id = getattr(request.state, 'user_id', 'unknown')
|
|
1133
|
+
|
|
1134
|
+
# Update blocked status
|
|
1135
|
+
await db.execute("""
|
|
1136
|
+
UPDATE blocked_users
|
|
1137
|
+
SET is_blocked = 0, unblocked_at = CURRENT_TIMESTAMP
|
|
1138
|
+
WHERE user_id = ?
|
|
1139
|
+
""", (user_id,))
|
|
1140
|
+
|
|
1141
|
+
# Audit log
|
|
1142
|
+
client_ip = get_client_ip(request)
|
|
1143
|
+
audit = await get_audit_logger()
|
|
1144
|
+
if audit:
|
|
1145
|
+
await audit.log(
|
|
1146
|
+
event_type="user_unblocked",
|
|
1147
|
+
action="User unblocked",
|
|
1148
|
+
actor_id=admin_id,
|
|
1149
|
+
actor_ip=client_ip,
|
|
1150
|
+
target_id=user_id,
|
|
1151
|
+
target_type="user"
|
|
1152
|
+
)
|
|
1153
|
+
|
|
1154
|
+
return {
|
|
1155
|
+
"success": True,
|
|
1156
|
+
"user_id": user_id,
|
|
1157
|
+
"unblocked_at": datetime.utcnow().isoformat() + "Z"
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
@router.get("/users/{user_id}/block-status", dependencies=[Depends(require_admin)])
|
|
1162
|
+
async def get_block_status(
|
|
1163
|
+
request: Request,
|
|
1164
|
+
user_id: str
|
|
1165
|
+
) -> BlockStatusResponse:
|
|
1166
|
+
"""Get user's block status."""
|
|
1167
|
+
from core.container import DependencyContainer
|
|
1168
|
+
container = DependencyContainer.get_instance()
|
|
1169
|
+
db = container.get_service('database_manager')
|
|
1170
|
+
|
|
1171
|
+
if not db:
|
|
1172
|
+
raise HTTPException(status_code=503, detail="Database unavailable")
|
|
1173
|
+
|
|
1174
|
+
# Get block status
|
|
1175
|
+
blocked = await db.fetch_one("""
|
|
1176
|
+
SELECT is_blocked, blocked_reason, blocked_at, blocked_by
|
|
1177
|
+
FROM blocked_users WHERE user_id = ?
|
|
1178
|
+
""", (user_id,))
|
|
1179
|
+
|
|
1180
|
+
if not blocked:
|
|
1181
|
+
return BlockStatusResponse(
|
|
1182
|
+
is_blocked=False,
|
|
1183
|
+
blocked_reason=None,
|
|
1184
|
+
blocked_at=None,
|
|
1185
|
+
blocked_by=None
|
|
1186
|
+
)
|
|
1187
|
+
|
|
1188
|
+
return BlockStatusResponse(
|
|
1189
|
+
is_blocked=bool(blocked['is_blocked']),
|
|
1190
|
+
blocked_reason=blocked['blocked_reason'],
|
|
1191
|
+
blocked_at=str(blocked['blocked_at']) if blocked['blocked_at'] else None,
|
|
1192
|
+
blocked_by=blocked['blocked_by']
|
|
1193
|
+
)
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
@router.get("/stats/dashboard", dependencies=[Depends(require_admin)])
|
|
1197
|
+
async def get_dashboard_stats(request: Request) -> DashboardStats:
|
|
1198
|
+
"""Get enhanced dashboard statistics with alerts."""
|
|
1199
|
+
from core.container import DependencyContainer
|
|
1200
|
+
container = DependencyContainer.get_instance()
|
|
1201
|
+
db = container.get_service('database_manager')
|
|
1202
|
+
|
|
1203
|
+
if not db:
|
|
1204
|
+
raise HTTPException(status_code=503, detail="Database unavailable")
|
|
1205
|
+
|
|
1206
|
+
# User stats
|
|
1207
|
+
total_users = await db.fetch_one("SELECT COUNT(*) as count FROM user_profiles")
|
|
1208
|
+
new_today = await db.fetch_one("""
|
|
1209
|
+
SELECT COUNT(*) as count FROM user_profiles
|
|
1210
|
+
WHERE DATE(created_at) = DATE('now')
|
|
1211
|
+
""")
|
|
1212
|
+
users_by_tier = await db.fetch_all("""
|
|
1213
|
+
SELECT tier, COUNT(*) as count FROM user_profiles GROUP BY tier
|
|
1214
|
+
""")
|
|
1215
|
+
users_by_role = await db.fetch_all("""
|
|
1216
|
+
SELECT role, COUNT(*) as count FROM user_profiles GROUP BY role
|
|
1217
|
+
""")
|
|
1218
|
+
|
|
1219
|
+
# Credit stats
|
|
1220
|
+
credit_totals = await db.fetch_one("""
|
|
1221
|
+
SELECT
|
|
1222
|
+
COALESCE(SUM(balance), 0) as total_balance,
|
|
1223
|
+
COALESCE(SUM(lifetime_earned), 0) as total_earned,
|
|
1224
|
+
COALESCE(SUM(lifetime_spent), 0) as total_spent
|
|
1225
|
+
FROM user_credits
|
|
1226
|
+
""")
|
|
1227
|
+
|
|
1228
|
+
# Session stats (today)
|
|
1229
|
+
sessions_today = await db.fetch_one("""
|
|
1230
|
+
SELECT COUNT(DISTINCT session_id) as count
|
|
1231
|
+
FROM usage_records
|
|
1232
|
+
WHERE DATE(timestamp) = DATE('now')
|
|
1233
|
+
""")
|
|
1234
|
+
|
|
1235
|
+
# Revenue stats - ALL TIME totals
|
|
1236
|
+
x402_total = await db.fetch_one("""
|
|
1237
|
+
SELECT COALESCE(SUM(amount_usd), 0) as total
|
|
1238
|
+
FROM x402_payment_requests
|
|
1239
|
+
WHERE status = 'completed'
|
|
1240
|
+
""")
|
|
1241
|
+
crypto_total = await db.fetch_one("""
|
|
1242
|
+
SELECT COALESCE(SUM(amount_usd), 0) as total
|
|
1243
|
+
FROM crypto_payments
|
|
1244
|
+
WHERE status = 'confirmed'
|
|
1245
|
+
""")
|
|
1246
|
+
|
|
1247
|
+
# Revenue stats - MTD (Month-to-Date)
|
|
1248
|
+
x402_mtd = await db.fetch_one("""
|
|
1249
|
+
SELECT COALESCE(SUM(amount_usd), 0) as total
|
|
1250
|
+
FROM x402_payment_requests
|
|
1251
|
+
WHERE status = 'completed'
|
|
1252
|
+
AND strftime('%Y-%m', completed_at) = strftime('%Y-%m', 'now')
|
|
1253
|
+
""")
|
|
1254
|
+
crypto_mtd = await db.fetch_one("""
|
|
1255
|
+
SELECT COALESCE(SUM(amount_usd), 0) as total
|
|
1256
|
+
FROM crypto_payments
|
|
1257
|
+
WHERE status = 'confirmed'
|
|
1258
|
+
AND strftime('%Y-%m', detected_at) = strftime('%Y-%m', 'now')
|
|
1259
|
+
""")
|
|
1260
|
+
|
|
1261
|
+
# Alerts
|
|
1262
|
+
alerts = []
|
|
1263
|
+
|
|
1264
|
+
# Check for outdated token verifications (>24h)
|
|
1265
|
+
outdated_verifications = await db.fetch_one("""
|
|
1266
|
+
SELECT COUNT(*) as count FROM user_profiles
|
|
1267
|
+
WHERE den_token_count > 0
|
|
1268
|
+
AND (den_token_verified_at IS NULL
|
|
1269
|
+
OR den_token_verified_at < datetime('now', '-24 hours'))
|
|
1270
|
+
""")
|
|
1271
|
+
if outdated_verifications and outdated_verifications['count'] > 0:
|
|
1272
|
+
alerts.append({
|
|
1273
|
+
"type": "outdated_verification",
|
|
1274
|
+
"severity": "warning",
|
|
1275
|
+
"count": outdated_verifications['count'],
|
|
1276
|
+
"message": f"{outdated_verifications['count']} users with outdated token verification (>24h)"
|
|
1277
|
+
})
|
|
1278
|
+
|
|
1279
|
+
# Check for pending sweeps (>1h)
|
|
1280
|
+
pending_sweeps = await db.fetch_one("""
|
|
1281
|
+
SELECT COUNT(*) as count FROM crypto_payments
|
|
1282
|
+
WHERE swept_at IS NULL
|
|
1283
|
+
AND detected_at < datetime('now', '-1 hour')
|
|
1284
|
+
""")
|
|
1285
|
+
if pending_sweeps and pending_sweeps['count'] > 0:
|
|
1286
|
+
alerts.append({
|
|
1287
|
+
"type": "pending_sweep",
|
|
1288
|
+
"severity": "warning",
|
|
1289
|
+
"count": pending_sweeps['count'],
|
|
1290
|
+
"message": f"{pending_sweeps['count']} pending deposits not swept (>1h old)"
|
|
1291
|
+
})
|
|
1292
|
+
|
|
1293
|
+
# Check for recent auth failures
|
|
1294
|
+
auth_failures = await db.fetch_one("""
|
|
1295
|
+
SELECT COUNT(*) as count FROM audit_log
|
|
1296
|
+
WHERE event_type = 'auth_failure'
|
|
1297
|
+
AND timestamp > datetime('now', '-1 hour')
|
|
1298
|
+
""")
|
|
1299
|
+
if auth_failures and auth_failures['count'] > 5:
|
|
1300
|
+
alerts.append({
|
|
1301
|
+
"type": "auth_failures",
|
|
1302
|
+
"severity": "high",
|
|
1303
|
+
"count": auth_failures['count'],
|
|
1304
|
+
"message": f"{auth_failures['count']} authentication failures in the last hour"
|
|
1305
|
+
})
|
|
1306
|
+
|
|
1307
|
+
return DashboardStats(
|
|
1308
|
+
users={
|
|
1309
|
+
"total": total_users['count'] if total_users else 0,
|
|
1310
|
+
"new_today": new_today['count'] if new_today else 0,
|
|
1311
|
+
"by_tier": {row['tier'] or 'free': row['count'] for row in users_by_tier},
|
|
1312
|
+
"by_role": {row['role'] or 'user': row['count'] for row in users_by_role}
|
|
1313
|
+
},
|
|
1314
|
+
credits={
|
|
1315
|
+
"total_balance": credit_totals['total_balance'] if credit_totals else 0,
|
|
1316
|
+
"total_issued": credit_totals['total_earned'] if credit_totals else 0,
|
|
1317
|
+
"total_spent": credit_totals['total_spent'] if credit_totals else 0
|
|
1318
|
+
},
|
|
1319
|
+
sessions={
|
|
1320
|
+
"total_today": sessions_today['count'] if sessions_today else 0
|
|
1321
|
+
},
|
|
1322
|
+
revenue={
|
|
1323
|
+
# MTD values (shown in breakdown)
|
|
1324
|
+
"x402_mtd_usd": float(x402_mtd['total']) if x402_mtd else 0.0,
|
|
1325
|
+
"crypto_mtd_usd": float(crypto_mtd['total']) if crypto_mtd else 0.0,
|
|
1326
|
+
"mtd_usd": (float(x402_mtd['total']) if x402_mtd else 0.0) + (float(crypto_mtd['total']) if crypto_mtd else 0.0),
|
|
1327
|
+
# All-time totals
|
|
1328
|
+
"x402_total_usd": float(x402_total['total']) if x402_total else 0.0,
|
|
1329
|
+
"crypto_total_usd": float(crypto_total['total']) if crypto_total else 0.0,
|
|
1330
|
+
"total_usd": (float(x402_total['total']) if x402_total else 0.0) + (float(crypto_total['total']) if crypto_total else 0.0)
|
|
1331
|
+
},
|
|
1332
|
+
alerts=alerts
|
|
1333
|
+
)
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
@router.get("/activity", dependencies=[Depends(require_admin)])
|
|
1337
|
+
async def get_activity_feed(
|
|
1338
|
+
request: Request,
|
|
1339
|
+
limit: int = Query(50, ge=1, le=200),
|
|
1340
|
+
offset: int = Query(0, ge=0),
|
|
1341
|
+
event_type: Optional[str] = Query(None, description="Filter by event type"),
|
|
1342
|
+
actor_id: Optional[str] = Query(None, description="Filter by actor user ID"),
|
|
1343
|
+
target_id: Optional[str] = Query(None, description="Filter by target user ID")
|
|
1344
|
+
) -> List[AuditEvent]:
|
|
1345
|
+
"""Get recent activity feed from audit log."""
|
|
1346
|
+
audit = await get_audit_logger()
|
|
1347
|
+
|
|
1348
|
+
if not audit:
|
|
1349
|
+
raise HTTPException(status_code=503, detail="Audit service unavailable")
|
|
1350
|
+
|
|
1351
|
+
events = await audit.get_recent_events(
|
|
1352
|
+
limit=limit,
|
|
1353
|
+
offset=offset,
|
|
1354
|
+
event_type=event_type,
|
|
1355
|
+
actor_id=actor_id,
|
|
1356
|
+
target_id=target_id
|
|
1357
|
+
)
|
|
1358
|
+
|
|
1359
|
+
return [
|
|
1360
|
+
AuditEvent(
|
|
1361
|
+
id=e['id'],
|
|
1362
|
+
timestamp=str(e['timestamp']),
|
|
1363
|
+
event_type=e['event_type'],
|
|
1364
|
+
actor_id=e['actor_id'],
|
|
1365
|
+
actor_wallet=e['actor_wallet'],
|
|
1366
|
+
actor_ip=e['actor_ip'],
|
|
1367
|
+
target_id=e['target_id'],
|
|
1368
|
+
action=e['action'],
|
|
1369
|
+
old_value=e['old_value'],
|
|
1370
|
+
new_value=e['new_value'],
|
|
1371
|
+
success=bool(e['success'])
|
|
1372
|
+
)
|
|
1373
|
+
for e in events
|
|
1374
|
+
]
|
|
1375
|
+
|
|
1376
|
+
|
|
1377
|
+
@router.get("/billing-failures", dependencies=[Depends(require_admin)])
|
|
1378
|
+
async def get_billing_failures(
|
|
1379
|
+
request: Request,
|
|
1380
|
+
status: str = Query("pending", description="Filter by status: pending, resolved, written_off"),
|
|
1381
|
+
limit: int = Query(50, ge=1, le=200),
|
|
1382
|
+
offset: int = Query(0, ge=0)
|
|
1383
|
+
) -> BillingFailuresResponse:
|
|
1384
|
+
"""Get billing failures for admin review.
|
|
1385
|
+
|
|
1386
|
+
Returns list of billing failures where credits could not be deducted.
|
|
1387
|
+
Admins can review and resolve these issues.
|
|
1388
|
+
"""
|
|
1389
|
+
from core.container import DependencyContainer
|
|
1390
|
+
container = DependencyContainer.get_instance()
|
|
1391
|
+
db = container.get_service('database_manager')
|
|
1392
|
+
|
|
1393
|
+
if not db:
|
|
1394
|
+
raise HTTPException(status_code=503, detail="Database unavailable")
|
|
1395
|
+
|
|
1396
|
+
# Get failures with user info
|
|
1397
|
+
failures = await db.fetch_all("""
|
|
1398
|
+
SELECT
|
|
1399
|
+
bf.id, bf.user_id, bf.session_id, bf.request_id,
|
|
1400
|
+
bf.credits_owed, bf.api_cost_usd, bf.model,
|
|
1401
|
+
bf.created_at, bf.resolved_at, bf.status, bf.resolution_notes,
|
|
1402
|
+
COALESCE(uc.balance, 0) as current_balance,
|
|
1403
|
+
u.wallet_address
|
|
1404
|
+
FROM billing_failures bf
|
|
1405
|
+
LEFT JOIN user_credits uc ON bf.user_id = uc.user_id
|
|
1406
|
+
LEFT JOIN user_profiles u ON bf.user_id = u.user_id
|
|
1407
|
+
WHERE bf.status = ?
|
|
1408
|
+
ORDER BY bf.created_at DESC
|
|
1409
|
+
LIMIT ? OFFSET ?
|
|
1410
|
+
""", (status, limit, offset))
|
|
1411
|
+
|
|
1412
|
+
# Get totals
|
|
1413
|
+
totals = await db.fetch_one("""
|
|
1414
|
+
SELECT
|
|
1415
|
+
COUNT(*) as total,
|
|
1416
|
+
COALESCE(SUM(credits_owed), 0) as total_credits,
|
|
1417
|
+
COALESCE(SUM(api_cost_usd), 0) as total_api_cost
|
|
1418
|
+
FROM billing_failures
|
|
1419
|
+
WHERE status = ?
|
|
1420
|
+
""", (status,))
|
|
1421
|
+
|
|
1422
|
+
return BillingFailuresResponse(
|
|
1423
|
+
failures=[
|
|
1424
|
+
BillingFailure(
|
|
1425
|
+
id=f['id'],
|
|
1426
|
+
user_id=f['user_id'],
|
|
1427
|
+
session_id=f['session_id'],
|
|
1428
|
+
request_id=f['request_id'],
|
|
1429
|
+
credits_owed=f['credits_owed'],
|
|
1430
|
+
api_cost_usd=f['api_cost_usd'],
|
|
1431
|
+
model=f['model'],
|
|
1432
|
+
created_at=str(f['created_at']),
|
|
1433
|
+
resolved_at=str(f['resolved_at']) if f['resolved_at'] else None,
|
|
1434
|
+
status=f['status'],
|
|
1435
|
+
resolution_notes=f['resolution_notes'],
|
|
1436
|
+
current_balance=f['current_balance'],
|
|
1437
|
+
wallet_address=f['wallet_address']
|
|
1438
|
+
)
|
|
1439
|
+
for f in failures
|
|
1440
|
+
],
|
|
1441
|
+
total=totals['total'] if totals else 0,
|
|
1442
|
+
status_filter=status,
|
|
1443
|
+
total_credits_owed=totals['total_credits'] if totals else 0,
|
|
1444
|
+
total_api_cost_usd=float(totals['total_api_cost']) if totals else 0.0
|
|
1445
|
+
)
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
@router.post("/billing-failures/{failure_id}/resolve", dependencies=[Depends(require_admin)])
|
|
1449
|
+
async def resolve_billing_failure(
|
|
1450
|
+
request: Request,
|
|
1451
|
+
failure_id: int,
|
|
1452
|
+
resolution_request: ResolveBillingFailureRequest
|
|
1453
|
+
):
|
|
1454
|
+
"""Resolve a billing failure.
|
|
1455
|
+
|
|
1456
|
+
Resolution options:
|
|
1457
|
+
- charged: Successfully deducted credits from user
|
|
1458
|
+
- written_off: Loss absorbed, user not charged
|
|
1459
|
+
- refunded: Credits returned to user (if already charged)
|
|
1460
|
+
"""
|
|
1461
|
+
from core.container import DependencyContainer
|
|
1462
|
+
container = DependencyContainer.get_instance()
|
|
1463
|
+
db = container.get_service('database_manager')
|
|
1464
|
+
|
|
1465
|
+
if not db:
|
|
1466
|
+
raise HTTPException(status_code=503, detail="Database unavailable")
|
|
1467
|
+
|
|
1468
|
+
# Verify failure exists and is pending
|
|
1469
|
+
failure = await db.fetch_one("""
|
|
1470
|
+
SELECT id, user_id, credits_owed, status
|
|
1471
|
+
FROM billing_failures WHERE id = ?
|
|
1472
|
+
""", (failure_id,))
|
|
1473
|
+
|
|
1474
|
+
if not failure:
|
|
1475
|
+
raise HTTPException(status_code=404, detail="Billing failure not found")
|
|
1476
|
+
|
|
1477
|
+
if failure['status'] != 'pending':
|
|
1478
|
+
raise HTTPException(
|
|
1479
|
+
status_code=400,
|
|
1480
|
+
detail=f"Failure already resolved with status: {failure['status']}"
|
|
1481
|
+
)
|
|
1482
|
+
|
|
1483
|
+
admin_id = getattr(request.state, 'user_id', 'unknown')
|
|
1484
|
+
resolution = resolution_request.resolution
|
|
1485
|
+
notes = resolution_request.notes
|
|
1486
|
+
|
|
1487
|
+
# If resolution is "charged", try to actually deduct credits
|
|
1488
|
+
if resolution == "charged":
|
|
1489
|
+
balance_mgr = container.get_service('balance_manager')
|
|
1490
|
+
if balance_mgr:
|
|
1491
|
+
success = await balance_mgr.deduct_credits(
|
|
1492
|
+
user_id=failure['user_id'],
|
|
1493
|
+
amount=failure['credits_owed'],
|
|
1494
|
+
reason=f"Billing reconciliation (resolved by admin {admin_id})"
|
|
1495
|
+
)
|
|
1496
|
+
if not success:
|
|
1497
|
+
raise HTTPException(
|
|
1498
|
+
status_code=400,
|
|
1499
|
+
detail="Failed to deduct credits - user may still have insufficient balance"
|
|
1500
|
+
)
|
|
1501
|
+
|
|
1502
|
+
# Update failure record
|
|
1503
|
+
await db.execute("""
|
|
1504
|
+
UPDATE billing_failures
|
|
1505
|
+
SET status = 'resolved',
|
|
1506
|
+
resolved_at = CURRENT_TIMESTAMP,
|
|
1507
|
+
resolution_notes = ?
|
|
1508
|
+
WHERE id = ?
|
|
1509
|
+
""", (f"{resolution}: {notes} (by {admin_id})", failure_id))
|
|
1510
|
+
|
|
1511
|
+
# Audit log
|
|
1512
|
+
audit = await get_audit_logger()
|
|
1513
|
+
if audit:
|
|
1514
|
+
client_ip = get_client_ip(request)
|
|
1515
|
+
await audit.log(
|
|
1516
|
+
event_type="billing_failure_resolved",
|
|
1517
|
+
action=f"Resolved billing failure #{failure_id}: {resolution}",
|
|
1518
|
+
actor_id=admin_id,
|
|
1519
|
+
actor_ip=client_ip,
|
|
1520
|
+
target_id=failure['user_id'],
|
|
1521
|
+
target_type="billing_failure",
|
|
1522
|
+
old_value="pending",
|
|
1523
|
+
new_value=resolution,
|
|
1524
|
+
metadata={
|
|
1525
|
+
"failure_id": failure_id,
|
|
1526
|
+
"credits_owed": failure['credits_owed'],
|
|
1527
|
+
"resolution": resolution,
|
|
1528
|
+
"notes": notes
|
|
1529
|
+
}
|
|
1530
|
+
)
|
|
1531
|
+
|
|
1532
|
+
return {
|
|
1533
|
+
"success": True,
|
|
1534
|
+
"failure_id": failure_id,
|
|
1535
|
+
"resolution": resolution,
|
|
1536
|
+
"resolved_by": admin_id
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
|
|
1540
|
+
@router.get("/billing-failures/summary", dependencies=[Depends(require_admin)])
|
|
1541
|
+
async def get_billing_failures_summary(request: Request):
|
|
1542
|
+
"""Get summary of billing failures by status."""
|
|
1543
|
+
from core.container import DependencyContainer
|
|
1544
|
+
container = DependencyContainer.get_instance()
|
|
1545
|
+
db = container.get_service('database_manager')
|
|
1546
|
+
|
|
1547
|
+
if not db:
|
|
1548
|
+
raise HTTPException(status_code=503, detail="Database unavailable")
|
|
1549
|
+
|
|
1550
|
+
summary = await db.fetch_all("""
|
|
1551
|
+
SELECT
|
|
1552
|
+
status,
|
|
1553
|
+
COUNT(*) as count,
|
|
1554
|
+
COALESCE(SUM(credits_owed), 0) as total_credits,
|
|
1555
|
+
COALESCE(SUM(api_cost_usd), 0) as total_api_cost
|
|
1556
|
+
FROM billing_failures
|
|
1557
|
+
GROUP BY status
|
|
1558
|
+
""")
|
|
1559
|
+
|
|
1560
|
+
# Get recent failures (last 24h)
|
|
1561
|
+
recent = await db.fetch_one("""
|
|
1562
|
+
SELECT COUNT(*) as count
|
|
1563
|
+
FROM billing_failures
|
|
1564
|
+
WHERE created_at > datetime('now', '-24 hours')
|
|
1565
|
+
""")
|
|
1566
|
+
|
|
1567
|
+
result = {
|
|
1568
|
+
"by_status": {
|
|
1569
|
+
row['status']: {
|
|
1570
|
+
"count": row['count'],
|
|
1571
|
+
"total_credits_owed": row['total_credits'],
|
|
1572
|
+
"total_api_cost_usd": float(row['total_api_cost'])
|
|
1573
|
+
}
|
|
1574
|
+
for row in summary
|
|
1575
|
+
},
|
|
1576
|
+
"recent_24h": recent['count'] if recent else 0
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
# Calculate totals
|
|
1580
|
+
result["total_pending"] = result["by_status"].get("pending", {}).get("count", 0)
|
|
1581
|
+
result["total_pending_credits"] = result["by_status"].get("pending", {}).get("total_credits_owed", 0)
|
|
1582
|
+
result["total_pending_api_cost"] = result["by_status"].get("pending", {}).get("total_api_cost_usd", 0.0)
|
|
1583
|
+
|
|
1584
|
+
return result
|