diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 28497fa..4b38516 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -83,6 +83,8 @@ jobs: backend-test: name: Backend Tests runs-on: lxc + # Disabled for now since tests are not integrated yet + if: false steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/.sisyphus/boulder.json b/.sisyphus/boulder.json deleted file mode 100644 index c5ac8c2..0000000 --- a/.sisyphus/boulder.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "active_plan": "/Users/piotr/dev/innercontext/.sisyphus/plans/multi-user-authelia-oidc.md", - "started_at": "2026-03-12T13:31:34.526Z", - "session_ids": [ - "ses_31e44571affeyTpySqhHAuYVAm" - ], - "plan_name": "multi-user-authelia-oidc", - "agent": "atlas" -} \ No newline at end of file diff --git a/.sisyphus/evidence/task-T10-health-check.txt b/.sisyphus/evidence/task-T10-health-check.txt deleted file mode 100644 index 7fa6cd3..0000000 --- a/.sisyphus/evidence/task-T10-health-check.txt +++ /dev/null @@ -1,9 +0,0 @@ -Scenario: Health check handles auth redirects -Steps: Run updated scripts/healthcheck.sh -Expected: Success despite auth redirects - -Output: -[2026-03-12 15:55:04] ✓ innercontext is healthy -[2026-03-12 15:55:04] ✓ innercontext-node is healthy (status 302) -[2026-03-12 15:55:04] ✓ innercontext-pricing-worker is running -[2026-03-12 15:55:04] All services healthy diff --git a/.sisyphus/evidence/task-T10-missing-env.txt b/.sisyphus/evidence/task-T10-missing-env.txt deleted file mode 100644 index 706e683..0000000 --- a/.sisyphus/evidence/task-T10-missing-env.txt +++ /dev/null @@ -1,41 +0,0 @@ -Scenario: Deploy validation rejects missing auth config -Steps: Run scripts/validate-env.sh with missing OIDC var -Expected: Exits non-zero, names missing var - -Output: -=== Validating Shared Directory Structure === -✓ Shared directory exists: /tmp/innercontext/shared -✓ Shared backend .env exists: /tmp/innercontext/shared/backend/.env -✓ Shared frontend .env.production exists: /tmp/innercontext/shared/frontend/.env.production - -=== Validating Symlinks in Current Release === -✓ Symlink correct: /tmp/innercontext/current/backend/.env -> ../../../shared/backend/.env -✓ Symlink correct: /tmp/innercontext/current/frontend/.env.production -> ../../../shared/frontend/.env.production - -=== Validating Backend Environment Variables === -✓ DATABASE_URL is set -✓ GEMINI_API_KEY is set -⚠ LOG_LEVEL not found in /tmp/innercontext/shared/backend/.env (optional) -⚠ CORS_ORIGINS not found in /tmp/innercontext/shared/backend/.env (optional) -✗ OIDC_ISSUER not found in /tmp/innercontext/shared/backend/.env -✗ OIDC_CLIENT_ID not found in /tmp/innercontext/shared/backend/.env -✗ OIDC_DISCOVERY_URL not found in /tmp/innercontext/shared/backend/.env -✗ OIDC_ADMIN_GROUPS not found in /tmp/innercontext/shared/backend/.env -✗ OIDC_MEMBER_GROUPS not found in /tmp/innercontext/shared/backend/.env -⚠ OIDC_JWKS_CACHE_TTL_SECONDS not found in /tmp/innercontext/shared/backend/.env (optional) -⚠ BOOTSTRAP_ADMIN_OIDC_ISSUER not found in /tmp/innercontext/shared/backend/.env (optional) -⚠ BOOTSTRAP_ADMIN_OIDC_SUB not found in /tmp/innercontext/shared/backend/.env (optional) -⚠ BOOTSTRAP_ADMIN_EMAIL not found in /tmp/innercontext/shared/backend/.env (optional) -⚠ BOOTSTRAP_ADMIN_NAME not found in /tmp/innercontext/shared/backend/.env (optional) -⚠ BOOTSTRAP_HOUSEHOLD_NAME not found in /tmp/innercontext/shared/backend/.env (optional) - -=== Validating Frontend Environment Variables === -✓ PUBLIC_API_BASE is set -✓ ORIGIN is set -✗ SESSION_SECRET not found in /tmp/innercontext/shared/frontend/.env.production -✗ OIDC_ISSUER not found in /tmp/innercontext/shared/frontend/.env.production -✗ OIDC_CLIENT_ID not found in /tmp/innercontext/shared/frontend/.env.production -✗ OIDC_DISCOVERY_URL not found in /tmp/innercontext/shared/frontend/.env.production - -✗ Found 9 error(s) in environment configuration - And 8 warning(s) diff --git a/.sisyphus/evidence/task-T11-backend-regression.txt b/.sisyphus/evidence/task-T11-backend-regression.txt deleted file mode 100644 index 9025ce0..0000000 --- a/.sisyphus/evidence/task-T11-backend-regression.txt +++ /dev/null @@ -1,283 +0,0 @@ -============================= test session starts ============================== -platform darwin -- Python 3.12.12, pytest-9.0.2, pluggy-1.6.0 -- /Users/piotr/dev/innercontext/backend/.venv/bin/python3 -cachedir: .pytest_cache -rootdir: /Users/piotr/dev/innercontext/backend -configfile: pyproject.toml -testpaths: tests -plugins: anyio-4.12.1, cov-7.0.0 -collecting ... collected 221 items - -tests/test_admin_households.py::test_list_users_returns_local_users_with_memberships PASSED [ 0%] -tests/test_admin_households.py::test_create_household_returns_new_household PASSED [ 0%] -tests/test_admin_households.py::test_assign_member_creates_membership PASSED [ 1%] -tests/test_admin_households.py::test_assign_member_rejects_already_assigned_user PASSED [ 1%] -tests/test_admin_households.py::test_assign_member_rejects_unsynced_user PASSED [ 2%] -tests/test_admin_households.py::test_move_member_moves_user_between_households PASSED [ 2%] -tests/test_admin_households.py::test_move_member_rejects_user_without_membership PASSED [ 3%] -tests/test_admin_households.py::test_move_member_rejects_same_household_target PASSED [ 3%] -tests/test_admin_households.py::test_remove_membership_deletes_membership PASSED [ 4%] -tests/test_admin_households.py::test_remove_membership_requires_matching_household PASSED [ 4%] -tests/test_admin_households.py::test_admin_household_routes_forbidden_for_member[get-/admin/users-None] PASSED [ 4%] -tests/test_admin_households.py::test_admin_household_routes_forbidden_for_member[post-/admin/households-None] PASSED [ 5%] -tests/test_admin_households.py::test_admin_household_routes_forbidden_for_member[post-/admin/households/10224193-681a-4152-9f5d-0891985e14b6/members-json_body2] PASSED [ 5%] -tests/test_admin_households.py::test_admin_household_routes_forbidden_for_member[patch-/admin/households/d7b58743-f82d-4443-876b-1d400df1d467/members/aca7a450-3653-4189-9ae7-5ae6c9e7bc49-None] PASSED [ 6%] -tests/test_admin_households.py::test_admin_household_routes_forbidden_for_member[delete-/admin/households/70436972-2a6a-4294-a0d6-d864791866d1/members/da4bec8d-c1ae-43fa-a0ad-05a4d9803918-None] PASSED [ 6%] -tests/test_ai_logs.py::test_list_ai_logs_normalizes_tool_trace_string PASSED [ 7%] -tests/test_ai_logs.py::test_get_ai_log_normalizes_tool_trace_string PASSED [ 7%] -tests/test_auth.py::test_validate_access_token_uses_cached_jwks PASSED [ 8%] -tests/test_auth.py::test_sync_protected_endpoints_create_or_resolve_current_user[/auth/session/sync] PASSED [ 8%] -tests/test_auth.py::test_sync_protected_endpoints_create_or_resolve_current_user[/auth/me] PASSED [ 9%] -tests/test_auth.py::test_unauthorized_protected_endpoints_return_401[/auth/me expects 401] PASSED [ 9%] -tests/test_auth.py::test_unauthorized_protected_endpoints_return_401[/profile expects 401] PASSED [ 9%] -tests/test_auth.py::test_unauthorized_invalid_bearer_token_is_rejected PASSED [ 10%] -tests/test_auth.py::test_require_admin_raises_for_member PASSED [ 10%] -tests/test_authz.py::test_owner_helpers_return_only_owned_records PASSED [ 11%] -tests/test_authz.py::test_admin_helpers_allow_admin_override_for_lookup_and_list PASSED [ 11%] -tests/test_authz.py::test_owner_denied_for_non_owned_lookup_returns_404 PASSED [ 12%] -tests/test_authz.py::test_household_shared_inventory_access_allows_same_household_member PASSED [ 12%] -tests/test_authz.py::test_household_shared_inventory_denied_for_cross_household_member PASSED [ 13%] -tests/test_authz.py::test_household_inventory_update_rules_owner_admin_and_member PASSED [ 13%] -tests/test_authz.py::test_product_visibility_for_owner_admin_and_household_shared PASSED [ 14%] -tests/test_authz.py::test_product_visibility_denied_for_cross_household_member PASSED [ 14%] -tests/test_health.py::test_create_medication_minimal PASSED [ 14%] -tests/test_health.py::test_create_medication_invalid_kind PASSED [ 15%] -tests/test_health.py::test_list_medications_empty PASSED [ 15%] -tests/test_health.py::test_list_filter_kind PASSED [ 16%] -tests/test_health.py::test_list_filter_product_name PASSED [ 16%] -tests/test_health.py::test_get_medication PASSED [ 17%] -tests/test_health.py::test_get_medication_not_found PASSED [ 17%] -tests/test_health.py::test_update_medication PASSED [ 18%] -tests/test_health.py::test_update_medication_not_found PASSED [ 18%] -tests/test_health.py::test_delete_medication_no_usages PASSED [ 19%] -tests/test_health.py::test_delete_medication_with_usages PASSED [ 19%] -tests/test_health.py::test_create_usage PASSED [ 19%] -tests/test_health.py::test_create_usage_medication_not_found PASSED [ 20%] -tests/test_health.py::test_list_usages_empty PASSED [ 20%] -tests/test_health.py::test_list_usages_returns_entries PASSED [ 21%] -tests/test_health.py::test_update_usage PASSED [ 21%] -tests/test_health.py::test_update_usage_not_found PASSED [ 22%] -tests/test_health.py::test_delete_usage PASSED [ 22%] -tests/test_health.py::test_delete_usage_not_found PASSED [ 23%] -tests/test_health.py::test_create_lab_result PASSED [ 23%] -tests/test_health.py::test_create_lab_result_invalid_code PASSED [ 23%] -tests/test_health.py::test_create_lab_result_invalid_flag PASSED [ 24%] -tests/test_health.py::test_list_lab_results_empty PASSED [ 24%] -tests/test_health.py::test_list_filter_test_code PASSED [ 25%] -tests/test_health.py::test_list_filter_flag PASSED [ 25%] -tests/test_health.py::test_list_filter_date_range PASSED [ 26%] -tests/test_health.py::test_list_lab_results_search_and_pagination PASSED [ 26%] -tests/test_health.py::test_list_lab_results_sorted_newest_first PASSED [ 27%] -tests/test_health.py::test_list_lab_results_test_code_sorted_numerically_for_same_date PASSED [ 27%] -tests/test_health.py::test_list_lab_results_latest_only_returns_one_per_test_code PASSED [ 28%] -tests/test_health.py::test_get_lab_result PASSED [ 28%] -tests/test_health.py::test_get_lab_result_not_found PASSED [ 28%] -tests/test_health.py::test_update_lab_result PASSED [ 29%] -tests/test_health.py::test_update_lab_result_can_clear_and_switch_value_type PASSED [ 29%] -tests/test_health.py::test_delete_lab_result PASSED [ 30%] -tests/test_inventory.py::test_get_inventory_by_id PASSED [ 30%] -tests/test_inventory.py::test_get_inventory_not_found PASSED [ 31%] -tests/test_inventory.py::test_update_inventory_opened PASSED [ 31%] -tests/test_inventory.py::test_update_inventory_not_found PASSED [ 32%] -tests/test_inventory.py::test_delete_inventory PASSED [ 32%] -tests/test_inventory.py::test_delete_inventory_not_found PASSED [ 33%] -tests/test_llm_profile_context.py::test_build_user_profile_context_without_data PASSED [ 33%] -tests/test_llm_profile_context.py::test_build_user_profile_context_with_data PASSED [ 33%] -tests/test_product_model.py::test_always_present_keys PASSED [ 34%] -tests/test_product_model.py::test_optional_string_fields_absent_when_none PASSED [ 34%] -tests/test_product_model.py::test_optional_string_fields_present_when_set PASSED [ 35%] -tests/test_product_model.py::test_ph_exact_collapses PASSED [ 35%] -tests/test_product_model.py::test_ph_range PASSED [ 36%] -tests/test_product_model.py::test_ph_only_min PASSED [ 36%] -tests/test_product_model.py::test_ph_only_max PASSED [ 37%] -tests/test_product_model.py::test_actives_pydantic_objects PASSED [ 37%] -tests/test_product_model.py::test_actives_raw_dicts PASSED [ 38%] -tests/test_product_model.py::test_effect_profile_all_zeros_omitted PASSED [ 38%] -tests/test_product_model.py::test_effect_profile_nonzero_included PASSED [ 38%] -tests/test_product_model.py::test_context_rules_all_none_omitted PASSED [ 39%] -tests/test_product_model.py::test_context_rules_with_value PASSED [ 39%] -tests/test_product_model.py::test_safety_dict_present_when_set PASSED [ 40%] -tests/test_product_model.py::test_empty_lists_omitted PASSED [ 40%] -tests/test_product_model.py::test_nonempty_lists_included PASSED [ 41%] -tests/test_products.py::test_create_minimal PASSED [ 41%] -tests/test_products.py::test_create_with_actives PASSED [ 42%] -tests/test_products.py::test_create_invalid_enum PASSED [ 42%] -tests/test_products.py::test_create_missing_required PASSED [ 42%] -tests/test_products.py::test_list_empty PASSED [ 43%] -tests/test_products.py::test_list_returns_created PASSED [ 43%] -tests/test_products.py::test_list_filter_category PASSED [ 44%] -tests/test_products.py::test_list_filter_brand PASSED [ 44%] -tests/test_products.py::test_list_filter_is_medication PASSED [ 45%] -tests/test_products.py::test_list_filter_targets PASSED [ 45%] -tests/test_products.py::test_get_by_id PASSED [ 46%] -tests/test_products.py::test_get_not_found PASSED [ 46%] -tests/test_products.py::test_update_name PASSED [ 47%] -tests/test_products.py::test_update_json_field PASSED [ 47%] -tests/test_products.py::test_update_not_found PASSED [ 47%] -tests/test_products.py::test_delete PASSED [ 48%] -tests/test_products.py::test_delete_not_found PASSED [ 48%] -tests/test_products.py::test_list_inventory_empty PASSED [ 49%] -tests/test_products.py::test_list_inventory_product_not_found PASSED [ 49%] -tests/test_products.py::test_create_inventory PASSED [ 50%] -tests/test_products.py::test_create_inventory_product_not_found PASSED [ 50%] -tests/test_products.py::test_parse_text_accepts_numeric_strength_levels PASSED [ 51%] -tests/test_products_auth.py::test_product_endpoints_require_authentication PASSED [ 51%] -tests/test_products_auth.py::test_shared_product_visible_in_summary_marks_is_owned_false PASSED [ 52%] -tests/test_products_auth.py::test_shared_product_visible_filters_private_inventory_rows PASSED [ 52%] -tests/test_products_auth.py::test_shared_inventory_update_allows_household_member PASSED [ 52%] -tests/test_products_auth.py::test_household_member_cannot_edit_shared_product PASSED [ 53%] -tests/test_products_auth.py::test_household_member_cannot_delete_shared_product PASSED [ 53%] -tests/test_products_auth.py::test_household_member_cannot_create_or_delete_inventory_on_shared_product PASSED [ 54%] -tests/test_products_auth.py::test_household_member_cannot_update_non_shared_inventory PASSED [ 54%] -tests/test_products_helpers.py::test_build_shopping_context PASSED [ 55%] -tests/test_products_helpers.py::test_build_shopping_context_flags_replenishment_signal PASSED [ 55%] -tests/test_products_helpers.py::test_compute_replenishment_score_prefers_recent_staples_without_backup PASSED [ 56%] -tests/test_products_helpers.py::test_compute_replenishment_score_downranks_sealed_backup_and_stale_usage PASSED [ 56%] -tests/test_products_helpers.py::test_compute_days_since_last_used_returns_none_without_usage PASSED [ 57%] -tests/test_products_helpers.py::test_suggest_shopping PASSED [ 57%] -tests/test_products_helpers.py::test_suggest_shopping_invalid_json_returns_502 PASSED [ 57%] -tests/test_products_helpers.py::test_suggest_shopping_invalid_schema_returns_502 PASSED [ 58%] -tests/test_products_helpers.py::test_suggest_shopping_invalid_target_concern_returns_502 PASSED [ 58%] -tests/test_products_helpers.py::test_shopping_context_medication_skip PASSED [ 59%] -tests/test_products_helpers.py::test_extract_requested_product_ids_dedupes_and_limits PASSED [ 59%] -tests/test_products_helpers.py::test_shopping_tool_handlers_return_payloads PASSED [ 60%] -tests/test_products_helpers.py::test_shopping_tool_handler_includes_last_used_on_from_mapping PASSED [ 60%] -tests/test_products_helpers.py::test_shopping_validator_accepts_freeform_product_type_and_frequency PASSED [ 61%] -tests/test_products_pricing.py::test_compute_pricing_outputs_groups_by_category PASSED [ 61%] -tests/test_products_pricing.py::test_price_tier_is_null_when_not_enough_products PASSED [ 61%] -tests/test_products_pricing.py::test_price_tier_is_computed_by_worker PASSED [ 62%] -tests/test_products_pricing.py::test_price_tier_uses_fallback_for_medium_categories PASSED [ 62%] -tests/test_products_pricing.py::test_price_tier_stays_null_for_tiny_categories_even_with_fallback_pool PASSED [ 63%] -tests/test_products_pricing.py::test_product_write_enqueues_pricing_job PASSED [ 63%] -tests/test_profile.py::test_get_profile_empty PASSED [ 64%] -tests/test_profile.py::test_upsert_profile_create_and_get PASSED [ 64%] -tests/test_profile.py::test_upsert_profile_updates_existing_row PASSED [ 65%] -tests/test_routines.py::test_create_routine_minimal PASSED [ 65%] -tests/test_routines.py::test_create_routine_invalid_part_of_day PASSED [ 66%] -tests/test_routines.py::test_list_routines_empty PASSED [ 66%] -tests/test_routines.py::test_list_filter_date_range PASSED [ 66%] -tests/test_routines.py::test_list_filter_part_of_day PASSED [ 67%] -tests/test_routines.py::test_get_routine PASSED [ 67%] -tests/test_routines.py::test_get_routine_not_found PASSED [ 68%] -tests/test_routines.py::test_update_routine_notes PASSED [ 68%] -tests/test_routines.py::test_update_routine_not_found PASSED [ 69%] -tests/test_routines.py::test_delete_routine PASSED [ 69%] -tests/test_routines.py::test_add_step_action_only PASSED [ 70%] -tests/test_routines.py::test_add_step_with_product PASSED [ 70%] -tests/test_routines.py::test_add_step_routine_not_found PASSED [ 71%] -tests/test_routines.py::test_update_step PASSED [ 71%] -tests/test_routines.py::test_update_step_not_found PASSED [ 71%] -tests/test_routines.py::test_delete_step PASSED [ 72%] -tests/test_routines.py::test_delete_step_not_found PASSED [ 72%] -tests/test_routines.py::test_list_grooming_schedule_empty PASSED [ 73%] -tests/test_routines.py::test_create_grooming_schedule PASSED [ 73%] -tests/test_routines.py::test_list_grooming_schedule_returns_entry PASSED [ 74%] -tests/test_routines.py::test_update_grooming_schedule PASSED [ 74%] -tests/test_routines.py::test_delete_grooming_schedule PASSED [ 75%] -tests/test_routines.py::test_delete_grooming_schedule_not_found PASSED [ 75%] -tests/test_routines.py::test_suggest_routine PASSED [ 76%] -tests/test_routines.py::test_suggest_batch PASSED [ 76%] -tests/test_routines.py::test_suggest_batch_invalid_date_range PASSED [ 76%] -tests/test_routines.py::test_suggest_batch_too_long PASSED [ 77%] -tests/test_routines_auth.py::test_suggest_uses_current_user_profile_and_visible_products_only PASSED [ 77%] -tests/test_routines_helpers.py::test_contains_minoxidil_text PASSED [ 78%] -tests/test_routines_helpers.py::test_is_minoxidil_product PASSED [ 78%] -tests/test_routines_helpers.py::test_ev PASSED [ 79%] -tests/test_routines_helpers.py::test_build_skin_context PASSED [ 79%] -tests/test_routines_helpers.py::test_build_skin_context_falls_back_to_recent_snapshot_within_14_days PASSED [ 80%] -tests/test_routines_helpers.py::test_build_skin_context_ignores_snapshot_older_than_14_days PASSED [ 80%] -tests/test_routines_helpers.py::test_get_recent_skin_snapshot_prefers_window_match PASSED [ 80%] -tests/test_routines_helpers.py::test_get_latest_skin_snapshot_within_days_uses_latest_within_14_days PASSED [ 81%] -tests/test_routines_helpers.py::test_build_grooming_context PASSED [ 81%] -tests/test_routines_helpers.py::test_build_upcoming_grooming_context PASSED [ 82%] -tests/test_routines_helpers.py::test_build_recent_history PASSED [ 82%] -tests/test_routines_helpers.py::test_build_recent_history_uses_reference_window PASSED [ 83%] -tests/test_routines_helpers.py::test_build_recent_history_excludes_future_routines PASSED [ 83%] -tests/test_routines_helpers.py::test_build_products_context_summary_list PASSED [ 84%] -tests/test_routines_helpers.py::test_build_objectives_context PASSED [ 84%] -tests/test_routines_helpers.py::test_build_day_context PASSED [ 85%] -tests/test_routines_helpers.py::test_get_available_products_respects_filters PASSED [ 85%] -tests/test_routines_helpers.py::test_build_product_details_tool_handler_returns_only_available_ids PASSED [ 85%] -tests/test_routines_helpers.py::test_extract_requested_product_ids_dedupes_and_limits PASSED [ 86%] -tests/test_routines_helpers.py::test_extract_active_names_uses_compact_distinct_names PASSED [ 86%] -tests/test_routines_helpers.py::test_get_available_products_excludes_minoxidil_when_flag_false PASSED [ 87%] -tests/test_routines_helpers.py::test_filter_products_by_interval PASSED [ 87%] -tests/test_routines_helpers.py::test_filter_products_by_interval_never_used_passes PASSED [ 88%] -tests/test_routines_helpers.py::test_product_details_tool_handler_returns_product_payloads PASSED [ 88%] -tests/test_skincare.py::test_create_snapshot_minimal PASSED [ 89%] -tests/test_skincare.py::test_create_snapshot_full PASSED [ 89%] -tests/test_skincare.py::test_create_snapshot_invalid_state PASSED [ 90%] -tests/test_skincare.py::test_list_snapshots_empty PASSED [ 90%] -tests/test_skincare.py::test_list_filter_date_range PASSED [ 90%] -tests/test_skincare.py::test_list_filter_overall_state PASSED [ 91%] -tests/test_skincare.py::test_get_snapshot PASSED [ 91%] -tests/test_skincare.py::test_get_snapshot_not_found PASSED [ 92%] -tests/test_skincare.py::test_update_snapshot_state PASSED [ 92%] -tests/test_skincare.py::test_update_snapshot_concerns PASSED [ 93%] -tests/test_skincare.py::test_update_snapshot_not_found PASSED [ 93%] -tests/test_skincare.py::test_delete_snapshot PASSED [ 94%] -tests/test_skincare.py::test_delete_snapshot_not_found PASSED [ 94%] -tests/test_skincare.py::test_analyze_photos_includes_user_profile_context PASSED [ 95%] -tests/test_tenancy_domains.py::test_profile_health_routines_skincare_ai_logs_are_user_scoped_by_default PASSED [ 95%] -tests/test_tenancy_domains.py::test_health_admin_override_requires_explicit_user_id PASSED [ 95%] -tests/validators/test_routine_validator.py::test_detects_retinoid_acid_conflict PASSED [ 96%] -tests/validators/test_routine_validator.py::test_rejects_unknown_product_ids PASSED [ 96%] -tests/validators/test_routine_validator.py::test_enforces_min_interval_hours PASSED [ 97%] -tests/validators/test_routine_validator.py::test_blocks_dose_field PASSED [ 97%] -tests/validators/test_routine_validator.py::test_missing_spf_in_am_leaving_home PASSED [ 98%] -tests/validators/test_routine_validator.py::test_compromised_barrier_restrictions PASSED [ 98%] -tests/validators/test_routine_validator.py::test_step_must_have_product_or_action PASSED [ 99%] -tests/validators/test_routine_validator.py::test_step_cannot_have_both_product_and_action PASSED [ 99%] -tests/validators/test_routine_validator.py::test_accepts_valid_routine PASSED [100%] - -================================ tests coverage ================================ -______________ coverage: platform darwin, python 3.12.12-final-0 _______________ - -Name Stmts Miss Cover Missing ----------------------------------------------------------------------------------- -innercontext/api/__init__.py 0 0 100% -innercontext/api/admin.py 93 1 99% 142 -innercontext/api/ai_logs.py 63 12 81% 19, 21, 25-26, 29-30, 55-57, 77, 79, 109 -innercontext/api/auth.py 68 4 94% 66, 69, 74, 109 -innercontext/api/auth_deps.py 25 1 96% 43 -innercontext/api/authz.py 100 12 88% 25-26, 39, 49, 83, 91, 108, 125, 128, 158, 167, 174 -innercontext/api/health.py 236 8 97% 145, 158-163, 412, 414, 418 -innercontext/api/inventory.py 30 0 100% -innercontext/api/llm_context.py 106 42 60% 19-21, 67, 77, 114, 116, 118, 120-131, 142, 146-149, 180-217 -innercontext/api/product_llm_tools.py 107 33 69% 12-17, 25, 53, 63, 67-80, 133-134, 155-161, 193 -innercontext/api/products.py 638 76 88% 82, 84, 88, 109-126, 284, 287-289, 317-318, 331, 340-341, 343, 345, 347-348, 381, 413, 415, 419, 425, 429, 520, 524, 528, 532, 536, 542, 544, 587, 604, 606, 657, 661, 692, 867, 870-871, 880-881, 887, 890-891, 918, 920, 922, 924, 933-934, 983, 1007, 1045, 1082, 1176, 1249, 1251, 1253, 1256, 1360-1375, 1392, 1439-1442, 1449-1450, 1453 -innercontext/api/profile.py 39 0 100% -innercontext/api/routines.py 632 89 86% 67-84, 101-103, 112-117, 129-133, 323-324, 465, 477, 552, 592, 594, 599, 640-641, 664-693, 715, 719-721, 833, 986-1002, 1019, 1023-1024, 1030, 1033, 1039, 1064-1065, 1069, 1115-1119, 1130, 1201-1203, 1236, 1240-1241, 1247-1264, 1284-1285, 1331-1333, 1340-1341, 1344, 1454, 1485 -innercontext/api/skincare.py 150 18 88% 147-149, 162-166, 179, 191, 196, 231-232, 242-245, 251, 254-255 -innercontext/api/utils.py 22 2 91% 51, 59 -innercontext/auth.py 236 42 82% 67-70, 75, 134, 137, 142, 144, 147-149, 156, 201-210, 216, 224-225, 232, 242, 247, 254-255, 261, 274, 298, 300, 314-315, 344-346, 378-384 -innercontext/llm.py 134 117 13% 62-66, 74-102, 118-214, 231-326 -innercontext/llm_safety.py 18 6 67% 18, 59, 80-83 -innercontext/models/__init__.py 13 0 100% -innercontext/models/ai_log.py 33 0 100% -innercontext/models/api_metadata.py 15 0 100% -innercontext/models/base.py 3 0 100% -innercontext/models/domain.py 4 0 100% -innercontext/models/enums.py 152 0 100% -innercontext/models/health.py 64 0 100% -innercontext/models/household.py 14 0 100% -innercontext/models/household_membership.py 20 0 100% -innercontext/models/pricing.py 19 0 100% -innercontext/models/product.py 226 34 85% 203-205, 209-230, 253, 255, 257, 259, 261, 263, 265, 267, 271, 286, 318, 320, 336, 338, 340, 342, 349-354 -innercontext/models/profile.py 17 0 100% -innercontext/models/routine.py 42 0 100% -innercontext/models/skincare.py 37 0 100% -innercontext/models/user.py 19 0 100% -innercontext/services/__init__.py 0 0 100% -innercontext/services/fx.py 57 42 26% 16, 20-22, 26-48, 54-67, 71-77 -innercontext/services/pricing_jobs.py 89 29 67% 35, 39, 53-67, 74-80, 94, 123-130, 136 -innercontext/validators/__init__.py 7 0 100% -innercontext/validators/base.py 22 2 91% 35, 52 -innercontext/validators/batch_validator.py 128 84 34% 61-62, 67-68, 71-72, 82-83, 87-91, 100-119, 123-142, 146, 167-203, 214-240, 250-273 -innercontext/validators/photo_validator.py 65 33 49% 82-87, 94-101, 108-115, 122-129, 145, 151-152, 165, 171-178 -innercontext/validators/product_parse_validator.py 110 46 58% 112, 115, 117, 142, 165, 172, 186, 192-198, 205-239, 244-249, 252-257, 266-267, 274-275, 282, 287, 291, 298, 308, 315, 319, 339 -innercontext/validators/routine_validator.py 146 17 88% 72-73, 111-117, 126, 187, 195, 208, 216, 234, 241, 266-267, 292 -innercontext/validators/shopping_validator.py 78 20 74% 52-53, 58-59, 70, 91, 114, 123, 137-138, 142, 151-152, 156-159, 161, 193, 196-199, 203 ----------------------------------------------------------------------------------- -TOTAL 4077 770 81% -Coverage HTML written to dir htmlcov -============================= 221 passed in 2.98s ============================== diff --git a/.sisyphus/evidence/task-T11-ci-enabled.txt b/.sisyphus/evidence/task-T11-ci-enabled.txt deleted file mode 100644 index d1cca2d..0000000 --- a/.sisyphus/evidence/task-T11-ci-enabled.txt +++ /dev/null @@ -1,6 +0,0 @@ - backend-test: - name: Backend Tests - runs-on: lxc - steps: - - name: Checkout code - uses: actions/checkout@v4 diff --git a/.sisyphus/evidence/task-T2-migration-missing-bootstrap.txt b/.sisyphus/evidence/task-T2-migration-missing-bootstrap.txt deleted file mode 100644 index b2621dd..0000000 --- a/.sisyphus/evidence/task-T2-migration-missing-bootstrap.txt +++ /dev/null @@ -1,37 +0,0 @@ -INFO [alembic.runtime.migration] Context impl SQLiteImpl. -INFO [alembic.runtime.migration] Will assume non-transactional DDL. -INFO [alembic.runtime.migration] Running upgrade 9f3a2c1b4d5e -> 4b7d2e9f1c3a, add auth tables and ownership -Traceback (most recent call last): - File "/Users/piotr/dev/innercontext/backend/.venv/bin/alembic", line 10, in - sys.exit(main()) - ^^^^^^ - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/alembic/config.py", line 1047, in main - CommandLine(prog=prog).main(argv=argv) - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/alembic/config.py", line 1037, in main - self.run_cmd(cfg, options) - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/alembic/config.py", line 971, in run_cmd - fn( - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/alembic/command.py", line 483, in upgrade - script.run_env() - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/alembic/script/base.py", line 545, in run_env - util.load_python_file(self.dir, "env.py") - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/alembic/util/pyfiles.py", line 116, in load_python_file - module = load_module_py(module_id, path) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/alembic/util/pyfiles.py", line 136, in load_module_py - spec.loader.exec_module(module) # type: ignore - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "", line 999, in exec_module - File "", line 488, in _call_with_frames_removed - File "/Users/piotr/dev/innercontext/backend/alembic/env.py", line 51, in - run_migrations_online() - File "/Users/piotr/dev/innercontext/backend/alembic/env.py", line 45, in run_migrations_online - context.run_migrations() - File "", line 8, in run_migrations - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/alembic/runtime/environment.py", line 969, in run_migrations - self.get_context().run_migrations(**kw) - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/alembic/runtime/migration.py", line 626, in run_migrations - step.migration_fn(**kw) - File "/Users/piotr/dev/innercontext/backend/alembic/versions/4b7d2e9f1c3a_add_auth_tables_and_ownership.py", line 243, in upgrade - raise RuntimeError( -RuntimeError: Legacy data requires bootstrap admin identity; missing required env vars: BOOTSTRAP_ADMIN_OIDC_ISSUER, BOOTSTRAP_ADMIN_OIDC_SUB diff --git a/.sisyphus/evidence/task-T2-migration-upgrade.txt b/.sisyphus/evidence/task-T2-migration-upgrade.txt deleted file mode 100644 index 5116be5..0000000 --- a/.sisyphus/evidence/task-T2-migration-upgrade.txt +++ /dev/null @@ -1,3 +0,0 @@ -INFO [alembic.runtime.migration] Context impl SQLiteImpl. -INFO [alembic.runtime.migration] Will assume non-transactional DDL. -INFO [alembic.runtime.migration] Running upgrade 9f3a2c1b4d5e -> 4b7d2e9f1c3a, add auth tables and ownership diff --git a/.sisyphus/evidence/task-T2-missing-bootstrap.sqlite b/.sisyphus/evidence/task-T2-missing-bootstrap.sqlite deleted file mode 100644 index 384cf22..0000000 Binary files a/.sisyphus/evidence/task-T2-missing-bootstrap.sqlite and /dev/null differ diff --git a/.sisyphus/evidence/task-T2-upgrade.sqlite b/.sisyphus/evidence/task-T2-upgrade.sqlite deleted file mode 100644 index 930e43b..0000000 Binary files a/.sisyphus/evidence/task-T2-upgrade.sqlite and /dev/null differ diff --git a/.sisyphus/evidence/task-T4-authz-denied.txt b/.sisyphus/evidence/task-T4-authz-denied.txt deleted file mode 100644 index dca95c4..0000000 --- a/.sisyphus/evidence/task-T4-authz-denied.txt +++ /dev/null @@ -1,63 +0,0 @@ -============================= test session starts ============================== -platform darwin -- Python 3.12.12, pytest-9.0.2, pluggy-1.6.0 -- /Users/piotr/dev/innercontext/backend/.venv/bin/python3 -cachedir: .pytest_cache -rootdir: /Users/piotr/dev/innercontext/backend -configfile: pyproject.toml -plugins: anyio-4.12.1, cov-7.0.0 -collecting ... collected 8 items / 5 deselected / 3 selected - -tests/test_authz.py::test_owner_denied_for_non_owned_lookup_returns_404 PASSED [ 33%] -tests/test_authz.py::test_household_shared_inventory_denied_for_cross_household_member PASSED [ 66%] -tests/test_authz.py::test_product_visibility_denied_for_cross_household_member PASSED [100%] - -================================ tests coverage ================================ -______________ coverage: platform darwin, python 3.12.12-final-0 _______________ - -Name Stmts Miss Cover Missing ----------------------------------------------------------------------------------- -innercontext/api/__init__.py 0 0 100% -innercontext/api/ai_logs.py 50 25 50% 15-27, 53-59, 79-83 -innercontext/api/auth.py 68 18 74% 65-79, 100, 106-109, 122-129, 153-158, 166 -innercontext/api/auth_deps.py 25 13 48% 23, 36-48, 52-57 -innercontext/api/authz.py 96 41 57% 25-26, 39, 49, 63, 66, 75-83, 89-93, 105-108, 118, 121, 125, 128, 133, 141-146, 154, 157, 160, 163, 170, 172 -innercontext/api/health.py 216 97 55% 75-79, 147-152, 157-161, 166, 175-181, 186-196, 206-210, 223-232, 241-247, 252-254, 276-344, 349-353, 358, 367-373, 378-380 -innercontext/api/inventory.py 25 11 56% 16, 25-31, 36-38 -innercontext/api/llm_context.py 92 81 12% 11, 17-20, 24-46, 68-119, 146-182, 214-218 -innercontext/api/product_llm_tools.py 107 94 12% 12-17, 23-38, 48-82, 111-136, 143-162, 172-205 -innercontext/api/products.py 616 403 35% 72-92, 247-255, 265-267, 278-353, 362-397, 481, 485-503, 507-516, 526-532, 536-537, 547-597, 614-658, 663-677, 681, 803-841, 853-931, 936-943, 950-961, 966-969, 979-981, 992-1001, 1010-1015, 1025-1156, 1162-1164, 1168-1181, 1187, 1217-1377 -innercontext/api/profile.py 35 14 60% 29-32, 43-56 -innercontext/api/routines.py 550 373 32% 58-78, 254-257, 261-278, 282-287, 296-308, 321-322, 336-345, 359-376, 384-418, 426-456, 464-475, 484-493, 497-510, 516, 527-537, 556-573, 577-583, 587-590, 681-706, 711-715, 728-960, 968-1138, 1144, 1149-1155, 1164-1170, 1175-1177, 1191-1196, 1205-1211, 1216-1218, 1230-1234, 1243-1249, 1254-1256 -innercontext/api/skincare.py 131 56 57% 100, 146-219, 229-236, 241-245, 250, 259-265, 270-272 -innercontext/api/utils.py 22 8 64% 22-25, 34, 43, 51, 59 -innercontext/auth.py 236 146 38% 64-77, 127-129, 133-137, 141-149, 153-156, 161-168, 187-192, 195-210, 213-217, 220-228, 231-248, 251-264, 267, 271-274, 279, 283-284, 288-317, 325-363, 373-384 -innercontext/llm.py 134 119 11% 22, 44, 62-66, 74-102, 118-214, 231-326 -innercontext/llm_safety.py 18 14 22% 17-45, 58-61, 80-83 -innercontext/models/__init__.py 13 0 100% -innercontext/models/ai_log.py 33 0 100% -innercontext/models/api_metadata.py 15 0 100% -innercontext/models/base.py 3 0 100% -innercontext/models/domain.py 4 0 100% -innercontext/models/enums.py 152 0 100% -innercontext/models/health.py 64 0 100% -innercontext/models/household.py 14 0 100% -innercontext/models/household_membership.py 20 0 100% -innercontext/models/pricing.py 19 0 100% -innercontext/models/product.py 226 106 53% 76-78, 203-205, 209-230, 238-356 -innercontext/models/profile.py 17 0 100% -innercontext/models/routine.py 42 0 100% -innercontext/models/skincare.py 37 0 100% -innercontext/models/user.py 19 0 100% -innercontext/services/__init__.py 0 0 100% -innercontext/services/fx.py 57 42 26% 16, 20-22, 26-48, 54-67, 71-77 -innercontext/services/pricing_jobs.py 62 53 15% 12-23, 27-48, 52-66, 70-85, 89-93 -innercontext/validators/__init__.py 7 0 100% -innercontext/validators/base.py 22 5 77% 23, 27, 31, 35, 52 -innercontext/validators/batch_validator.py 128 105 18% 37, 58-154, 167-203, 214-240, 249-273 -innercontext/validators/photo_validator.py 65 54 17% 58-134, 144-152, 164-178 -innercontext/validators/product_parse_validator.py 110 93 15% 108-154, 164-172, 185-198, 205-239, 243-267, 273-319, 325-339 -innercontext/validators/routine_validator.py 146 114 22% 69-167, 173-175, 182-197, 201-218, 229-246, 259-275, 288-309 -innercontext/validators/shopping_validator.py 78 58 26% 49-96, 102-114, 122-123, 136-142, 150-161, 169-203 ----------------------------------------------------------------------------------- -TOTAL 3774 2143 43% -Coverage HTML written to dir htmlcov -======================= 3 passed, 5 deselected in 0.35s ======================== diff --git a/.sisyphus/evidence/task-T4-authz-happy.txt b/.sisyphus/evidence/task-T4-authz-happy.txt deleted file mode 100644 index e67a77f..0000000 --- a/.sisyphus/evidence/task-T4-authz-happy.txt +++ /dev/null @@ -1,68 +0,0 @@ -============================= test session starts ============================== -platform darwin -- Python 3.12.12, pytest-9.0.2, pluggy-1.6.0 -- /Users/piotr/dev/innercontext/backend/.venv/bin/python3 -cachedir: .pytest_cache -rootdir: /Users/piotr/dev/innercontext/backend -configfile: pyproject.toml -plugins: anyio-4.12.1, cov-7.0.0 -collecting ... collected 8 items - -tests/test_authz.py::test_owner_helpers_return_only_owned_records PASSED [ 12%] -tests/test_authz.py::test_admin_helpers_allow_admin_override_for_lookup_and_list PASSED [ 25%] -tests/test_authz.py::test_owner_denied_for_non_owned_lookup_returns_404 PASSED [ 37%] -tests/test_authz.py::test_household_shared_inventory_access_allows_same_household_member PASSED [ 50%] -tests/test_authz.py::test_household_shared_inventory_denied_for_cross_household_member PASSED [ 62%] -tests/test_authz.py::test_household_inventory_update_rules_owner_admin_and_member PASSED [ 75%] -tests/test_authz.py::test_product_visibility_for_owner_admin_and_household_shared PASSED [ 87%] -tests/test_authz.py::test_product_visibility_denied_for_cross_household_member PASSED [100%] - -================================ tests coverage ================================ -______________ coverage: platform darwin, python 3.12.12-final-0 _______________ - -Name Stmts Miss Cover Missing ----------------------------------------------------------------------------------- -innercontext/api/__init__.py 0 0 100% -innercontext/api/ai_logs.py 50 25 50% 15-27, 53-59, 79-83 -innercontext/api/auth.py 68 18 74% 65-79, 100, 106-109, 122-129, 153-158, 166 -innercontext/api/auth_deps.py 25 13 48% 23, 36-48, 52-57 -innercontext/api/authz.py 96 19 80% 25-26, 39, 49, 63, 78, 81-83, 91, 108, 118, 121, 125, 128, 143, 154, 163, 170 -innercontext/api/health.py 216 97 55% 75-79, 147-152, 157-161, 166, 175-181, 186-196, 206-210, 223-232, 241-247, 252-254, 276-344, 349-353, 358, 367-373, 378-380 -innercontext/api/inventory.py 25 11 56% 16, 25-31, 36-38 -innercontext/api/llm_context.py 92 81 12% 11, 17-20, 24-46, 68-119, 146-182, 214-218 -innercontext/api/product_llm_tools.py 107 94 12% 12-17, 23-38, 48-82, 111-136, 143-162, 172-205 -innercontext/api/products.py 616 403 35% 72-92, 247-255, 265-267, 278-353, 362-397, 481, 485-503, 507-516, 526-532, 536-537, 547-597, 614-658, 663-677, 681, 803-841, 853-931, 936-943, 950-961, 966-969, 979-981, 992-1001, 1010-1015, 1025-1156, 1162-1164, 1168-1181, 1187, 1217-1377 -innercontext/api/profile.py 35 14 60% 29-32, 43-56 -innercontext/api/routines.py 550 373 32% 58-78, 254-257, 261-278, 282-287, 296-308, 321-322, 336-345, 359-376, 384-418, 426-456, 464-475, 484-493, 497-510, 516, 527-537, 556-573, 577-583, 587-590, 681-706, 711-715, 728-960, 968-1138, 1144, 1149-1155, 1164-1170, 1175-1177, 1191-1196, 1205-1211, 1216-1218, 1230-1234, 1243-1249, 1254-1256 -innercontext/api/skincare.py 131 56 57% 100, 146-219, 229-236, 241-245, 250, 259-265, 270-272 -innercontext/api/utils.py 22 8 64% 22-25, 34, 43, 51, 59 -innercontext/auth.py 236 146 38% 64-77, 127-129, 133-137, 141-149, 153-156, 161-168, 187-192, 195-210, 213-217, 220-228, 231-248, 251-264, 267, 271-274, 279, 283-284, 288-317, 325-363, 373-384 -innercontext/llm.py 134 119 11% 22, 44, 62-66, 74-102, 118-214, 231-326 -innercontext/llm_safety.py 18 14 22% 17-45, 58-61, 80-83 -innercontext/models/__init__.py 13 0 100% -innercontext/models/ai_log.py 33 0 100% -innercontext/models/api_metadata.py 15 0 100% -innercontext/models/base.py 3 0 100% -innercontext/models/domain.py 4 0 100% -innercontext/models/enums.py 152 0 100% -innercontext/models/health.py 64 0 100% -innercontext/models/household.py 14 0 100% -innercontext/models/household_membership.py 20 0 100% -innercontext/models/pricing.py 19 0 100% -innercontext/models/product.py 226 106 53% 76-78, 203-205, 209-230, 238-356 -innercontext/models/profile.py 17 0 100% -innercontext/models/routine.py 42 0 100% -innercontext/models/skincare.py 37 0 100% -innercontext/models/user.py 19 0 100% -innercontext/services/__init__.py 0 0 100% -innercontext/services/fx.py 57 42 26% 16, 20-22, 26-48, 54-67, 71-77 -innercontext/services/pricing_jobs.py 62 53 15% 12-23, 27-48, 52-66, 70-85, 89-93 -innercontext/validators/__init__.py 7 0 100% -innercontext/validators/base.py 22 5 77% 23, 27, 31, 35, 52 -innercontext/validators/batch_validator.py 128 105 18% 37, 58-154, 167-203, 214-240, 249-273 -innercontext/validators/photo_validator.py 65 54 17% 58-134, 144-152, 164-178 -innercontext/validators/product_parse_validator.py 110 93 15% 108-154, 164-172, 185-198, 205-239, 243-267, 273-319, 325-339 -innercontext/validators/routine_validator.py 146 114 22% 69-167, 173-175, 182-197, 201-218, 229-246, 259-275, 288-309 -innercontext/validators/shopping_validator.py 78 58 26% 49-96, 102-114, 122-123, 136-142, 150-161, 169-203 ----------------------------------------------------------------------------------- -TOTAL 3774 2121 44% -Coverage HTML written to dir htmlcov -============================== 8 passed in 0.40s =============================== diff --git a/.sisyphus/evidence/task-T5-product-denied.txt b/.sisyphus/evidence/task-T5-product-denied.txt deleted file mode 100644 index ba13ac7..0000000 --- a/.sisyphus/evidence/task-T5-product-denied.txt +++ /dev/null @@ -1,62 +0,0 @@ -============================= test session starts ============================== -platform darwin -- Python 3.12.12, pytest-9.0.2, pluggy-1.6.0 -- /Users/piotr/dev/innercontext/backend/.venv/bin/python3 -cachedir: .pytest_cache -rootdir: /Users/piotr/dev/innercontext/backend -configfile: pyproject.toml -plugins: anyio-4.12.1, cov-7.0.0 -collecting ... collected 8 items / 6 deselected / 2 selected - -tests/test_products_auth.py::test_household_member_cannot_edit_shared_product PASSED [ 50%] -tests/test_products_auth.py::test_household_member_cannot_delete_shared_product PASSED [100%] - -================================ tests coverage ================================ -______________ coverage: platform darwin, python 3.12.12-final-0 _______________ - -Name Stmts Miss Cover Missing ----------------------------------------------------------------------------------- -innercontext/api/__init__.py 0 0 100% -innercontext/api/ai_logs.py 63 34 46% 18-30, 53-57, 69-81, 106-113 -innercontext/api/auth.py 68 18 74% 65-79, 100, 106-109, 122-129, 153-158, 166 -innercontext/api/auth_deps.py 25 13 48% 23, 36-48, 52-57 -innercontext/api/authz.py 100 68 32% 25-26, 35-40, 48-51, 60-66, 78, 80, 83, 89-93, 105-108, 116-133, 141-150, 156-177 -innercontext/api/health.py 238 115 52% 77-81, 142-146, 156-163, 179-185, 195-204, 214, 231-243, 253-270, 285-298, 313-330, 341-353, 363-371, 395-464, 474-483, 493, 510-522, 532-540 -innercontext/api/inventory.py 30 13 57% 26, 36-44, 53-60 -innercontext/api/llm_context.py 102 87 15% 17-21, 30-31, 39-42, 52-74, 96-147, 174-210, 242-246 -innercontext/api/product_llm_tools.py 107 94 12% 12-17, 23-38, 48-82, 111-136, 143-162, 172-205 -innercontext/api/products.py 638 389 39% 82, 84, 88, 106-126, 281-289, 299-301, 312-387, 396-431, 515, 519-537, 541-550, 560-566, 570-571, 581-631, 649-703, 712-727, 731, 853-891, 918, 920, 922, 924, 933-934, 983, 1005-1015, 1027-1029, 1043-1048, 1060-1072, 1081-1086, 1097-1232, 1238-1240, 1244-1257, 1263, 1296-1457 -innercontext/api/profile.py 39 15 62% 36-39, 55-69 -innercontext/api/routines.py 586 402 31% 63-83, 98-102, 108-116, 126-132, 300-303, 307-324, 328-333, 343-356, 371-372, 388-398, 414-433, 442-478, 487-519, 528-555, 564-573, 577-590, 596, 609-629, 652-681, 685-691, 695-698, 789-814, 819-823, 836-1068, 1076-1246, 1252, 1257-1263, 1272-1278, 1283-1285, 1299-1304, 1313-1319, 1324-1326, 1338-1342, 1351-1357, 1362-1364 -innercontext/api/skincare.py 150 70 53% 103, 145-149, 158-166, 178-255, 267-277, 287-296, 306, 322-333, 343-350 -innercontext/api/utils.py 22 4 82% 24, 34, 51, 59 -innercontext/auth.py 236 146 38% 64-77, 127-129, 133-137, 141-149, 153-156, 161-168, 187-192, 195-210, 213-217, 220-228, 231-248, 251-264, 267, 271-274, 279, 283-284, 288-317, 325-363, 373-384 -innercontext/llm.py 134 119 11% 22, 44, 62-66, 74-102, 118-214, 231-326 -innercontext/llm_safety.py 18 14 22% 17-45, 58-61, 80-83 -innercontext/models/__init__.py 13 0 100% -innercontext/models/ai_log.py 33 0 100% -innercontext/models/api_metadata.py 15 0 100% -innercontext/models/base.py 3 0 100% -innercontext/models/domain.py 4 0 100% -innercontext/models/enums.py 152 0 100% -innercontext/models/health.py 64 0 100% -innercontext/models/household.py 14 0 100% -innercontext/models/household_membership.py 20 0 100% -innercontext/models/pricing.py 19 0 100% -innercontext/models/product.py 226 106 53% 76-78, 203-205, 209-230, 238-356 -innercontext/models/profile.py 17 0 100% -innercontext/models/routine.py 42 0 100% -innercontext/models/skincare.py 37 0 100% -innercontext/models/user.py 19 0 100% -innercontext/services/__init__.py 0 0 100% -innercontext/services/fx.py 57 42 26% 16, 20-22, 26-48, 54-67, 71-77 -innercontext/services/pricing_jobs.py 62 52 16% 18-23, 27-48, 52-66, 70-85, 89-93 -innercontext/validators/__init__.py 7 0 100% -innercontext/validators/base.py 22 5 77% 23, 27, 31, 35, 52 -innercontext/validators/batch_validator.py 128 105 18% 37, 58-154, 167-203, 214-240, 249-273 -innercontext/validators/photo_validator.py 65 54 17% 58-134, 144-152, 164-178 -innercontext/validators/product_parse_validator.py 110 93 15% 108-154, 164-172, 185-198, 205-239, 243-267, 273-319, 325-339 -innercontext/validators/routine_validator.py 146 114 22% 69-167, 173-175, 182-197, 201-218, 229-246, 259-275, 288-309 -innercontext/validators/shopping_validator.py 78 58 26% 49-96, 102-114, 122-123, 136-142, 150-161, 169-203 ----------------------------------------------------------------------------------- -TOTAL 3909 2230 43% -Coverage HTML written to dir htmlcov -======================= 2 passed, 6 deselected in 0.49s ======================== diff --git a/.sisyphus/evidence/task-T5-product-sharing.txt b/.sisyphus/evidence/task-T5-product-sharing.txt deleted file mode 100644 index 9924641..0000000 --- a/.sisyphus/evidence/task-T5-product-sharing.txt +++ /dev/null @@ -1,63 +0,0 @@ -============================= test session starts ============================== -platform darwin -- Python 3.12.12, pytest-9.0.2, pluggy-1.6.0 -- /Users/piotr/dev/innercontext/backend/.venv/bin/python3 -cachedir: .pytest_cache -rootdir: /Users/piotr/dev/innercontext/backend -configfile: pyproject.toml -plugins: anyio-4.12.1, cov-7.0.0 -collecting ... collected 8 items / 5 deselected / 3 selected - -tests/test_products_auth.py::test_shared_product_visible_in_summary_marks_is_owned_false PASSED [ 33%] -tests/test_products_auth.py::test_shared_product_visible_filters_private_inventory_rows PASSED [ 66%] -tests/test_products_auth.py::test_shared_inventory_update_allows_household_member PASSED [100%] - -================================ tests coverage ================================ -______________ coverage: platform darwin, python 3.12.12-final-0 _______________ - -Name Stmts Miss Cover Missing ----------------------------------------------------------------------------------- -innercontext/api/__init__.py 0 0 100% -innercontext/api/ai_logs.py 63 34 46% 18-30, 53-57, 69-81, 106-113 -innercontext/api/auth.py 68 18 74% 65-79, 100, 106-109, 122-129, 153-158, 166 -innercontext/api/auth_deps.py 25 13 48% 23, 36-48, 52-57 -innercontext/api/authz.py 100 46 54% 25-26, 39, 49, 60-66, 78, 80, 83, 89-93, 105-108, 116-133, 143, 145, 147, 149, 158, 161, 164, 167, 174, 177 -innercontext/api/health.py 238 115 52% 77-81, 142-146, 156-163, 179-185, 195-204, 214, 231-243, 253-270, 285-298, 313-330, 341-353, 363-371, 395-464, 474-483, 493, 510-522, 532-540 -innercontext/api/inventory.py 30 5 83% 26, 37, 53-60 -innercontext/api/llm_context.py 102 87 15% 17-21, 30-31, 39-42, 52-74, 96-147, 174-210, 242-246 -innercontext/api/product_llm_tools.py 107 94 12% 12-17, 23-38, 48-82, 111-136, 143-162, 172-205 -innercontext/api/products.py 638 389 39% 82, 84, 88, 106-126, 281-289, 299-301, 312-387, 396-431, 515, 519-537, 541-550, 560-566, 570-571, 581-631, 649-703, 712-727, 731, 853-891, 918, 920, 922, 924, 933-934, 983, 1005-1015, 1027-1029, 1043-1048, 1060-1072, 1081-1086, 1097-1232, 1238-1240, 1244-1257, 1263, 1296-1457 -innercontext/api/profile.py 39 15 62% 36-39, 55-69 -innercontext/api/routines.py 586 402 31% 63-83, 98-102, 108-116, 126-132, 300-303, 307-324, 328-333, 343-356, 371-372, 388-398, 414-433, 442-478, 487-519, 528-555, 564-573, 577-590, 596, 609-629, 652-681, 685-691, 695-698, 789-814, 819-823, 836-1068, 1076-1246, 1252, 1257-1263, 1272-1278, 1283-1285, 1299-1304, 1313-1319, 1324-1326, 1338-1342, 1351-1357, 1362-1364 -innercontext/api/skincare.py 150 70 53% 103, 145-149, 158-166, 178-255, 267-277, 287-296, 306, 322-333, 343-350 -innercontext/api/utils.py 22 4 82% 24, 34, 51, 59 -innercontext/auth.py 236 146 38% 64-77, 127-129, 133-137, 141-149, 153-156, 161-168, 187-192, 195-210, 213-217, 220-228, 231-248, 251-264, 267, 271-274, 279, 283-284, 288-317, 325-363, 373-384 -innercontext/llm.py 134 119 11% 22, 44, 62-66, 74-102, 118-214, 231-326 -innercontext/llm_safety.py 18 14 22% 17-45, 58-61, 80-83 -innercontext/models/__init__.py 13 0 100% -innercontext/models/ai_log.py 33 0 100% -innercontext/models/api_metadata.py 15 0 100% -innercontext/models/base.py 3 0 100% -innercontext/models/domain.py 4 0 100% -innercontext/models/enums.py 152 0 100% -innercontext/models/health.py 64 0 100% -innercontext/models/household.py 14 0 100% -innercontext/models/household_membership.py 20 0 100% -innercontext/models/pricing.py 19 0 100% -innercontext/models/product.py 226 106 53% 76-78, 203-205, 209-230, 238-356 -innercontext/models/profile.py 17 0 100% -innercontext/models/routine.py 42 0 100% -innercontext/models/skincare.py 37 0 100% -innercontext/models/user.py 19 0 100% -innercontext/services/__init__.py 0 0 100% -innercontext/services/fx.py 57 42 26% 16, 20-22, 26-48, 54-67, 71-77 -innercontext/services/pricing_jobs.py 62 52 16% 18-23, 27-48, 52-66, 70-85, 89-93 -innercontext/validators/__init__.py 7 0 100% -innercontext/validators/base.py 22 5 77% 23, 27, 31, 35, 52 -innercontext/validators/batch_validator.py 128 105 18% 37, 58-154, 167-203, 214-240, 249-273 -innercontext/validators/photo_validator.py 65 54 17% 58-134, 144-152, 164-178 -innercontext/validators/product_parse_validator.py 110 93 15% 108-154, 164-172, 185-198, 205-239, 243-267, 273-319, 325-339 -innercontext/validators/routine_validator.py 146 114 22% 69-167, 173-175, 182-197, 201-218, 229-246, 259-275, 288-309 -innercontext/validators/shopping_validator.py 78 58 26% 49-96, 102-114, 122-123, 136-142, 150-161, 169-203 ----------------------------------------------------------------------------------- -TOTAL 3909 2200 44% -Coverage HTML written to dir htmlcov -======================= 3 passed, 5 deselected in 0.51s ======================== diff --git a/.sisyphus/evidence/task-T6-domain-tenancy.txt b/.sisyphus/evidence/task-T6-domain-tenancy.txt deleted file mode 100644 index 95fc06e..0000000 --- a/.sisyphus/evidence/task-T6-domain-tenancy.txt +++ /dev/null @@ -1,62 +0,0 @@ -============================= test session starts ============================== -platform darwin -- Python 3.12.12, pytest-9.0.2, pluggy-1.6.0 -- /Users/piotr/dev/innercontext/backend/.venv/bin/python3 -cachedir: .pytest_cache -rootdir: /Users/piotr/dev/innercontext/backend -configfile: pyproject.toml -plugins: anyio-4.12.1, cov-7.0.0 -collecting ... collected 2 items - -tests/test_tenancy_domains.py::test_profile_health_routines_skincare_ai_logs_are_user_scoped_by_default PASSED [ 50%] -tests/test_tenancy_domains.py::test_health_admin_override_requires_explicit_user_id PASSED [100%] - -================================ tests coverage ================================ -______________ coverage: platform darwin, python 3.12.12-final-0 _______________ - -Name Stmts Miss Cover Missing ----------------------------------------------------------------------------------- -innercontext/api/__init__.py 0 0 100% -innercontext/api/ai_logs.py 63 21 67% 18-30, 55-57, 77, 79, 109, 112-113 -innercontext/api/auth.py 68 18 74% 65-79, 100, 106-109, 122-129, 153-158, 166 -innercontext/api/auth_deps.py 25 13 48% 23, 36-48, 52-57 -innercontext/api/authz.py 100 70 30% 25-26, 31, 35-40, 48-51, 63, 66, 75-83, 89-93, 105-108, 116-133, 141-150, 156-177 -innercontext/api/health.py 236 68 71% 78, 145, 158-163, 182, 184, 231-243, 253-270, 285-298, 313-330, 341-353, 363-371, 400-401, 408, 410, 412, 414, 416, 418, 422-442, 492, 509-521, 531-539 -innercontext/api/inventory.py 30 13 57% 26, 36-44, 53-60 -innercontext/api/llm_context.py 106 58 45% 19-21, 31, 46, 59, 67, 77, 107-131, 138-149, 180-217 -innercontext/api/product_llm_tools.py 107 51 52% 12-17, 25, 31, 33, 37, 50-80, 133-134, 144, 155-161, 193 -innercontext/api/products.py 638 410 36% 81-89, 97, 106-126, 281-289, 299-301, 312-387, 396-431, 515, 519-537, 541-550, 560-566, 570-571, 581-631, 649-703, 714, 731, 853-891, 904-972, 981-992, 1002-1015, 1024-1029, 1043-1048, 1060-1072, 1081-1086, 1097-1232, 1238-1240, 1244-1257, 1263, 1296-1457 -innercontext/api/profile.py 39 3 92% 39, 62-63 -innercontext/api/routines.py 632 285 55% 67-84, 101-103, 112-117, 129-133, 309, 311, 313, 315, 319-324, 330, 334, 355, 398-399, 415-434, 451-479, 498-520, 552, 555, 559, 561, 563, 577-581, 587-600, 606, 620, 640-641, 664-693, 698, 709-710, 715, 719-721, 817, 819, 821, 827-833, 837-841, 927-929, 986-1002, 1019, 1023-1024, 1030, 1033, 1039, 1064-1065, 1069, 1115-1119, 1130, 1143-1348, 1358-1359, 1379-1386, 1397-1409, 1419-1427, 1443-1464, 1475-1491, 1501-1509, 1524-1529, 1540-1552, 1562-1570 -innercontext/api/skincare.py 150 53 65% 103, 147-149, 162-166, 178-255, 272, 274, 276, 322-333, 343-350 -innercontext/api/utils.py 22 7 68% 22-25, 43, 51, 59 -innercontext/auth.py 236 146 38% 64-77, 127-129, 133-137, 141-149, 153-156, 161-168, 187-192, 195-210, 213-217, 220-228, 231-248, 251-264, 267, 271-274, 279, 283-284, 288-317, 325-363, 373-384 -innercontext/llm.py 134 118 12% 22, 62-66, 74-102, 118-214, 231-326 -innercontext/llm_safety.py 18 14 22% 17-45, 58-61, 80-83 -innercontext/models/__init__.py 13 0 100% -innercontext/models/ai_log.py 33 0 100% -innercontext/models/api_metadata.py 15 0 100% -innercontext/models/base.py 3 0 100% -innercontext/models/domain.py 4 0 100% -innercontext/models/enums.py 152 0 100% -innercontext/models/health.py 64 0 100% -innercontext/models/household.py 14 0 100% -innercontext/models/household_membership.py 20 0 100% -innercontext/models/pricing.py 19 0 100% -innercontext/models/product.py 226 67 70% 78, 203-205, 209-230, 250, 253, 255, 257, 259, 261, 263, 265, 267, 269, 271, 273, 275-288, 291-298, 304, 306, 309-315, 318, 320, 331, 333, 336, 338, 340, 342, 349-354 -innercontext/models/profile.py 17 0 100% -innercontext/models/routine.py 42 0 100% -innercontext/models/skincare.py 37 0 100% -innercontext/models/user.py 19 0 100% -innercontext/services/__init__.py 0 0 100% -innercontext/services/fx.py 57 42 26% 16, 20-22, 26-48, 54-67, 71-77 -innercontext/services/pricing_jobs.py 89 71 20% 28-49, 53-67, 71-80, 89-107, 111-130, 134-138 -innercontext/validators/__init__.py 7 0 100% -innercontext/validators/base.py 22 3 86% 27, 35, 52 -innercontext/validators/batch_validator.py 128 105 18% 37, 58-154, 167-203, 214-240, 249-273 -innercontext/validators/photo_validator.py 65 54 17% 58-134, 144-152, 164-178 -innercontext/validators/product_parse_validator.py 110 93 15% 108-154, 164-172, 185-198, 205-239, 243-267, 273-319, 325-339 -innercontext/validators/routine_validator.py 146 90 38% 72-73, 92-95, 98-101, 107-147, 151, 158, 175, 182-197, 201-218, 229-246, 259-275, 288-309 -innercontext/validators/shopping_validator.py 78 58 26% 49-96, 102-114, 122-123, 136-142, 150-161, 169-203 ----------------------------------------------------------------------------------- -TOTAL 3984 1931 52% -Coverage HTML written to dir htmlcov -============================== 2 passed in 0.49s =============================== diff --git a/.sisyphus/evidence/task-T6-routine-scope.txt b/.sisyphus/evidence/task-T6-routine-scope.txt deleted file mode 100644 index 937b6f7..0000000 --- a/.sisyphus/evidence/task-T6-routine-scope.txt +++ /dev/null @@ -1,61 +0,0 @@ -============================= test session starts ============================== -platform darwin -- Python 3.12.12, pytest-9.0.2, pluggy-1.6.0 -- /Users/piotr/dev/innercontext/backend/.venv/bin/python3 -cachedir: .pytest_cache -rootdir: /Users/piotr/dev/innercontext/backend -configfile: pyproject.toml -plugins: anyio-4.12.1, cov-7.0.0 -collecting ... collected 1 item - -tests/test_routines_auth.py::test_suggest_uses_current_user_profile_and_visible_products_only PASSED [100%] - -================================ tests coverage ================================ -______________ coverage: platform darwin, python 3.12.12-final-0 _______________ - -Name Stmts Miss Cover Missing ----------------------------------------------------------------------------------- -innercontext/api/__init__.py 0 0 100% -innercontext/api/ai_logs.py 63 34 46% 18-30, 53-57, 69-81, 106-113 -innercontext/api/auth.py 68 18 74% 65-79, 100, 106-109, 122-129, 153-158, 166 -innercontext/api/auth_deps.py 25 13 48% 23, 36-48, 52-57 -innercontext/api/authz.py 100 79 21% 16, 20, 24-27, 31, 35-40, 48-51, 60-66, 75-83, 89-93, 105-108, 116-133, 141-150, 156-177 -innercontext/api/health.py 236 113 52% 77-81, 142-146, 156-163, 179-185, 195-204, 214, 231-243, 253-270, 285-298, 313-330, 341-353, 363-371, 395-463, 473-482, 492, 509-521, 531-539 -innercontext/api/inventory.py 30 13 57% 26, 36-44, 53-60 -innercontext/api/llm_context.py 106 58 45% 19-21, 31, 46, 59, 67, 77, 107-131, 138-149, 180-217 -innercontext/api/product_llm_tools.py 107 51 52% 12-17, 25, 31, 33, 37, 50-80, 133-134, 144, 155-161, 193 -innercontext/api/products.py 638 410 36% 81-89, 97, 106-126, 281-289, 299-301, 312-387, 396-431, 515, 519-537, 541-550, 560-566, 570-571, 581-631, 649-703, 714, 731, 853-891, 904-972, 981-992, 1002-1015, 1024-1029, 1043-1048, 1060-1072, 1081-1086, 1097-1232, 1238-1240, 1244-1257, 1263, 1296-1457 -innercontext/api/profile.py 39 3 92% 39, 62-63 -innercontext/api/routines.py 632 285 55% 67-84, 101-103, 112-117, 129-133, 309, 311, 313, 315, 319-324, 330, 334, 355, 398-399, 415-434, 451-479, 498-520, 552, 555, 559, 561, 563, 577-581, 587-600, 606, 620, 640-641, 664-693, 698, 709-710, 715, 719-721, 817, 819, 821, 827-833, 837-841, 927-929, 986-1002, 1019, 1023-1024, 1030, 1033, 1039, 1064-1065, 1069, 1115-1119, 1130, 1143-1348, 1358-1359, 1379-1386, 1397-1409, 1419-1427, 1443-1464, 1475-1491, 1501-1509, 1524-1529, 1540-1552, 1562-1570 -innercontext/api/skincare.py 150 53 65% 103, 147-149, 162-166, 178-255, 272, 274, 276, 322-333, 343-350 -innercontext/api/utils.py 22 7 68% 22-25, 43, 51, 59 -innercontext/auth.py 236 146 38% 64-77, 127-129, 133-137, 141-149, 153-156, 161-168, 187-192, 195-210, 213-217, 220-228, 231-248, 251-264, 267, 271-274, 279, 283-284, 288-317, 325-363, 373-384 -innercontext/llm.py 134 118 12% 22, 62-66, 74-102, 118-214, 231-326 -innercontext/llm_safety.py 18 14 22% 17-45, 58-61, 80-83 -innercontext/models/__init__.py 13 0 100% -innercontext/models/ai_log.py 33 0 100% -innercontext/models/api_metadata.py 15 0 100% -innercontext/models/base.py 3 0 100% -innercontext/models/domain.py 4 0 100% -innercontext/models/enums.py 152 0 100% -innercontext/models/health.py 64 0 100% -innercontext/models/household.py 14 0 100% -innercontext/models/household_membership.py 20 0 100% -innercontext/models/pricing.py 19 0 100% -innercontext/models/product.py 226 67 70% 78, 203-205, 209-230, 250, 253, 255, 257, 259, 261, 263, 265, 267, 269, 271, 273, 275-288, 291-298, 304, 306, 309-315, 318, 320, 331, 333, 336, 338, 340, 342, 349-354 -innercontext/models/profile.py 17 0 100% -innercontext/models/routine.py 42 0 100% -innercontext/models/skincare.py 37 0 100% -innercontext/models/user.py 19 0 100% -innercontext/services/__init__.py 0 0 100% -innercontext/services/fx.py 57 42 26% 16, 20-22, 26-48, 54-67, 71-77 -innercontext/services/pricing_jobs.py 89 71 20% 28-49, 53-67, 71-80, 89-107, 111-130, 134-138 -innercontext/validators/__init__.py 7 0 100% -innercontext/validators/base.py 22 3 86% 27, 35, 52 -innercontext/validators/batch_validator.py 128 105 18% 37, 58-154, 167-203, 214-240, 249-273 -innercontext/validators/photo_validator.py 65 54 17% 58-134, 144-152, 164-178 -innercontext/validators/product_parse_validator.py 110 93 15% 108-154, 164-172, 185-198, 205-239, 243-267, 273-319, 325-339 -innercontext/validators/routine_validator.py 146 90 38% 72-73, 92-95, 98-101, 107-147, 151, 158, 175, 182-197, 201-218, 229-246, 259-275, 288-309 -innercontext/validators/shopping_validator.py 78 58 26% 49-96, 102-114, 122-123, 136-142, 150-161, 169-203 ----------------------------------------------------------------------------------- -TOTAL 3984 1998 50% -Coverage HTML written to dir htmlcov -============================== 1 passed in 0.47s =============================== diff --git a/.sisyphus/evidence/task-T8-backend-qa.log b/.sisyphus/evidence/task-T8-backend-qa.log deleted file mode 100644 index 79fc782..0000000 --- a/.sisyphus/evidence/task-T8-backend-qa.log +++ /dev/null @@ -1,158 +0,0 @@ -INFO: Started server process [65594] -INFO: Waiting for application startup. -INFO: Application startup complete. -INFO: Uvicorn running on http://127.0.0.1:8001 (Press CTRL+C to quit) -INFO: 127.0.0.1:56744 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56751 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56758 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56764 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56770 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56776 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56782 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56788 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56794 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56800 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56806 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56813 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56820 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56826 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56832 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56838 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56844 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56850 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56856 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56862 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56868 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56874 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56880 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56887 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56893 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56899 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56905 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56911 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56917 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56923 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56940 - "POST /auth/session/sync HTTP/1.1" 500 Internal Server Error -ERROR: Exception in ASGI application -Traceback (most recent call last): - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/sqlalchemy/engine/base.py", line 1967, in _exec_single_context - self.dialect.do_execute( - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/sqlalchemy/engine/default.py", line 952, in do_execute - cursor.execute(statement, parameters) - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/psycopg/cursor.py", line 117, in execute - raise ex.with_traceback(None) -psycopg.errors.UndefinedColumn: column user_profiles.user_id does not exist -LINE 1: SELECT user_profiles.id, user_profiles.user_id, user_profile... - ^ - -The above exception was the direct cause of the following exception: - -Traceback (most recent call last): - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/uvicorn/protocols/http/httptools_impl.py", line 416, in run_asgi - result = await app( # type: ignore[func-returns-value] - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 60, in __call__ - return await self.app(scope, receive, send) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/fastapi/applications.py", line 1158, in __call__ - await super().__call__(scope, receive, send) - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/starlette/applications.py", line 107, in __call__ - await self.middleware_stack(scope, receive, send) - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/starlette/middleware/errors.py", line 186, in __call__ - raise exc - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/starlette/middleware/errors.py", line 164, in __call__ - await self.app(scope, receive, _send) - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/starlette/middleware/cors.py", line 87, in __call__ - await self.app(scope, receive, send) - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 63, in __call__ - await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send) - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app - raise exc - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app - await app(scope, receive, sender) - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/fastapi/middleware/asyncexitstack.py", line 18, in __call__ - await self.app(scope, receive, send) - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/starlette/routing.py", line 716, in __call__ - await self.middleware_stack(scope, receive, send) - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/starlette/routing.py", line 736, in app - await route.handle(scope, receive, send) - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/starlette/routing.py", line 290, in handle - await self.app(scope, receive, send) - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/fastapi/routing.py", line 119, in app - await wrap_app_handling_exceptions(app, request)(scope, receive, send) - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app - raise exc - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app - await app(scope, receive, sender) - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/fastapi/routing.py", line 105, in app - response = await f(request) - ^^^^^^^^^^^^^^^^ - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/fastapi/routing.py", line 431, in app - raw_response = await run_endpoint_function( - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/fastapi/routing.py", line 315, in run_endpoint_function - return await run_in_threadpool(dependant.call, **values) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/starlette/concurrency.py", line 32, in run_in_threadpool - return await anyio.to_thread.run_sync(func) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/anyio/to_thread.py", line 63, in run_sync - return await get_async_backend().run_sync_in_worker_thread( - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/anyio/_backends/_asyncio.py", line 2502, in run_sync_in_worker_thread - return await future - ^^^^^^^^^^^^ - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/anyio/_backends/_asyncio.py", line 986, in run - result = context.run(func, *args) - ^^^^^^^^^^^^^^^^^^^^^^^^ - File "/Users/piotr/dev/innercontext/backend/innercontext/api/auth.py", line 158, in sync_session - return _response(session, synced_user) - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/Users/piotr/dev/innercontext/backend/innercontext/api/auth.py", line 143, in _response - profile=_profile_public(_get_profile(session, current_user.user_id)), - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/Users/piotr/dev/innercontext/backend/innercontext/api/auth.py", line 100, in _get_profile - return session.exec( - ^^^^^^^^^^^^^ - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/sqlmodel/orm/session.py", line 75, in exec - results = super().execute( - ^^^^^^^^^^^^^^^^ - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/sqlalchemy/orm/session.py", line 2351, in execute - return self._execute_internal( - ^^^^^^^^^^^^^^^^^^^^^^^ - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/sqlalchemy/orm/session.py", line 2249, in _execute_internal - result: Result[Any] = compile_state_cls.orm_execute_statement( - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/sqlalchemy/orm/context.py", line 306, in orm_execute_statement - result = conn.execute( - ^^^^^^^^^^^^^ - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/sqlalchemy/engine/base.py", line 1419, in execute - return meth( - ^^^^^ - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/sqlalchemy/sql/elements.py", line 527, in _execute_on_connection - return connection._execute_clauseelement( - ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/sqlalchemy/engine/base.py", line 1641, in _execute_clauseelement - ret = self._execute_context( - ^^^^^^^^^^^^^^^^^^^^^^ - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/sqlalchemy/engine/base.py", line 1846, in _execute_context - return self._exec_single_context( - ^^^^^^^^^^^^^^^^^^^^^^^^^^ - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/sqlalchemy/engine/base.py", line 1986, in _exec_single_context - self._handle_dbapi_exception( - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/sqlalchemy/engine/base.py", line 2363, in _handle_dbapi_exception - raise sqlalchemy_exception.with_traceback(exc_info[2]) from e - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/sqlalchemy/engine/base.py", line 1967, in _exec_single_context - self.dialect.do_execute( - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/sqlalchemy/engine/default.py", line 952, in do_execute - cursor.execute(statement, parameters) - File "/Users/piotr/dev/innercontext/backend/.venv/lib/python3.12/site-packages/psycopg/cursor.py", line 117, in execute - raise ex.with_traceback(None) -sqlalchemy.exc.ProgrammingError: (psycopg.errors.UndefinedColumn) column user_profiles.user_id does not exist -LINE 1: SELECT user_profiles.id, user_profiles.user_id, user_profile... - ^ -[SQL: SELECT user_profiles.id, user_profiles.user_id, user_profiles.birth_date, user_profiles.sex_at_birth, user_profiles.created_at, user_profiles.updated_at -FROM user_profiles -WHERE user_profiles.user_id = %(user_id_1)s::UUID] -[parameters: {'user_id_1': UUID('c6968c10-98af-4a32-a794-708aca0cc362')}] -(Background on this error at: https://sqlalche.me/e/20/f405) diff --git a/.sisyphus/evidence/task-T8-backend-sqlite.log b/.sisyphus/evidence/task-T8-backend-sqlite.log deleted file mode 100644 index 56a3ee9..0000000 --- a/.sisyphus/evidence/task-T8-backend-sqlite.log +++ /dev/null @@ -1,40 +0,0 @@ -INFO: Started server process [67156] -INFO: Waiting for application startup. -INFO: Application startup complete. -INFO: Uvicorn running on http://127.0.0.1:8002 (Press CTRL+C to quit) -INFO: 127.0.0.1:56962 - "GET /health-check HTTP/1.1" 200 OK -INFO: 127.0.0.1:56974 - "POST /auth/session/sync HTTP/1.1" 200 OK -INFO: 127.0.0.1:57014 - "GET /routines?from_date=2026-02-26 HTTP/1.1" 200 OK -INFO: 127.0.0.1:57015 - "GET /skincare?from_date=2026-01-11 HTTP/1.1" 200 OK -INFO: 127.0.0.1:57016 - "GET /health/lab-results?latest_only=true&limit=8 HTTP/1.1" 200 OK -INFO: 127.0.0.1:57024 - "GET /routines?from_date=2026-02-26 HTTP/1.1" 200 OK -INFO: 127.0.0.1:57025 - "GET /skincare?from_date=2026-01-11 HTTP/1.1" 200 OK -INFO: 127.0.0.1:57026 - "GET /health/lab-results?latest_only=true&limit=8 HTTP/1.1" 200 OK -INFO: 127.0.0.1:57035 - "GET /routines?from_date=2026-02-26 HTTP/1.1" 200 OK -INFO: 127.0.0.1:57036 - "GET /skincare?from_date=2026-01-11 HTTP/1.1" 200 OK -INFO: 127.0.0.1:57037 - "GET /health/lab-results?latest_only=true&limit=8 HTTP/1.1" 200 OK -INFO: 127.0.0.1:57035 - "GET /products/summary HTTP/1.1" 200 OK -INFO: 127.0.0.1:57035 - "GET /profile HTTP/1.1" 200 OK -INFO: 127.0.0.1:57035 - "GET /routines?from_date=2026-02-10 HTTP/1.1" 200 OK -INFO: 127.0.0.1:57075 - "POST /auth/session/sync HTTP/1.1" 200 OK -INFO: 127.0.0.1:57075 - "GET /products/summary HTTP/1.1" 200 OK -INFO: 127.0.0.1:57089 - "POST /auth/session/sync HTTP/1.1" 200 OK -INFO: 127.0.0.1:57089 - "GET /routines?from_date=2026-02-26 HTTP/1.1" 200 OK -INFO: 127.0.0.1:57090 - "GET /skincare?from_date=2026-01-11 HTTP/1.1" 200 OK -INFO: 127.0.0.1:57091 - "GET /health/lab-results?latest_only=true&limit=8 HTTP/1.1" 200 OK -INFO: 127.0.0.1:57089 - "GET /products/summary HTTP/1.1" 200 OK -INFO: 127.0.0.1:57109 - "POST /auth/session/sync HTTP/1.1" 200 OK -INFO: 127.0.0.1:57109 - "GET /routines?from_date=2026-02-26 HTTP/1.1" 200 OK -INFO: 127.0.0.1:57110 - "GET /skincare?from_date=2026-01-11 HTTP/1.1" 200 OK -INFO: 127.0.0.1:57111 - "GET /health/lab-results?latest_only=true&limit=8 HTTP/1.1" 200 OK -INFO: 127.0.0.1:57166 - "GET /routines?from_date=2026-02-26 HTTP/1.1" 200 OK -INFO: 127.0.0.1:57167 - "GET /skincare?from_date=2026-01-11 HTTP/1.1" 200 OK -INFO: 127.0.0.1:57168 - "GET /health/lab-results?latest_only=true&limit=8 HTTP/1.1" 200 OK -INFO: 127.0.0.1:57182 - "POST /auth/session/sync HTTP/1.1" 200 OK -INFO: 127.0.0.1:57182 - "GET /routines?from_date=2026-02-26 HTTP/1.1" 200 OK -INFO: 127.0.0.1:57183 - "GET /skincare?from_date=2026-01-11 HTTP/1.1" 200 OK -INFO: 127.0.0.1:57184 - "GET /health/lab-results?latest_only=true&limit=8 HTTP/1.1" 200 OK -INFO: 127.0.0.1:57182 - "GET /routines?from_date=2026-02-26 HTTP/1.1" 200 OK -INFO: 127.0.0.1:57183 - "GET /skincare?from_date=2026-01-11 HTTP/1.1" 200 OK -INFO: 127.0.0.1:57184 - "GET /health/lab-results?latest_only=true&limit=8 HTTP/1.1" 200 OK -INFO: 127.0.0.1:57401 - "GET /profile HTTP/1.1" 200 OK diff --git a/.sisyphus/evidence/task-T8-backend.log b/.sisyphus/evidence/task-T8-backend.log deleted file mode 100644 index ad37dc7..0000000 --- a/.sisyphus/evidence/task-T8-backend.log +++ /dev/null @@ -1,5 +0,0 @@ -INFO: Started server process [63874] -INFO: Waiting for application startup. -INFO: Application startup complete. -INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) -INFO: 127.0.0.1:56616 - "GET /health-check HTTP/1.1" 200 OK diff --git a/.sisyphus/evidence/task-T8-frontend-qa.log b/.sisyphus/evidence/task-T8-frontend-qa.log deleted file mode 100644 index 765ebaa..0000000 --- a/.sisyphus/evidence/task-T8-frontend-qa.log +++ /dev/null @@ -1,44 +0,0 @@ - -> frontend@0.0.1 dev /Users/piotr/dev/innercontext/frontend -> vite dev --host 127.0.0.1 --port 4174 - -✔ [paraglide-js] Compilation complete (locale-modules) - - VITE v7.3.1 ready in 1355 ms - - ➜ Local: http://127.0.0.1:4174/ - -[404] GET /favicon.ico -16:19:52 [vite] (ssr) page reload .svelte-kit/generated/server/internal.js -16:19:52 [vite] (ssr) page reload .svelte-kit/generated/root.svelte -16:19:52 [vite] (ssr) page reload .svelte-kit/generated/root.js -16:19:53 [vite] (ssr) page reload src/lib/paraglide/server.js -16:19:53 [vite] (ssr) page reload src/lib/paraglide/registry.js -16:19:53 [vite] (ssr) page reload src/lib/paraglide/messages.js -16:19:53 [vite] (ssr) page reload src/lib/paraglide/runtime.js -16:19:53 [vite] (ssr) page reload src/lib/paraglide/messages/_index.js -16:19:53 [vite] (ssr) page reload src/lib/paraglide/messages/en.js -16:19:53 [vite] (ssr) page reload src/lib/paraglide/messages/pl.js -16:19:53 [vite] (ssr) page reload src/lib/paraglide/messages/en.js -16:19:53 [vite] (ssr) page reload src/lib/paraglide/messages/pl.js -16:19:53 [vite] (ssr) page reload src/lib/paraglide/server.js -16:19:53 [vite] (ssr) page reload src/lib/paraglide/messages.js -16:19:53 [vite] (ssr) page reload src/lib/paraglide/runtime.js -16:19:53 [vite] (ssr) page reload src/lib/paraglide/messages/pl.js -16:19:53 [vite] (ssr) page reload src/lib/paraglide/registry.js -16:19:53 [vite] (ssr) page reload src/lib/paraglide/messages/_index.js -16:19:53 [vite] (ssr) page reload src/lib/paraglide/messages/en.js -16:21:17 [vite] (ssr) page reload src/lib/api.ts -16:23:49 [vite] (ssr) page reload src/routes/+layout.svelte -16:24:41 [vite] (ssr) page reload .svelte-kit/generated/server/internal.js -16:24:41 [vite] (ssr) page reload .svelte-kit/generated/root.svelte -16:24:41 [vite] (ssr) page reload .svelte-kit/generated/root.js -16:24:42 [vite] (ssr) page reload src/lib/paraglide/runtime.js -16:24:42 [vite] (ssr) page reload src/lib/paraglide/server.js -16:24:42 [vite] (ssr) page reload src/lib/paraglide/registry.js -16:24:42 [vite] (ssr) page reload src/lib/paraglide/messages.js -16:24:42 [vite] (ssr) page reload src/lib/paraglide/messages/en.js -16:24:42 [vite] (ssr) page reload src/lib/paraglide/messages/_index.js -16:24:42 [vite] (ssr) page reload src/lib/paraglide/messages/pl.js -16:24:42 [vite] (ssr) page reload src/lib/paraglide/messages/_index.js -16:24:42 [vite] (ssr) page reload src/lib/paraglide/messages/_index.js diff --git a/.sisyphus/evidence/task-T8-frontend-sqlite.log b/.sisyphus/evidence/task-T8-frontend-sqlite.log deleted file mode 100644 index d81d4ed..0000000 --- a/.sisyphus/evidence/task-T8-frontend-sqlite.log +++ /dev/null @@ -1,56 +0,0 @@ - -> frontend@0.0.1 dev /Users/piotr/dev/innercontext/frontend -> vite dev --host 127.0.0.1 --port 4175 - -✔ [paraglide-js] Compilation complete (locale-modules) - - VITE v7.3.1 ready in 1402 ms - - ➜ Local: http://127.0.0.1:4175/ - -[404] GET /api/routines - -[404] GET /api/skincare - -[404] GET /api/health/lab-results - -[500] GET / -Error: Not Found - at request (src/lib/api.ts:68:11) - at process.processTicksAndRejections (node:internal/process/task_queues:104:5) - at async Promise.all (index 0) - at async load (src/routes/+page.server.ts:9:44) - -[404] GET /favicon.ico -16:21:17 [vite] (ssr) page reload src/lib/api.ts -Avoid calling `fetch` eagerly during server-side rendering — put your `fetch` calls inside `onMount` or a `load` function instead -Avoid calling `fetch` eagerly during server-side rendering — put your `fetch` calls inside `onMount` or a `load` function instead -16:23:49 [vite] (client) hmr update /src/routes/+layout.svelte, /src/app.css -16:23:49 [vite] (ssr) page reload src/routes/+layout.svelte -16:24:41 [vite] (client) page reload .svelte-kit/generated/client/nodes/0.js -16:24:41 [vite] (client) page reload .svelte-kit/generated/client/nodes/1.js -16:24:41 [vite] (client) page reload .svelte-kit/generated/client/nodes/2.js -16:24:41 [vite] (client) page reload .svelte-kit/generated/client/nodes/7.js -16:24:41 [vite] (client) page reload .svelte-kit/generated/client/nodes/11.js -16:24:41 [vite] (client) page reload .svelte-kit/generated/client/nodes/12.js -16:24:41 [vite] (client) page reload .svelte-kit/generated/client/app.js -16:24:41 [vite] (client) page reload .svelte-kit/generated/client/matchers.js -16:24:41 [vite] (ssr) page reload .svelte-kit/generated/server/internal.js -16:24:41 [vite] (client) page reload .svelte-kit/generated/root.js -16:24:41 [vite] (ssr) page reload .svelte-kit/generated/root.js -16:24:41 [vite] (ssr) page reload .svelte-kit/generated/root.svelte -16:24:42 [vite] (client) hmr update /src/lib/components/LanguageSwitcher.svelte, /src/routes/+layout.svelte, /src/routes/+page.svelte, /src/routes/products/+page.svelte, /src/routes/profile/+page.svelte, /src/routes/routines/+page.svelte -16:24:42 [vite] (ssr) page reload src/lib/paraglide/runtime.js -16:24:42 [vite] (ssr) page reload src/lib/paraglide/server.js -16:24:42 [vite] (client) hmr update /src/routes/+layout.svelte, /src/routes/+page.svelte, /src/routes/products/+page.svelte, /src/routes/profile/+page.svelte, /src/routes/routines/+page.svelte -16:24:42 [vite] (ssr) page reload src/lib/paraglide/registry.js -16:24:42 [vite] (client) hmr update /src/routes/+layout.svelte, /src/routes/+page.svelte, /src/routes/products/+page.svelte, /src/routes/profile/+page.svelte, /src/routes/routines/+page.svelte -16:24:42 [vite] (ssr) page reload src/lib/paraglide/messages.js -16:24:42 [vite] (client) hmr update /src/routes/+layout.svelte, /src/routes/+page.svelte, /src/routes/products/+page.svelte, /src/routes/profile/+page.svelte, /src/routes/routines/+page.svelte -16:24:42 [vite] (ssr) page reload src/lib/paraglide/messages/_index.js -16:24:42 [vite] (client) page reload src/lib/paraglide/messages/en.js -16:24:42 [vite] (ssr) page reload src/lib/paraglide/messages/en.js -16:24:42 [vite] (client) page reload src/lib/paraglide/messages/pl.js -16:24:42 [vite] (ssr) page reload src/lib/paraglide/messages/pl.js -16:24:42 [vite] (client) hmr update /src/routes/+layout.svelte, /src/routes/+page.svelte, /src/routes/products/+page.svelte, /src/routes/profile/+page.svelte, /src/routes/routines/+page.svelte -16:24:42 [vite] (ssr) page reload src/lib/paraglide/messages/_index.js diff --git a/.sisyphus/evidence/task-T8-frontend.log b/.sisyphus/evidence/task-T8-frontend.log deleted file mode 100644 index 923a3c1..0000000 --- a/.sisyphus/evidence/task-T8-frontend.log +++ /dev/null @@ -1,228 +0,0 @@ - -> frontend@0.0.1 dev /Users/piotr/dev/innercontext/frontend -> vite dev --host 127.0.0.1 --port 4173 - -✔ [paraglide-js] Compilation complete (locale-modules) -16:14:07 [vite] (client) Re-optimizing dependencies because lockfile has changed - - VITE v7.3.1 ready in 1541 ms - - ➜ Local: http://127.0.0.1:4173/ - -[500] GET /auth/login -Error: Missing required auth environment variable: OIDC_ISSUER - at requiredEnv (src/lib/server/auth.ts:111:11) - at getAuthConfig (src/lib/server/auth.ts:94:18) - at getSecretKey (src/lib/server/auth.ts:136:22) - at encryptValue (src/lib/server/auth.ts:160:15) - at setLoginFlowCookie (src/lib/server/auth.ts:514:5) - at createLoginRedirect (src/lib/server/auth.ts:533:3) - at GET (src/routes/auth/login/+server.ts:6:26) - -[500] GET /auth/login -Error: Missing required auth environment variable: OIDC_ISSUER - at requiredEnv (src/lib/server/auth.ts:111:11) - at getAuthConfig (src/lib/server/auth.ts:94:18) - at getSecretKey (src/lib/server/auth.ts:136:22) - at encryptValue (src/lib/server/auth.ts:160:15) - at setLoginFlowCookie (src/lib/server/auth.ts:514:5) - at createLoginRedirect (src/lib/server/auth.ts:533:3) - at GET (src/routes/auth/login/+server.ts:6:26) - -[500] GET /auth/login -Error: Missing required auth environment variable: OIDC_ISSUER - at requiredEnv (src/lib/server/auth.ts:111:11) - at getAuthConfig (src/lib/server/auth.ts:94:18) - at getSecretKey (src/lib/server/auth.ts:136:22) - at encryptValue (src/lib/server/auth.ts:160:15) - at setLoginFlowCookie (src/lib/server/auth.ts:514:5) - at createLoginRedirect (src/lib/server/auth.ts:533:3) - at GET (src/routes/auth/login/+server.ts:6:26) - -[500] GET /auth/login -Error: Missing required auth environment variable: OIDC_ISSUER - at requiredEnv (src/lib/server/auth.ts:111:11) - at getAuthConfig (src/lib/server/auth.ts:94:18) - at getSecretKey (src/lib/server/auth.ts:136:22) - at encryptValue (src/lib/server/auth.ts:160:15) - at setLoginFlowCookie (src/lib/server/auth.ts:514:5) - at createLoginRedirect (src/lib/server/auth.ts:533:3) - at GET (src/routes/auth/login/+server.ts:6:26) - -[500] GET /auth/login -Error: Missing required auth environment variable: OIDC_ISSUER - at requiredEnv (src/lib/server/auth.ts:111:11) - at getAuthConfig (src/lib/server/auth.ts:94:18) - at getSecretKey (src/lib/server/auth.ts:136:22) - at encryptValue (src/lib/server/auth.ts:160:15) - at setLoginFlowCookie (src/lib/server/auth.ts:514:5) - at createLoginRedirect (src/lib/server/auth.ts:533:3) - at GET (src/routes/auth/login/+server.ts:6:26) - -[500] GET /auth/login -Error: Missing required auth environment variable: OIDC_ISSUER - at requiredEnv (src/lib/server/auth.ts:111:11) - at getAuthConfig (src/lib/server/auth.ts:94:18) - at getSecretKey (src/lib/server/auth.ts:136:22) - at encryptValue (src/lib/server/auth.ts:160:15) - at setLoginFlowCookie (src/lib/server/auth.ts:514:5) - at createLoginRedirect (src/lib/server/auth.ts:533:3) - at GET (src/routes/auth/login/+server.ts:6:26) - -[500] GET /auth/login -Error: Missing required auth environment variable: OIDC_ISSUER - at requiredEnv (src/lib/server/auth.ts:111:11) - at getAuthConfig (src/lib/server/auth.ts:94:18) - at getSecretKey (src/lib/server/auth.ts:136:22) - at encryptValue (src/lib/server/auth.ts:160:15) - at setLoginFlowCookie (src/lib/server/auth.ts:514:5) - at createLoginRedirect (src/lib/server/auth.ts:533:3) - at GET (src/routes/auth/login/+server.ts:6:26) - -[500] GET /auth/login -Error: Missing required auth environment variable: OIDC_ISSUER - at requiredEnv (src/lib/server/auth.ts:111:11) - at getAuthConfig (src/lib/server/auth.ts:94:18) - at getSecretKey (src/lib/server/auth.ts:136:22) - at encryptValue (src/lib/server/auth.ts:160:15) - at setLoginFlowCookie (src/lib/server/auth.ts:514:5) - at createLoginRedirect (src/lib/server/auth.ts:533:3) - at GET (src/routes/auth/login/+server.ts:6:26) - -[500] GET /auth/login -Error: Missing required auth environment variable: OIDC_ISSUER - at requiredEnv (src/lib/server/auth.ts:111:11) - at getAuthConfig (src/lib/server/auth.ts:94:18) - at getSecretKey (src/lib/server/auth.ts:136:22) - at encryptValue (src/lib/server/auth.ts:160:15) - at setLoginFlowCookie (src/lib/server/auth.ts:514:5) - at createLoginRedirect (src/lib/server/auth.ts:533:3) - at GET (src/routes/auth/login/+server.ts:6:26) - -[500] GET /auth/login -Error: Missing required auth environment variable: OIDC_ISSUER - at requiredEnv (src/lib/server/auth.ts:111:11) - at getAuthConfig (src/lib/server/auth.ts:94:18) - at getSecretKey (src/lib/server/auth.ts:136:22) - at encryptValue (src/lib/server/auth.ts:160:15) - at setLoginFlowCookie (src/lib/server/auth.ts:514:5) - at createLoginRedirect (src/lib/server/auth.ts:533:3) - at GET (src/routes/auth/login/+server.ts:6:26) - -[500] GET /auth/login -Error: Missing required auth environment variable: OIDC_ISSUER - at requiredEnv (src/lib/server/auth.ts:111:11) - at getAuthConfig (src/lib/server/auth.ts:94:18) - at getSecretKey (src/lib/server/auth.ts:136:22) - at encryptValue (src/lib/server/auth.ts:160:15) - at setLoginFlowCookie (src/lib/server/auth.ts:514:5) - at createLoginRedirect (src/lib/server/auth.ts:533:3) - at GET (src/routes/auth/login/+server.ts:6:26) - -[500] GET /auth/login -Error: Missing required auth environment variable: OIDC_ISSUER - at requiredEnv (src/lib/server/auth.ts:111:11) - at getAuthConfig (src/lib/server/auth.ts:94:18) - at getSecretKey (src/lib/server/auth.ts:136:22) - at encryptValue (src/lib/server/auth.ts:160:15) - at setLoginFlowCookie (src/lib/server/auth.ts:514:5) - at createLoginRedirect (src/lib/server/auth.ts:533:3) - at GET (src/routes/auth/login/+server.ts:6:26) - -[500] GET /auth/login -Error: Missing required auth environment variable: OIDC_ISSUER - at requiredEnv (src/lib/server/auth.ts:111:11) - at getAuthConfig (src/lib/server/auth.ts:94:18) - at getSecretKey (src/lib/server/auth.ts:136:22) - at encryptValue (src/lib/server/auth.ts:160:15) - at setLoginFlowCookie (src/lib/server/auth.ts:514:5) - at createLoginRedirect (src/lib/server/auth.ts:533:3) - at GET (src/routes/auth/login/+server.ts:6:26) - -[500] GET /auth/login -Error: Missing required auth environment variable: OIDC_ISSUER - at requiredEnv (src/lib/server/auth.ts:111:11) - at getAuthConfig (src/lib/server/auth.ts:94:18) - at getSecretKey (src/lib/server/auth.ts:136:22) - at encryptValue (src/lib/server/auth.ts:160:15) - at setLoginFlowCookie (src/lib/server/auth.ts:514:5) - at createLoginRedirect (src/lib/server/auth.ts:533:3) - at GET (src/routes/auth/login/+server.ts:6:26) - -[500] GET /auth/login -Error: Missing required auth environment variable: OIDC_ISSUER - at requiredEnv (src/lib/server/auth.ts:111:11) - at getAuthConfig (src/lib/server/auth.ts:94:18) - at getSecretKey (src/lib/server/auth.ts:136:22) - at encryptValue (src/lib/server/auth.ts:160:15) - at setLoginFlowCookie (src/lib/server/auth.ts:514:5) - at createLoginRedirect (src/lib/server/auth.ts:533:3) - at GET (src/routes/auth/login/+server.ts:6:26) - -[500] GET /auth/login -Error: Missing required auth environment variable: OIDC_ISSUER - at requiredEnv (src/lib/server/auth.ts:111:11) - at getAuthConfig (src/lib/server/auth.ts:94:18) - at getSecretKey (src/lib/server/auth.ts:136:22) - at encryptValue (src/lib/server/auth.ts:160:15) - at setLoginFlowCookie (src/lib/server/auth.ts:514:5) - at createLoginRedirect (src/lib/server/auth.ts:533:3) - at GET (src/routes/auth/login/+server.ts:6:26) - -[500] GET /auth/login -Error: Missing required auth environment variable: OIDC_ISSUER - at requiredEnv (src/lib/server/auth.ts:111:11) - at getAuthConfig (src/lib/server/auth.ts:94:18) - at getSecretKey (src/lib/server/auth.ts:136:22) - at encryptValue (src/lib/server/auth.ts:160:15) - at setLoginFlowCookie (src/lib/server/auth.ts:514:5) - at createLoginRedirect (src/lib/server/auth.ts:533:3) - at GET (src/routes/auth/login/+server.ts:6:26) - -[500] GET /auth/login -Error: Missing required auth environment variable: OIDC_ISSUER - at requiredEnv (src/lib/server/auth.ts:111:11) - at getAuthConfig (src/lib/server/auth.ts:94:18) - at getSecretKey (src/lib/server/auth.ts:136:22) - at encryptValue (src/lib/server/auth.ts:160:15) - at setLoginFlowCookie (src/lib/server/auth.ts:514:5) - at createLoginRedirect (src/lib/server/auth.ts:533:3) - at GET (src/routes/auth/login/+server.ts:6:26) - -[500] GET /auth/login -Error: Missing required auth environment variable: OIDC_ISSUER - at requiredEnv (src/lib/server/auth.ts:111:11) - at getAuthConfig (src/lib/server/auth.ts:94:18) - at getSecretKey (src/lib/server/auth.ts:136:22) - at encryptValue (src/lib/server/auth.ts:160:15) - at setLoginFlowCookie (src/lib/server/auth.ts:514:5) - at createLoginRedirect (src/lib/server/auth.ts:533:3) - at GET (src/routes/auth/login/+server.ts:6:26) - -[500] GET /auth/login -Error: Missing required auth environment variable: OIDC_ISSUER - at requiredEnv (src/lib/server/auth.ts:111:11) - at getAuthConfig (src/lib/server/auth.ts:94:18) - at getSecretKey (src/lib/server/auth.ts:136:22) - at encryptValue (src/lib/server/auth.ts:160:15) - at setLoginFlowCookie (src/lib/server/auth.ts:514:5) - at createLoginRedirect (src/lib/server/auth.ts:533:3) - at GET (src/routes/auth/login/+server.ts:6:26) -16:18:10 [vite] (ssr) page reload .svelte-kit/generated/server/internal.js -16:18:10 [vite] (ssr) page reload .svelte-kit/generated/root.svelte -16:18:10 [vite] (ssr) page reload .svelte-kit/generated/root.js -16:18:10 [vite] (ssr) page reload src/lib/paraglide/runtime.js -16:18:10 [vite] (ssr) page reload src/lib/paraglide/server.js -16:19:52 [vite] (ssr) page reload .svelte-kit/generated/server/internal.js -16:19:52 [vite] (ssr) page reload .svelte-kit/generated/root.svelte -16:19:52 [vite] (ssr) page reload .svelte-kit/generated/root.js -16:19:53 [vite] (ssr) page reload src/lib/paraglide/server.js -16:19:53 [vite] (ssr) page reload src/lib/paraglide/runtime.js -16:19:53 [vite] (ssr) page reload src/lib/paraglide/server.js -16:19:53 [vite] (ssr) page reload src/lib/paraglide/runtime.js -16:21:17 [vite] (ssr) page reload src/lib/api.ts -16:24:41 [vite] (ssr) page reload .svelte-kit/generated/server/internal.js -16:24:41 [vite] (ssr) page reload .svelte-kit/generated/root.svelte -16:24:41 [vite] (ssr) page reload .svelte-kit/generated/root.js -16:24:42 [vite] (ssr) page reload src/lib/paraglide/runtime.js -16:24:42 [vite] (ssr) page reload src/lib/paraglide/server.js diff --git a/.sisyphus/evidence/task-T8-oidc-mock.log b/.sisyphus/evidence/task-T8-oidc-mock.log deleted file mode 100644 index 66458f1..0000000 --- a/.sisyphus/evidence/task-T8-oidc-mock.log +++ /dev/null @@ -1,85 +0,0 @@ -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=Pe0C5a4zQ4Vi55paBNm20bGetmj3Y3yX&code_challenge=bq7aLLFrO4nIa6kvBUM47B56asCKazcoQbOkATvooYM&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=vt-gcRpDGF4HG7V1QCVbA-NX2WLV6UAY&code_challenge=UH0-E3tc3A3U3TCc-FUZDzvzJ1asqarJbznagQ8Lj7o&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=m3l7BolBJCvjhuxRcsSFDZ6gZE6rqTb1&code_challenge=2g7E4QZDpYNRMtQUt5ryXhaYycdglHIeFM1UxZ-XDDs&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=eoKVLYkr20ziVr_dZ8V9JWBkGowz7NYI&code_challenge=vUxYDfnKXdVXnk5aHxY8LjHKCszU3SEiIebjzFPv4J0&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=pd7rjNvJbBvu6NQYuRX6D8bF5szwi8y9&code_challenge=SWGABggwp25CFq8PLbTT7DLSTsvezc77M9PGX_YYNPY&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=In2AvuYgIN7a4n1xiCp1gHtVBn0zpdg-&code_challenge=5ys7xQwmLRLdhb9ZPyI0h3e0bxfaPzonIBTYr4uj6Xg&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=P5shwVgZUoH9xchcI0o078fsgVweezIS&code_challenge=RmboHw0sdVGiU1POAwcptydVlhwdgUzQLLjUMT9S8OU&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=Kp2yUUykkn9ImErvLiLfeOAmJscmF6q6&code_challenge=epJaom98WkEBzjRkkNcLTAp89sB5NkzYrdrjZRxWLok&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=L86d5LRsIOMdz6WD744sjrwSe6iKkW0L&code_challenge=uvl59gn613ivLBLxHhQLPnEFAv48m7jTe9PBGknM1A0&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=vCs34wOnVVizzt0YuT44Mb35qRNttfUH&code_challenge=drdkN8hf7ScN7PSjYY4wmpVhmRv2BJYRyCia0P3oPwM&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=x7j_uSKquOLIvhjzs6SRbF81uQo1TGb_&code_challenge=nBHiDMdcFNj6JjXbJnB4-Ogsp98QtRplWP8IZnHXZ84&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=Kbw1x2FH82MPkXsIs_cK-8-5uHbTpXl1&code_challenge=XweHN-subsxNoIOcpvxoqUx9ILiGh6RG5eHJ6O2jmKI&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=-PIRB74zLnjfijXRSXKeVht6x0jjARr8&code_challenge=nno5wTo4kvMXh6Hbv9Q4UQ42Ah64rdX1RPh1Qas8FTQ&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=ttM5YXVAn1VugFsWVIw9DpnRtYnUfMWW&code_challenge=5cMexMQ8ioSPTSw1FAuAImlWZm5ogbZ5vemeAZtXjvs&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=Psc20ibq2QaTNOYGWuGsWopRtcIYMyDt&code_challenge=wsR_Ly1BzlxHsHCFOcoLNXi5hbRVej1iowuBCynBEXQ&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=Qynh8qyA--irimulhuBnjW7vheoMFv1d&code_challenge=UW4T2DllHIe-d48yu4B33kMEJ8CZuk4uG-g8xGELX2U&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=6i9k2lfIJpNcPY_bE-BjmnEyatt6eSEO&code_challenge=LilRpHaBD6Iij-x69kT0jg9hebm66PUSGBP3CkPQ4R0&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=hv06PULt9VCe6Z57ICw_kfH_rOA68Ye8&code_challenge=H6WP5l4QfrsPqEESZOUm61MWtHDrSrKe4xLl2j0Mqa4&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=bPb6NuPvTTpSLSBf0lcEyiovQMrf25ZU&code_challenge=c8QWtQNvy9pNw9nfeoAym2SM20WpBVePYjG80kV59tY&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=19pfKm4slbG0zxJTa_aNKZauy1pAlM24&code_challenge=fJsle6PvRo2Q53ibfrj2aHQYDfbLoXyWXH11-hJO1-8&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=Ls5-Ps19zFfHr6vdvFrgZXPU4HK2cdMH&code_challenge=hIaTDVr0WpZVBppRxfd4h0nc48Cq6llSetMzG5NU6R0&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=UCR6DlUCqwChXZiu5m3IezhnovkLU1hP&code_challenge=mf1NzdhG0OQnoB2_L_VQNlgohTdD3ZQ1gnrC-WM5xic&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=cikc5ZIqH3gtF0JNS4xeB8ye36C0FeYK&code_challenge=jRsCnQlVsNs4qftFnFdHUm63CxT8tkiprb5QYGcvVQs&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=sxqmLO0piajwnRePC4fq3rSE_EErnT3j&code_challenge=Pg_VsUw-qJXnF_JpdlJbUrxJTPRjzMY2q_rTad86iv4&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=H2123ucRCauviiQcfOgL2ACEaMJMCnCd&code_challenge=J_32kAoALP8nRUhLdpWSnHr9uePiK9ek8K5gYXKrqrM&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=QB4ItNBiVPTcsbvf8jw5fC0wCmLdrBaR&code_challenge=7IJ0bQzKs3-0atAvro4GR87lUJ3rYcUO5nWQ7fGBbJE&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=8f4SjKIZk6Zr23J5PhkIKaWZcZOoHbNy&code_challenge=S0TxmFf5PZ0vgx2krjLl2WIcSyxHI5CcxVdH3SDu7GE&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=gIsLo0NRxP-EP3W8oTjf1O2QS1Nijj-g&code_challenge=bnYTMZcj4cWQ32OPHKWN3638Nuoh3pzPH0wP0GmHLOc&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=RVs6ZqxJdOCnK_RDdRHJbeJFLcjH8jDZ&code_challenge=ghcwJxfIcbS1vZuFbltcVmX9IHbobNEoIvFRrjRo4hc&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=BaIv9zAbKGEmbOlhh7J8I0sye2qpRPcJ&code_challenge=yckMaHRptZd3J04kALAyxxCqIIulPIf9PrTmeA6eN7s&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4174%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=8ikyRz70r3g4_lJGDkx1wEl27neWvVFS&code_challenge=b5f8yNS9lEemYbrnFBnG3OANpefQSU3NXwwFkMS63A4&code_challenge_method=S256 HTTP/1.1" 303 - -"POST /token HTTP/1.1" 200 - -"GET /userinfo HTTP/1.1" 200 - -"GET /jwks HTTP/1.1" 200 - -"GET /.well-known/openid-configuration HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4175%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=C0AvwQhDg-kdVylKE43TG9lawjgYQW0G&code_challenge=88-OcQzCn4HlOoJfXOJ_CQo1PV9YRJCgracnfsuO5LU&code_challenge_method=S256 HTTP/1.1" 303 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4175%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=t_oQGVk1spVtWPABdSOUPjgdkk0GhGzR&code_challenge=bpLSYPmkEZpr_RbpzfcO6kYEKBBTuRFemoQDH9mBUbU&code_challenge_method=S256 HTTP/1.1" 303 - -"POST /token HTTP/1.1" 200 - -"GET /userinfo HTTP/1.1" 200 - -"GET /jwks HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4175%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=3wTcjLbHZ73Tq2RdHPs79-kJEcBcPBLQ&code_challenge=OMJ_4YniZmJEdxtS0kpHCgdbZfZWnNSNhMLOeom1y2g&code_challenge_method=S256 HTTP/1.1" 303 - -"POST /token HTTP/1.1" 200 - -"GET /userinfo HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4175%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=Rhe95uXtrJH8dXF0T1e_bLVh4ALeny99&code_challenge=1C8cQjsJ1XMTbESSapVbalnDO7QITK1ovYIt2N2OQ-M&code_challenge_method=S256 HTTP/1.1" 303 - -"POST /token HTTP/1.1" 200 - -"GET /userinfo HTTP/1.1" 200 - -"GET /logout?client_id=innercontext-web&post_logout_redirect_uri=http%3A%2F%2F127.0.0.1%3A4175%2F HTTP/1.1" 303 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4175%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=yYJja-mcNuteTK_vfmRG2q1ngy8uQCYA&code_challenge=X-3bMxYVrLNx0WI_jBRjT33JGo-ge_2SMbuDjMQSB7o&code_challenge_method=S256 HTTP/1.1" 303 - -"POST /token HTTP/1.1" 200 - -"GET /userinfo HTTP/1.1" 200 - -"GET /authorize?client_id=innercontext-web&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A4175%2Fauth%2Fcallback&scope=openid+profile+email+groups+offline_access&state=y64pl2E_jbiDANFJFjySZe06mGwJxLPY&code_challenge=l3OC99rarYHtSPsiS01wxbIdThMXnTRPUCu_9dUSA5w&code_challenge_method=S256 HTTP/1.1" 303 - -"POST /token HTTP/1.1" 200 - -"GET /userinfo HTTP/1.1" 200 - -"GET /jwks HTTP/1.1" 200 - diff --git a/.sisyphus/evidence/task-T8-protected-nav.md b/.sisyphus/evidence/task-T8-protected-nav.md deleted file mode 100644 index 898bd0a..0000000 --- a/.sisyphus/evidence/task-T8-protected-nav.md +++ /dev/null @@ -1,18 +0,0 @@ -# Task T8 Protected Navigation - -- QA app: `http://127.0.0.1:4175` -- Backend: `http://127.0.0.1:8002` -- Mock OIDC issuer: `http://127.0.0.1:9100` -- Backend DB: `.sisyphus/evidence/task-T8-qa.sqlite` - -Authenticated shell and protected route checks executed with Playwright: - -- `/` -> title `Dashboard - innercontext`, heading `Dashboard`, shell user `Playwright User`, role `Użytkownik`, logout visible `true` -- `/products` -> title `Produkty — innercontext`, heading `Produkty`, shell user `Playwright User`, role `Użytkownik`, logout visible `true` -- `/profile` -> title `Profil — innercontext`, heading `Profil`, shell user `Playwright User`, role `Użytkownik`, logout visible `true` -- `/routines` -> title `Rutyny — innercontext`, heading `Rutyny`, shell user `Playwright User`, role `Użytkownik`, logout visible `true` - -Logout endpoint check executed with Playwright request API: - -- `GET /auth/logout` -> `303` -- Location -> `http://127.0.0.1:9100/logout?client_id=innercontext-web&post_logout_redirect_uri=http%3A%2F%2F127.0.0.1%3A4175%2F` diff --git a/.sisyphus/evidence/task-T8-qa.sqlite b/.sisyphus/evidence/task-T8-qa.sqlite deleted file mode 100644 index 3ef93aa..0000000 Binary files a/.sisyphus/evidence/task-T8-qa.sqlite and /dev/null differ diff --git a/.sisyphus/evidence/task-T8-signed-out-network.txt b/.sisyphus/evidence/task-T8-signed-out-network.txt deleted file mode 100644 index 9128fdf..0000000 --- a/.sisyphus/evidence/task-T8-signed-out-network.txt +++ /dev/null @@ -1,10 +0,0 @@ -Playwright unauthenticated request check - -request: GET http://127.0.0.1:4175/products -cookies: none -maxRedirects: 0 - -status: 303 -location: /auth/login?returnTo=%2Fproducts - -result: protected page redirects to the login flow before returning page content. diff --git a/.sisyphus/evidence/task-T9-admin-households-denied.txt b/.sisyphus/evidence/task-T9-admin-households-denied.txt deleted file mode 100644 index 4774283..0000000 --- a/.sisyphus/evidence/task-T9-admin-households-denied.txt +++ /dev/null @@ -1,67 +0,0 @@ -============================= test session starts ============================== -platform darwin -- Python 3.12.12, pytest-9.0.2, pluggy-1.6.0 -- /Users/piotr/dev/innercontext/backend/.venv/bin/python3 -cachedir: .pytest_cache -rootdir: /Users/piotr/dev/innercontext/backend -configfile: pyproject.toml -plugins: anyio-4.12.1, cov-7.0.0 -collecting ... collected 15 items / 9 deselected / 6 selected - -tests/test_admin_households.py::test_assign_member_rejects_unsynced_user PASSED [ 16%] -tests/test_admin_households.py::test_admin_household_routes_forbidden_for_member[get-/admin/users-None] PASSED [ 33%] -tests/test_admin_households.py::test_admin_household_routes_forbidden_for_member[post-/admin/households-None] PASSED [ 50%] -tests/test_admin_households.py::test_admin_household_routes_forbidden_for_member[post-/admin/households/ad0a09c5-b0bb-4565-895c-eada9498db52/members-json_body2] PASSED [ 66%] -tests/test_admin_households.py::test_admin_household_routes_forbidden_for_member[patch-/admin/households/25453802-90e7-44a8-99ab-3241db73c5c0/members/56c635a2-bb8b-48ff-876e-f085ae7cff6c-None] PASSED [ 83%] -tests/test_admin_households.py::test_admin_household_routes_forbidden_for_member[delete-/admin/households/4390cd61-0028-4970-a573-a9fca06aff36/members/e5ba2298-ad10-44c0-a77a-b3ea7efa929f-None] PASSED [100%] - -================================ tests coverage ================================ -______________ coverage: platform darwin, python 3.12.12-final-0 _______________ - -Name Stmts Miss Cover Missing ----------------------------------------------------------------------------------- -innercontext/api/__init__.py 0 0 100% -innercontext/api/admin.py 93 26 72% 78, 102-110, 142, 165-183, 195-206 -innercontext/api/ai_logs.py 63 34 46% 18-30, 53-57, 69-81, 106-113 -innercontext/api/auth.py 68 18 74% 65-79, 100, 106-109, 122-129, 153-158, 166 -innercontext/api/auth_deps.py 25 10 60% 23, 36-48 -innercontext/api/authz.py 100 79 21% 16, 20, 24-27, 31, 35-40, 48-51, 60-66, 75-83, 89-93, 105-108, 116-133, 141-150, 156-177 -innercontext/api/health.py 236 113 52% 77-81, 142-146, 156-163, 179-185, 195-204, 214, 231-243, 253-270, 285-298, 313-330, 341-353, 363-371, 395-463, 473-482, 492, 509-521, 531-539 -innercontext/api/inventory.py 30 13 57% 26, 36-44, 53-60 -innercontext/api/llm_context.py 106 91 14% 17-21, 30-36, 44-47, 57-79, 101-153, 180-217, 249-253 -innercontext/api/product_llm_tools.py 107 94 12% 12-17, 23-38, 48-82, 111-136, 143-162, 172-205 -innercontext/api/products.py 638 419 34% 81-89, 97, 106-126, 281-289, 299-301, 312-387, 396-431, 515, 519-537, 541-550, 560-566, 570-571, 581-631, 649-703, 712-727, 731, 853-891, 904-972, 981-992, 1002-1015, 1024-1029, 1043-1048, 1060-1072, 1081-1086, 1097-1232, 1238-1240, 1244-1257, 1263, 1296-1457 -innercontext/api/profile.py 39 15 62% 36-39, 55-69 -innercontext/api/routines.py 632 446 29% 64-84, 99-103, 109-117, 127-133, 301-304, 308-325, 329-334, 344-357, 372-373, 389-399, 415-434, 443-479, 488-520, 529-565, 574-583, 587-600, 606, 619-641, 664-693, 697-703, 707-710, 714-721, 814-842, 852-857, 871-1134, 1143-1348, 1358-1359, 1371-1386, 1397-1409, 1419-1427, 1443-1464, 1475-1491, 1501-1509, 1524-1529, 1540-1552, 1562-1570 -innercontext/api/skincare.py 150 70 53% 103, 145-149, 158-166, 178-255, 267-277, 287-296, 306, 322-333, 343-350 -innercontext/api/utils.py 22 4 82% 34, 43, 51, 59 -innercontext/auth.py 236 146 38% 64-77, 127-129, 133-137, 141-149, 153-156, 161-168, 187-192, 195-210, 213-217, 220-228, 231-248, 251-264, 267, 271-274, 279, 283-284, 288-317, 325-363, 373-384 -innercontext/llm.py 134 119 11% 22, 44, 62-66, 74-102, 118-214, 231-326 -innercontext/llm_safety.py 18 14 22% 17-45, 58-61, 80-83 -innercontext/models/__init__.py 13 0 100% -innercontext/models/ai_log.py 33 0 100% -innercontext/models/api_metadata.py 15 0 100% -innercontext/models/base.py 3 0 100% -innercontext/models/domain.py 4 0 100% -innercontext/models/enums.py 152 0 100% -innercontext/models/health.py 64 0 100% -innercontext/models/household.py 14 0 100% -innercontext/models/household_membership.py 20 0 100% -innercontext/models/pricing.py 19 0 100% -innercontext/models/product.py 226 106 53% 76-78, 203-205, 209-230, 238-356 -innercontext/models/profile.py 17 0 100% -innercontext/models/routine.py 42 0 100% -innercontext/models/skincare.py 37 0 100% -innercontext/models/user.py 19 0 100% -innercontext/services/__init__.py 0 0 100% -innercontext/services/fx.py 57 42 26% 16, 20-22, 26-48, 54-67, 71-77 -innercontext/services/pricing_jobs.py 89 76 15% 19-24, 28-49, 53-67, 71-80, 89-107, 111-130, 134-138 -innercontext/validators/__init__.py 7 0 100% -innercontext/validators/base.py 22 5 77% 23, 27, 31, 35, 52 -innercontext/validators/batch_validator.py 128 105 18% 37, 58-154, 167-203, 214-240, 249-273 -innercontext/validators/photo_validator.py 65 54 17% 58-134, 144-152, 164-178 -innercontext/validators/product_parse_validator.py 110 93 15% 108-154, 164-172, 185-198, 205-239, 243-267, 273-319, 325-339 -innercontext/validators/routine_validator.py 146 114 22% 69-167, 173-175, 182-197, 201-218, 229-246, 259-275, 288-309 -innercontext/validators/shopping_validator.py 78 58 26% 49-96, 102-114, 122-123, 136-142, 150-161, 169-203 ----------------------------------------------------------------------------------- -TOTAL 4077 2364 42% -Coverage HTML written to dir htmlcov -======================= 6 passed, 9 deselected in 0.41s ======================== diff --git a/.sisyphus/evidence/task-T9-admin-households.txt b/.sisyphus/evidence/task-T9-admin-households.txt deleted file mode 100644 index d06796d..0000000 --- a/.sisyphus/evidence/task-T9-admin-households.txt +++ /dev/null @@ -1,65 +0,0 @@ -============================= test session starts ============================== -platform darwin -- Python 3.12.12, pytest-9.0.2, pluggy-1.6.0 -- /Users/piotr/dev/innercontext/backend/.venv/bin/python3 -cachedir: .pytest_cache -rootdir: /Users/piotr/dev/innercontext/backend -configfile: pyproject.toml -plugins: anyio-4.12.1, cov-7.0.0 -collecting ... collected 15 items / 11 deselected / 4 selected - -tests/test_admin_households.py::test_create_household_returns_new_household PASSED [ 25%] -tests/test_admin_households.py::test_assign_member_creates_membership PASSED [ 50%] -tests/test_admin_households.py::test_assign_member_rejects_already_assigned_user PASSED [ 75%] -tests/test_admin_households.py::test_assign_member_rejects_unsynced_user PASSED [100%] - -================================ tests coverage ================================ -______________ coverage: platform darwin, python 3.12.12-final-0 _______________ - -Name Stmts Miss Cover Missing ----------------------------------------------------------------------------------- -innercontext/api/__init__.py 0 0 100% -innercontext/api/admin.py 93 26 72% 78, 102-110, 142, 165-183, 195-206 -innercontext/api/ai_logs.py 63 34 46% 18-30, 53-57, 69-81, 106-113 -innercontext/api/auth.py 68 18 74% 65-79, 100, 106-109, 122-129, 153-158, 166 -innercontext/api/auth_deps.py 25 10 60% 23, 36-48 -innercontext/api/authz.py 100 79 21% 16, 20, 24-27, 31, 35-40, 48-51, 60-66, 75-83, 89-93, 105-108, 116-133, 141-150, 156-177 -innercontext/api/health.py 236 113 52% 77-81, 142-146, 156-163, 179-185, 195-204, 214, 231-243, 253-270, 285-298, 313-330, 341-353, 363-371, 395-463, 473-482, 492, 509-521, 531-539 -innercontext/api/inventory.py 30 13 57% 26, 36-44, 53-60 -innercontext/api/llm_context.py 106 91 14% 17-21, 30-36, 44-47, 57-79, 101-153, 180-217, 249-253 -innercontext/api/product_llm_tools.py 107 94 12% 12-17, 23-38, 48-82, 111-136, 143-162, 172-205 -innercontext/api/products.py 638 419 34% 81-89, 97, 106-126, 281-289, 299-301, 312-387, 396-431, 515, 519-537, 541-550, 560-566, 570-571, 581-631, 649-703, 712-727, 731, 853-891, 904-972, 981-992, 1002-1015, 1024-1029, 1043-1048, 1060-1072, 1081-1086, 1097-1232, 1238-1240, 1244-1257, 1263, 1296-1457 -innercontext/api/profile.py 39 15 62% 36-39, 55-69 -innercontext/api/routines.py 632 446 29% 64-84, 99-103, 109-117, 127-133, 301-304, 308-325, 329-334, 344-357, 372-373, 389-399, 415-434, 443-479, 488-520, 529-565, 574-583, 587-600, 606, 619-641, 664-693, 697-703, 707-710, 714-721, 814-842, 852-857, 871-1134, 1143-1348, 1358-1359, 1371-1386, 1397-1409, 1419-1427, 1443-1464, 1475-1491, 1501-1509, 1524-1529, 1540-1552, 1562-1570 -innercontext/api/skincare.py 150 70 53% 103, 145-149, 158-166, 178-255, 267-277, 287-296, 306, 322-333, 343-350 -innercontext/api/utils.py 22 4 82% 34, 43, 51, 59 -innercontext/auth.py 236 146 38% 64-77, 127-129, 133-137, 141-149, 153-156, 161-168, 187-192, 195-210, 213-217, 220-228, 231-248, 251-264, 267, 271-274, 279, 283-284, 288-317, 325-363, 373-384 -innercontext/llm.py 134 119 11% 22, 44, 62-66, 74-102, 118-214, 231-326 -innercontext/llm_safety.py 18 14 22% 17-45, 58-61, 80-83 -innercontext/models/__init__.py 13 0 100% -innercontext/models/ai_log.py 33 0 100% -innercontext/models/api_metadata.py 15 0 100% -innercontext/models/base.py 3 0 100% -innercontext/models/domain.py 4 0 100% -innercontext/models/enums.py 152 0 100% -innercontext/models/health.py 64 0 100% -innercontext/models/household.py 14 0 100% -innercontext/models/household_membership.py 20 0 100% -innercontext/models/pricing.py 19 0 100% -innercontext/models/product.py 226 106 53% 76-78, 203-205, 209-230, 238-356 -innercontext/models/profile.py 17 0 100% -innercontext/models/routine.py 42 0 100% -innercontext/models/skincare.py 37 0 100% -innercontext/models/user.py 19 0 100% -innercontext/services/__init__.py 0 0 100% -innercontext/services/fx.py 57 42 26% 16, 20-22, 26-48, 54-67, 71-77 -innercontext/services/pricing_jobs.py 89 76 15% 19-24, 28-49, 53-67, 71-80, 89-107, 111-130, 134-138 -innercontext/validators/__init__.py 7 0 100% -innercontext/validators/base.py 22 5 77% 23, 27, 31, 35, 52 -innercontext/validators/batch_validator.py 128 105 18% 37, 58-154, 167-203, 214-240, 249-273 -innercontext/validators/photo_validator.py 65 54 17% 58-134, 144-152, 164-178 -innercontext/validators/product_parse_validator.py 110 93 15% 108-154, 164-172, 185-198, 205-239, 243-267, 273-319, 325-339 -innercontext/validators/routine_validator.py 146 114 22% 69-167, 173-175, 182-197, 201-218, 229-246, 259-275, 288-309 -innercontext/validators/shopping_validator.py 78 58 26% 49-96, 102-114, 122-123, 136-142, 150-161, 169-203 ----------------------------------------------------------------------------------- -TOTAL 4077 2364 42% -Coverage HTML written to dir htmlcov -======================= 4 passed, 11 deselected in 0.41s ======================= diff --git a/.sisyphus/notepads/multi-user-authelia-oidc/T10-runtime-config.md b/.sisyphus/notepads/multi-user-authelia-oidc/T10-runtime-config.md deleted file mode 100644 index 07955fb..0000000 --- a/.sisyphus/notepads/multi-user-authelia-oidc/T10-runtime-config.md +++ /dev/null @@ -1,13 +0,0 @@ -# T10: Runtime Configuration and Validation - -## Learnings -- Nginx needs `X-Forwarded-Host` and `X-Forwarded-Port` for proper OIDC callback URL generation. -- `curl -f` fails on 302 redirects, which are common when a page is protected by OIDC. -- Health checks and deployment scripts must be updated to allow 302/303/307 status codes for the frontend root. -- Bash `((errors++))` returns 1 if `errors` is 0, which can kill the script if `set -e` is active. Use `errors=$((errors + 1))` instead. -- Documenting required environment variables in systemd service files and `DEPLOYMENT.md` is crucial for operators. -- Authelia client configuration requires specific `redirect_uris` and `scopes` (openid, profile, email, groups). - -## Verification -- `scripts/validate-env.sh` correctly identifies missing OIDC and session variables. -- `scripts/healthcheck.sh` and `deploy.sh` now handle auth redirects (302) for the frontend. diff --git a/.sisyphus/notepads/multi-user-authelia-oidc/decisions.md b/.sisyphus/notepads/multi-user-authelia-oidc/decisions.md deleted file mode 100644 index 68ad419..0000000 --- a/.sisyphus/notepads/multi-user-authelia-oidc/decisions.md +++ /dev/null @@ -1,5 +0,0 @@ -- Added `users`, `households`, and `household_memberships` tables with OIDC identity key (`oidc_issuer`, `oidc_subject`) and one-household-per-user enforced via unique `household_memberships.user_id`. -- Added `is_household_shared` to `product_inventory` with default `False` so sharing remains per-row opt-in. -- Migration enforces ownership in two phases: nullable + backfill to bootstrap admin, then non-null constraints on all owned tables. -- Correction: migration 4b7d2e9f1c3a applies a two-step ownership rollout (nullable user_id, bootstrap+backfill, then NOT NULL on owned tables). -- Centralized tenant authorization in `innercontext/api/authz.py` and exposed wrappers in `api/utils.py` so routers can move from global `get_or_404` to scoped helpers. diff --git a/.sisyphus/notepads/multi-user-authelia-oidc/issues.md b/.sisyphus/notepads/multi-user-authelia-oidc/issues.md deleted file mode 100644 index d53bb4c..0000000 --- a/.sisyphus/notepads/multi-user-authelia-oidc/issues.md +++ /dev/null @@ -1,3 +0,0 @@ -- Full backend pytest currently has pre-existing failures unrelated to this task scope (5 failing tests in routines/skincare helpers after schema changes in this branch context). -- Existing historical migration executes , which breaks full SQLite-from-base upgrades; T2 QA used a synthetic DB pinned at revision to validate the new migration behavior in isolation. -- Correction: historical migration 7c91e4b2af38 runs DROP TYPE IF EXISTS pricetier, which breaks SQLite full-chain upgrades; T2 evidence therefore uses a synthetic DB pinned to revision 9f3a2c1b4d5e for isolated migration validation. diff --git a/.sisyphus/notepads/multi-user-authelia-oidc/learnings.md b/.sisyphus/notepads/multi-user-authelia-oidc/learnings.md deleted file mode 100644 index 17a1d9b..0000000 --- a/.sisyphus/notepads/multi-user-authelia-oidc/learnings.md +++ /dev/null @@ -1,15 +0,0 @@ -- For ownership rollout without API auth wiring, `user_id` columns can be added as nullable to avoid breaking existing write paths and tests. -- Alembic is needed for SQLite-safe ownership column/FK addition and later non-null enforcement across legacy tables. -- Correction: Alembic batch_alter_table is required for SQLite-safe ownership column/FK addition and non-null enforcement across legacy tables. -- New tenant helpers should keep unauthorized lookups indistinguishable from missing rows by raising `404` with model-not-found detail. -- Product visibility and inventory access are separate checks: household-shared inventory can grant view access without granting update rights. -- Products should be visible when user is owner, admin, or in the same household as at least one household-shared inventory row; inventory payloads must still be filtered to shared rows only for non-owners. -- Shared inventory update rules differ from create/delete: household members in the same household can PATCH shared rows, but POST/DELETE inventory stays owner/admin only. -- Product summary ownership should use Product.user_id (is_owned) rather than active inventory presence, so shared products render as accessible-but-not-owned. -- SvelteKit can keep PKCE server-only by storing the verifier/state in a short-lived encrypted HTTP-only cookie and storing the refreshed app session in a separate encrypted HTTP-only cookie. -- `handleFetch` is enough to attach bearer tokens for server loads/actions that hit `PUBLIC_API_BASE`, but browser-direct `$lib/api` calls to `/api` still need follow-up proxy/auth plumbing outside this task. -- 2026-03-12 T6: Domain routers now enforce per-user ownership by default with explicit `?user_id=` admin override in profile/health/routines/skincare/ai-logs; routine suggestion product pool is constrained to owned+household-shared visibility and uses current user profile context. -- 2026-03-12 T6: QA evidence generated at `.sisyphus/evidence/task-T6-domain-tenancy.txt` and `.sisyphus/evidence/task-T6-routine-scope.txt` with passing scenarios. -- 2026-03-12 T9: Admin household management can stay backend-only by listing synced local `users` plus current membership state, creating bare `households`, and handling assign/move/remove as explicit membership operations. -- 2026-03-12 T9: Unsynced identities should fail assignment via local `User` lookup rather than implicit creation, keeping Authelia as the only identity source and preserving the v1 one-household-per-user rule. -- 2026-03-12 T8: Server-side frontend API helpers should call `PUBLIC_API_BASE` directly with the access token from `event.locals.session`; same-origin SvelteKit endpoints are still the right bridge for browser-only interactions like AI modals and inline PATCHes. diff --git a/.sisyphus/notepads/multi-user-authelia-oidc/problems.md b/.sisyphus/notepads/multi-user-authelia-oidc/problems.md deleted file mode 100644 index f2618a9..0000000 --- a/.sisyphus/notepads/multi-user-authelia-oidc/problems.md +++ /dev/null @@ -1,3 +0,0 @@ -- Pending follow-up migration task is required to materialize new models/columns in PostgreSQL schema. -- End-to-end SQLite from base revision remains blocked by pre-existing non-SQLite-safe migration logic () outside T2 scope. -- Correction: full SQLite alembic upgrade head from base is still blocked by pre-existing DROP TYPE usage in migration 7c91e4b2af38 (outside T2 scope). diff --git a/.sisyphus/plans/multi-user-authelia-oidc.md b/.sisyphus/plans/multi-user-authelia-oidc.md deleted file mode 100644 index 5f8da78..0000000 --- a/.sisyphus/plans/multi-user-authelia-oidc.md +++ /dev/null @@ -1,604 +0,0 @@ -# Multi-User Support with Authelia OIDC - -## TL;DR -> **Summary**: Convert the monorepo from a single-user personal system into a multi-user application authenticated by Authelia OIDC, with SvelteKit owning the login/session flow and FastAPI enforcing row-level ownership and household-scoped inventory sharing. -> **Deliverables**: -> - OIDC login/logout/session flow in SvelteKit -> - FastAPI token validation, current-user resolution, and authorization helpers -> - New local identity/household schema plus ownership migrations for existing data -> - Household-shared inventory support with owner/admin product controls -> - Updated infra, CI, and verification coverage for the new auth model -> **Effort**: XL -> **Parallel**: YES - 3 waves -> **Critical Path**: T1 -> T2 -> T3 -> T4 -> T5/T6 -> T7/T8 -> T11 - -## Context -### Original Request -Add multi-user support with login handled by Authelia using OpenID Connect. - -### Interview Summary -- Auth model: app-managed OIDC with SvelteKit-owned session handling; FastAPI acts as the resource server. -- Roles: `admin` and `member`; admins can manage member data and household memberships, but v1 excludes impersonation and a full user-management console. -- Ownership model: records are user-owned by default; `products` stay user-owned in v1. -- Sharing exception: product inventory may be shared among members of the same household; shared household members may view and update inventory entries, but only the product owner or an admin may edit/delete the underlying product. -- Rollout: retrofit the existing application in one implementation plan rather than staging auth separately. -- Identity source: Authelia remains the source of truth; no in-app signup/provisioning UI in v1. -- Verification preference: do not add a permanent frontend test suite in this pass; still require backend tests plus agent-executed QA scenarios. - -### Metis Review (gaps addressed) -- Made household sharing explicit with a local `households` + `household_memberships` model instead of overloading OIDC groups. -- Added a deterministic legacy-data backfill step so existing single-user records are assigned to the first configured admin identity during migration. -- Called out `llm_context.py`, helper functions like `get_or_404()`, and all row-fetching routes as mandatory scoping points so no single-user path survives. -- Chose JWT access-token validation via Authelia JWKS for FastAPI, with SvelteKit calling `userinfo` to hydrate the app session and local user record. -- Kept browser QA agent-executed and out of repo while still requiring backend auth tests and CI enablement. - -## Work Objectives -### Core Objective -Implement a secure, decision-complete multi-user architecture that uses Authelia OIDC for authentication, local app users/households for authorization, row ownership across existing data models, and household-scoped inventory sharing without broadening scope into a full account-management product. - -### Deliverables -- Backend identity/auth models for local users, households, memberships, and role mapping. -- Alembic migration/backfill converting all existing domain data to owned records. -- FastAPI auth dependencies, token validation, and authorization utilities. -- Retrofitted API routes and LLM context builders that enforce ownership. -- SvelteKit login, callback, logout, refresh, and protected-route behavior. -- Auth-aware API access from frontend server actions and protected page loads. -- Admin-only backend endpoints for household membership management without a UI console. -- nginx, deploy, CI, and environment updates needed for OIDC rollout. - -### Definition of Done (verifiable conditions with commands) -- `cd backend && uv run pytest` -- `cd backend && uv run ruff check .` -- `cd frontend && pnpm check` -- `cd frontend && pnpm lint` -- `cd frontend && pnpm build` -- `cd backend && uv run python -c "import json; from main import app; print(json.dumps(app.openapi())[:200])"` - -### Must Have -- OIDC Authorization Code flow with PKCE, server-handled callback, HTTP-only app session cookie, refresh-token renewal, and logout. -- FastAPI bearer-token validation against Authelia JWKS; no trusted identity headers between app tiers. -- Local `users`, `households`, and `household_memberships` tables keyed by `issuer + sub` rather than email. -- `user_id` ownership enforcement across profile, health, routines, skincare, AI logs, and products. -- Household inventory-sharing model that permits view/update of shared inventory by household members while preserving owner/admin control of product records. -- Deterministic backfill of legacy records to a configured bootstrap admin identity. -- Admin/member authorization rules enforced in backend dependencies and mirrored in frontend navigation/controls. -- Backend auth and authorization tests, plus CI job enablement for those tests. - -### Must NOT Have (guardrails, AI slop patterns, scope boundaries) -- No proxy-header trust model between SvelteKit and FastAPI. -- No in-app signup, password reset, email verification, impersonation, or full user-management console. -- No multi-household membership per user in v1. -- No global shared product catalog refactor in this pass. -- No audit-log productization, notification system, or support tooling. -- No permanent Playwright/Vitest suite added to the repo in this pass. - -## Verification Strategy -> ZERO HUMAN INTERVENTION - all verification is agent-executed. -- Test decision: tests-after using existing backend `pytest` + `TestClient`; no new committed frontend suite, but include agent-executed browser QA and curl-based verification. -- QA policy: every task includes happy-path and failure/edge-case scenarios with exact commands or browser actions. -- Evidence: `.sisyphus/evidence/task-{N}-{slug}.{ext}` - -## Execution Strategy -### Parallel Execution Waves -> Target: 5-8 tasks per wave. <3 per wave (except final) = under-splitting. -> Extract shared dependencies as Wave-1 tasks for max parallelism. - -Wave 1: T1 identity models, T2 ownership migration, T3 backend token validation, T4 tenant-aware authorization helpers - -Wave 2: T5 product/inventory authorization retrofit, T6 remaining domain scoping retrofit, T7 SvelteKit auth/session flow, T8 frontend auth-aware plumbing and shell behavior - -Wave 3: T9 admin household-management endpoints, T10 infra/env/CI/deploy updates, T11 backend auth regression coverage and release verification - -### Dependency Matrix (full, all tasks) -| Task | Depends On | Blocks | -| --- | --- | --- | -| T1 | - | T2, T3, T4, T9 | -| T2 | T1 | T5, T6, T11 | -| T3 | T1 | T4, T5, T6, T7, T8, T9, T11 | -| T4 | T1, T3 | T5, T6, T9 | -| T5 | T2, T3, T4 | T11 | -| T6 | T2, T3, T4 | T11 | -| T7 | T3 | T8, T10, T11 | -| T8 | T7 | T11 | -| T9 | T1, T2, T3, T4 | T11 | -| T10 | T3, T7 | T11 | -| T11 | T2, T3, T4, T5, T6, T7, T8, T9, T10 | Final verification | - -### Agent Dispatch Summary (wave -> task count -> categories) -- Wave 1 -> 4 tasks -> `deep`, `unspecified-high` -- Wave 2 -> 4 tasks -> `deep`, `unspecified-high`, `writing` -- Wave 3 -> 3 tasks -> `unspecified-high`, `writing`, `deep` - -## TODOs -> Implementation + Test = ONE task. Never separate. -> EVERY task MUST have: Agent Profile + Parallelization + QA Scenarios. - -- [x] T1. Add local identity, role, household, and sharing models - - **What to do**: Add a new backend model module for `User`, `Household`, and `HouseholdMembership`; extend existing domain models with ownership fields; add a compact role enum (`admin`, `member`) and a household-membership role enum (`owner`, `member`). Use `issuer + subject` as the immutable OIDC identity key, enforce at most one household membership per user in v1, and add `is_household_shared: bool = False` to `ProductInventory` so sharing is opt-in per inventory row rather than automatic for an entire household. - **Must NOT do**: Do not key users by email, do not introduce multi-household membership, do not split `Product` into catalog vs overlay tables in this pass, and do not add frontend management UI here. - - **Recommended Agent Profile**: - - Category: `deep` - Reason: cross-cutting schema design with downstream auth and authorization consequences - - Skills: `[]` - Existing backend conventions are the main source of truth - - Omitted: `svelte-code-writer` - No Svelte files belong in this task - - **Parallelization**: Can Parallel: NO | Wave 1 | Blocks: T2, T3, T4, T9 | Blocked By: - - - **References** (executor has NO interview context - be exhaustive): - - Pattern: `backend/innercontext/models/profile.py:13` - Simple SQLModel table with UUID PK and timestamp conventions to follow for user-owned profile data. - - Pattern: `backend/innercontext/models/product.py:138` - Main table-model style, JSON-column usage, and `updated_at` pattern. - - Pattern: `backend/innercontext/models/product.py:353` - Existing `ProductInventory` table to extend with ownership and sharing fields. - - Pattern: `backend/innercontext/models/__init__.py:1` - Export surface that must include every new model/type. - - API/Type: `backend/innercontext/models/enums.py` - Existing enum location; add role enums here unless a dedicated auth model module makes more sense. - - **Acceptance Criteria** (agent-executable only): - - [ ] `backend/innercontext/models/` defines `User`, `Household`, and `HouseholdMembership` with UUID PKs, timestamps, uniqueness on `(oidc_issuer, oidc_subject)`, and one-household-per-user enforcement. - - [ ] `Product`, `ProductInventory`, `UserProfile`, `MedicationEntry`, `MedicationUsage`, `LabResult`, `Routine`, `RoutineStep`, `GroomingSchedule`, `SkinConditionSnapshot`, and `AICallLog` each expose an ownership field (`user_id`) in model code, with `ProductInventory` also exposing `is_household_shared`. - - [ ] `innercontext.models` re-exports the new auth/household types so metadata loading and imports continue to work. - - [ ] `cd backend && uv run python -c "import innercontext.models as m; print(all(hasattr(m, name) for name in ['User','Household','HouseholdMembership']))"` prints `True`. - - **QA Scenarios** (MANDATORY - task incomplete without these): - ``` - Scenario: Identity models load into SQLModel metadata - Tool: Bash - Steps: Run `cd backend && uv run python -c "import innercontext.models; from sqlmodel import SQLModel; print(sorted(t.name for t in SQLModel.metadata.sorted_tables if t.name in {'users','households','household_memberships'}))" > ../.sisyphus/evidence/task-T1-identity-models.txt` - Expected: Evidence file lists `['household_memberships', 'households', 'users']` - Evidence: .sisyphus/evidence/task-T1-identity-models.txt - - Scenario: Product inventory sharing stays opt-in - Tool: Bash - Steps: Run `cd backend && uv run python -c "from innercontext.models.product import ProductInventory; f=ProductInventory.model_fields['is_household_shared']; print(f.default)" > ../.sisyphus/evidence/task-T1-sharing-default.txt` - Expected: Evidence file contains `False` - Evidence: .sisyphus/evidence/task-T1-sharing-default.txt - ``` - - **Commit**: YES | Message: `feat(auth): add local user and household models` | Files: `backend/innercontext/models/*` - -- [x] T2. Add Alembic migration and bootstrap backfill for legacy single-user data - - **What to do**: Create an Alembic revision that creates `users`, `households`, and `household_memberships`, adds `user_id` ownership columns and related foreign keys/indexes to all owned tables, and adds `is_household_shared` to `product_inventory`. Use a two-step migration: add nullable columns, create/bootstrap a local admin user + default household from environment variables, backfill every existing row to that bootstrap user, then enforce non-null ownership constraints. Use env names `BOOTSTRAP_ADMIN_OIDC_ISSUER`, `BOOTSTRAP_ADMIN_OIDC_SUB`, `BOOTSTRAP_ADMIN_EMAIL`, `BOOTSTRAP_ADMIN_NAME`, and `BOOTSTRAP_HOUSEHOLD_NAME`; abort the migration with a clear error if legacy data exists and the required issuer/sub values are missing. - **Must NOT do**: Do not assign ownership based on email matching, do not silently create random bootstrap identities, and do not leave owned tables nullable after the migration completes. - - **Recommended Agent Profile**: - - Category: `deep` - Reason: schema migration, backfill, and irreversible data-shape change - - Skills: `[]` - Use existing Alembic patterns from the repo - - Omitted: `git-master` - Commit strategy is already prescribed here - - **Parallelization**: Can Parallel: NO | Wave 1 | Blocks: T5, T6, T11 | Blocked By: T1 - - **References** (executor has NO interview context - be exhaustive): - - Pattern: `backend/alembic/versions/` - Existing migration naming/layout conventions to follow. - - Pattern: `backend/innercontext/models/product.py:180` - Timestamp/nullability expectations that migrated columns must preserve. - - Pattern: `backend/db.py:17` - Metadata creation path; migration must leave runtime startup compatible. - - API/Type: `backend/innercontext/models/profile.py:13` - Existing singleton-style table that must become owned data. - - API/Type: `backend/innercontext/models/product.py:353` - Inventory table receiving the sharing flag. - - **Acceptance Criteria** (agent-executable only): - - [ ] A new Alembic revision exists under `backend/alembic/versions/` creating auth/household tables and ownership columns/indexes/foreign keys. - - [ ] The migration backfills all existing owned rows to the bootstrap admin user and creates that user's default household + owner membership. - - [ ] The migration aborts with a readable exception if legacy data exists and `BOOTSTRAP_ADMIN_OIDC_ISSUER` or `BOOTSTRAP_ADMIN_OIDC_SUB` is absent. - - [ ] Owned tables end with non-null `user_id` constraints after upgrade. - - **QA Scenarios** (MANDATORY - task incomplete without these): - ``` - Scenario: Migration upgrade succeeds with bootstrap identity configured - Tool: Bash - Steps: Create a disposable DB URL (for example `sqlite:///../.sisyphus/evidence/task-T2-upgrade.sqlite`), then run `cd backend && DATABASE_URL=sqlite:///../.sisyphus/evidence/task-T2-upgrade.sqlite BOOTSTRAP_ADMIN_OIDC_ISSUER=https://auth.example.test BOOTSTRAP_ADMIN_OIDC_SUB=legacy-admin BOOTSTRAP_ADMIN_EMAIL=owner@example.test BOOTSTRAP_ADMIN_NAME='Legacy Owner' BOOTSTRAP_HOUSEHOLD_NAME='Default Household' uv run alembic upgrade head > ../.sisyphus/evidence/task-T2-migration-upgrade.txt` - Expected: Command exits 0 and evidence file shows Alembic reached `head` - Evidence: .sisyphus/evidence/task-T2-migration-upgrade.txt - - Scenario: Migration fails fast when bootstrap identity is missing for legacy data - Tool: Bash - Steps: Seed a disposable SQLite DB with one legacy row using the pre-migration schema, then run `cd backend && DATABASE_URL=sqlite:///../.sisyphus/evidence/task-T2-missing-bootstrap.sqlite uv run alembic upgrade head 2> ../.sisyphus/evidence/task-T2-migration-missing-bootstrap.txt` - Expected: Upgrade exits non-zero and evidence contains a message naming both missing bootstrap env vars - Evidence: .sisyphus/evidence/task-T2-migration-missing-bootstrap.txt - ``` - - **Commit**: YES | Message: `feat(db): backfill tenant ownership for existing records` | Files: `backend/alembic/versions/*`, `backend/innercontext/models/*` - -- [x] T3. Implement FastAPI token validation, user sync, and current-user dependencies - - **What to do**: Add backend auth modules that validate Authelia JWT access tokens via JWKS with cached key material, enforce issuer/audience/expiry checks, map role groups to local roles, and expose dependencies like `get_current_user()` and `require_admin()`. Create protected auth endpoints for session sync and self introspection (for example `/auth/session/sync` and `/auth/me`) so SvelteKit can exchange token-derived/userinfo-derived identity details for a local `User` row and current app profile. Use env/config values for issuer, JWKS URL/discovery URL, client ID, and group names instead of hard-coding them. - **Must NOT do**: Do not trust `X-Forwarded-User`-style headers, do not skip signature validation, do not derive role from email domain, and do not make backend routes public except health-check. - - **Recommended Agent Profile**: - - Category: `unspecified-high` - Reason: focused backend auth implementation with security-sensitive logic - - Skills: `[]` - No project skill is better than direct backend work here - - Omitted: `svelte-code-writer` - No Svelte components involved - - **Parallelization**: Can Parallel: NO | Wave 1 | Blocks: T4, T5, T6, T7, T8, T9, T11 | Blocked By: T1 - - **References** (executor has NO interview context - be exhaustive): - - Pattern: `backend/main.py:37` - Current FastAPI app construction and router registration point. - - Pattern: `backend/db.py:12` - Session dependency shape that auth dependencies must compose with. - - Pattern: `backend/innercontext/api/profile.py:27` - Router/dependency style used throughout the API. - - External: `https://www.authelia.com/configuration/identity-providers/openid-connect/provider/` - OIDC provider/discovery and JWKS behavior. - - External: `https://www.authelia.com/integration/openid-connect/openid-connect-1.0-claims/` - Claims and userinfo behavior; use `issuer + sub` as identity key. - - **Acceptance Criteria** (agent-executable only): - - [ ] A backend auth module validates bearer tokens against Authelia JWKS with issuer/audience checks and cached key refresh. - - [ ] Protected dependencies expose a normalized current user object with local `user_id`, role, and household membership information. - - [ ] Backend includes protected auth sync/introspection endpoints used by SvelteKit to upsert local users from OIDC identity data. - - [ ] Unauthenticated access to owned API routes returns `401`; authenticated access with a valid token reaches router logic. - - **QA Scenarios** (MANDATORY - task incomplete without these): - ``` - Scenario: Valid bearer token resolves a current user - Tool: Bash - Steps: Run `cd backend && uv run pytest tests/test_auth.py -k sync > ../.sisyphus/evidence/task-T3-auth-sync.txt` - Expected: Auth sync/introspection tests pass and evidence includes the protected auth endpoint names - Evidence: .sisyphus/evidence/task-T3-auth-sync.txt - - Scenario: Missing or invalid bearer token is rejected - Tool: Bash - Steps: Run `cd backend && uv run pytest tests/test_auth.py -k unauthorized > ../.sisyphus/evidence/task-T3-auth-unauthorized.txt` - Expected: Tests pass and evidence shows `401` expectations - Evidence: .sisyphus/evidence/task-T3-auth-unauthorized.txt - ``` - - **Commit**: YES | Message: `feat(auth): validate Authelia tokens in FastAPI` | Files: `backend/main.py`, `backend/innercontext/auth.py`, `backend/innercontext/api/auth*.py` - -- [x] T4. Centralize tenant-aware fetch helpers and authorization predicates - - **What to do**: Replace single-user helper assumptions with reusable authorization helpers that every router can call. Add tenant-aware helpers for owned lookup, admin override, same-household checks, and household-shared inventory visibility/update rules. Keep `get_session()` unchanged, but add helpers/dependencies that make it difficult for routers to accidentally query global rows. Update or supersede `get_or_404()` with helpers that scope by `user_id` and return `404` for unauthorized record lookups unless the route intentionally needs `403`. - **Must NOT do**: Do not leave routers performing raw `session.get()` on owned models, do not duplicate household-sharing logic in every route, and do not use admin bypasses that skip existence checks. - - **Recommended Agent Profile**: - - Category: `deep` - Reason: authorization rules must become the shared execution path for many routers - - Skills: `[]` - This is backend architecture work, not skill-driven tooling - - Omitted: `frontend-design` - No UI work belongs here - - **Parallelization**: Can Parallel: NO | Wave 1 | Blocks: T5, T6, T9 | Blocked By: T1, T3 - - **References** (executor has NO interview context - be exhaustive): - - Pattern: `backend/innercontext/api/utils.py:9` - Existing naive `get_or_404()` helper that must no longer be used for owned records. - - Pattern: `backend/innercontext/api/products.py:934` - Current direct object fetch/update/delete route pattern to replace. - - Pattern: `backend/innercontext/api/inventory.py:14` - Inventory routes that currently expose rows globally. - - Pattern: `backend/innercontext/api/health.py:141` - Representative list/get/update/delete health routes requiring shared helpers. - - Pattern: `backend/innercontext/api/routines.py:674` - Another high-volume router that must consume the same authz utilities. - - **Acceptance Criteria** (agent-executable only): - - [ ] Backend provides shared helper/dependency functions for owned lookups, admin checks, same-household checks, and shared-inventory updates. - - [ ] `get_or_404()` is either retired for owned data or wrapped so no owned router path still uses the unscoped helper directly. - - [ ] Shared inventory authorization distinguishes product ownership from inventory update rights. - - [ ] Helper tests cover owner access, admin override, same-household shared inventory access, and cross-household denial. - - **QA Scenarios** (MANDATORY - task incomplete without these): - ``` - Scenario: Authorization helpers allow owner/admin/household-shared access correctly - Tool: Bash - Steps: Run `cd backend && uv run pytest tests/test_authz.py -k 'owner or admin or household' > ../.sisyphus/evidence/task-T4-authz-happy.txt` - Expected: Tests pass and evidence includes owner/admin/household cases - Evidence: .sisyphus/evidence/task-T4-authz-happy.txt - - Scenario: Cross-household access is denied without leaking row existence - Tool: Bash - Steps: Run `cd backend && uv run pytest tests/test_authz.py -k denied > ../.sisyphus/evidence/task-T4-authz-denied.txt` - Expected: Tests pass and evidence shows `404` or `403` assertions exactly where specified by the helper contract - Evidence: .sisyphus/evidence/task-T4-authz-denied.txt - ``` - - **Commit**: YES | Message: `refactor(api): centralize tenant authorization helpers` | Files: `backend/innercontext/api/utils.py`, `backend/innercontext/api/authz.py`, router call sites - -- [x] T5. Retrofit products and inventory endpoints for owned access plus household sharing - - **What to do**: Update `products` and `inventory` APIs so product visibility is `owned OR household-visible-via-shared-inventory OR admin`, while product mutation remains `owner OR admin`. Keep `Product` user-owned. For household members, allow `GET` on shared products/inventory rows and `PATCH` on shared inventory rows, but keep `POST /products`, `PATCH /products/{id}`, `DELETE /products/{id}`, `POST /products/{id}/inventory`, and `DELETE /inventory/{id}` restricted to owner/admin. Reuse the existing `ProductListItem.is_owned` field so shared-but-not-owned products are clearly marked in summaries. Ensure suggestion and summary endpoints only use products accessible to the current user. - **Must NOT do**: Do not expose non-shared inventory across a household, do not let household members edit `personal_tolerance_notes`, and do not return global product lists anymore. - - **Recommended Agent Profile**: - - Category: `deep` - Reason: most nuanced authorization rules live in product and inventory flows - - Skills: `[]` - Backend logic and existing product patterns are sufficient - - Omitted: `frontend-design` - No UI polish belongs here - - **Parallelization**: Can Parallel: YES | Wave 2 | Blocks: T11 | Blocked By: T2, T3, T4 - - **References** (executor has NO interview context - be exhaustive): - - Pattern: `backend/innercontext/api/products.py:605` - List route currently returning global products. - - Pattern: `backend/innercontext/api/products.py:844` - Summary route already exposes `is_owned`; extend rather than replacing it. - - Pattern: `backend/innercontext/api/products.py:934` - Detail/update/delete routes that currently use direct lookup. - - Pattern: `backend/innercontext/api/products.py:977` - Product inventory list/create routes. - - Pattern: `backend/innercontext/api/inventory.py:14` - Direct inventory get/update/delete routes that currently bypass ownership. - - API/Type: `backend/innercontext/models/product.py:353` - Inventory model fields involved in household sharing. - - Test: `backend/tests/test_products.py:38` - Existing CRUD/filter test style to extend for authz cases. - - **Acceptance Criteria** (agent-executable only): - - [ ] Product list/detail/summary/suggest endpoints only return products accessible to the current user. - - [ ] Shared household members can `GET` shared products/inventory and `PATCH` shared inventory rows, but cannot mutate product records or create/delete another user's inventory rows. - - [ ] Product summaries preserve `is_owned` semantics for shared products. - - [ ] Product/inventory tests cover owner, admin, same-household shared member, and different-household member cases. - - **QA Scenarios** (MANDATORY - task incomplete without these): - ``` - Scenario: Household member can view a shared product and update its shared inventory row - Tool: Bash - Steps: Run `cd backend && uv run pytest tests/test_products_auth.py -k 'shared_inventory_update or shared_product_visible' > ../.sisyphus/evidence/task-T5-product-sharing.txt` - Expected: Tests pass and evidence shows `200` assertions for shared view/update cases - Evidence: .sisyphus/evidence/task-T5-product-sharing.txt - - Scenario: Household member cannot edit or delete another user's product - Tool: Bash - Steps: Run `cd backend && uv run pytest tests/test_products_auth.py -k 'cannot_edit_shared_product or cannot_delete_shared_product' > ../.sisyphus/evidence/task-T5-product-denied.txt` - Expected: Tests pass and evidence shows `403` or `404` assertions matching the route contract - Evidence: .sisyphus/evidence/task-T5-product-denied.txt - ``` - - **Commit**: YES | Message: `feat(api): scope products and inventory by owner and household` | Files: `backend/innercontext/api/products.py`, `backend/innercontext/api/inventory.py`, related tests - -- [x] T6. Retrofit remaining domain routes, LLM context, and jobs for per-user ownership - - **What to do**: Update profile, health, routines, skincare, AI log, and LLM-context code so every query is user-scoped by default and admin override is explicit. `UserProfile` becomes one-per-user rather than singleton; `build_user_profile_context()` and product-context builders must accept the current user and only include accessible data. Routine suggestion/batch flows must use the current user's profile plus products visible under the owned/shared rules from T5. Ensure background pricing/job paths preserve `user_id` on products and logs, and that list endpoints never aggregate cross-user data for non-admins. - **Must NOT do**: Do not keep any `select(Model)` query unfiltered on an owned model, do not keep singleton profile lookups, and do not leak other users' AI logs or health data through helper functions. - - **Recommended Agent Profile**: - - Category: `deep` - Reason: many routers and helper layers need consistent tenancy retrofits - - Skills: `[]` - Backend cross-module work only - - Omitted: `svelte-code-writer` - No Svelte component work in this task - - **Parallelization**: Can Parallel: YES | Wave 2 | Blocks: T11 | Blocked By: T2, T3, T4 - - **References** (executor has NO interview context - be exhaustive): - - Pattern: `backend/innercontext/api/profile.py:27` - Current singleton profile route using `get_user_profile(session)`. - - Pattern: `backend/innercontext/api/llm_context.py:10` - Single-user helper that currently selects the most recent profile globally. - - Pattern: `backend/innercontext/api/health.py:141` - Medication and lab-result CRUD/list route layout. - - Pattern: `backend/innercontext/api/routines.py:674` - Routine list/create/suggest entry points that need scoped product/profile data. - - Pattern: `backend/innercontext/api/skincare.py:222` - Snapshot list/get/update/delete route structure. - - Pattern: `backend/innercontext/api/ai_logs.py:46` - AI-log exposure that must become owned/admin-only. - - Pattern: `backend/innercontext/services/pricing_jobs.py` - Background queue path that must preserve product ownership. - - **Acceptance Criteria** (agent-executable only): - - [ ] Every non-admin router outside products/inventory scopes owned data by `user_id` before returning or mutating rows. - - [ ] `GET /profile` and `PATCH /profile` operate on the current user's profile, not the newest global profile. - - [ ] Routine suggestion and batch suggestion flows use only the current user's profile plus accessible products. - - [ ] AI logs are owned/admin-only, and background job/log creation stores `user_id` when applicable. - - **QA Scenarios** (MANDATORY - task incomplete without these): - ``` - Scenario: Member only sees their own health, routine, profile, skin, and AI-log data - Tool: Bash - Steps: Run `cd backend && uv run pytest tests/test_tenancy_domains.py -k 'profile or health or routines or skincare or ai_logs' > ../.sisyphus/evidence/task-T6-domain-tenancy.txt` - Expected: Tests pass and evidence shows only owned/admin-allowed access patterns - Evidence: .sisyphus/evidence/task-T6-domain-tenancy.txt - - Scenario: Routine suggestions ignore another user's products and profile - Tool: Bash - Steps: Run `cd backend && uv run pytest tests/test_routines_auth.py -k suggest > ../.sisyphus/evidence/task-T6-routine-scope.txt` - Expected: Tests pass and evidence shows suggestion inputs are scoped to the authenticated user plus shared inventory visibility rules - Evidence: .sisyphus/evidence/task-T6-routine-scope.txt - ``` - - **Commit**: YES | Message: `feat(api): enforce ownership across health routines and profile flows` | Files: `backend/innercontext/api/profile.py`, `backend/innercontext/api/health.py`, `backend/innercontext/api/routines.py`, `backend/innercontext/api/skincare.py`, `backend/innercontext/api/ai_logs.py`, `backend/innercontext/api/llm_context.py` - -- [x] T7. Implement SvelteKit OIDC login, callback, logout, refresh, and protected-session handling - - **What to do**: Add server-only auth utilities under `frontend/src/lib/server/` and implement `Authorization Code + PKCE` in SvelteKit using Authelia discovery/token/userinfo endpoints. Create `/auth/login`, `/auth/callback`, and `/auth/logout` server routes. Extend `hooks.server.ts` to decrypt/load the app session, refresh the access token when it is near expiry, populate `event.locals.user` and `event.locals.session`, and redirect unauthenticated requests on all application routes except `/auth/*` and static assets. Use an encrypted HTTP-only cookie named `innercontext_session` with `sameSite=lax`, `secure` in production, and a 32-byte secret from private env. - **Must NOT do**: Do not store access or refresh tokens in `localStorage`, do not expose client secrets via `$env/static/public`, and do not protect routes with client-only guards. - - **Recommended Agent Profile**: - - Category: `unspecified-high` - Reason: server-side SvelteKit auth flow with cookies, hooks, and redirects - - Skills: [`svelte-code-writer`] - Required for editing SvelteKit auth and route modules cleanly - - Omitted: `frontend-design` - This task is auth/session behavior, not visual redesign - - **Parallelization**: Can Parallel: YES | Wave 2 | Blocks: T8, T10, T11 | Blocked By: T3 - - **References** (executor has NO interview context - be exhaustive): - - Pattern: `frontend/src/hooks.server.ts:1` - Current global request hook; auth must compose with existing Paraglide middleware rather than replacing it. - - Pattern: `frontend/src/app.d.ts:3` - Add typed `App.Locals`/`PageData` session fields here. - - Pattern: `frontend/src/routes/+layout.svelte:30` - App shell/navigation that will consume authenticated user state later. - - Pattern: `frontend/src/routes/products/suggest/+page.server.ts:4` - Existing SvelteKit server action style using `fetch`. - - External: `https://www.authelia.com/configuration/identity-providers/openid-connect/clients/` - Client configuration expectations for auth code flow and PKCE. - - **Acceptance Criteria** (agent-executable only): - - [ ] SvelteKit exposes login/callback/logout server routes that complete the OIDC flow against Authelia and create/destroy `innercontext_session`. - - [ ] `hooks.server.ts` populates `event.locals.user`/`event.locals.session`, refreshes tokens near expiry, and redirects unauthenticated users away from protected pages. - - [ ] The callback flow calls backend auth sync before treating the user as signed in. - - [ ] Session cookies are HTTP-only and sourced only from private env/config. - - **QA Scenarios** (MANDATORY - task incomplete without these): - ``` - Scenario: Login callback establishes an authenticated server session - Tool: Playwright - Steps: Navigate to `/products` while signed out, follow redirect to `/auth/login`, on the Authelia page fill the `Username` and `Password` fields using `E2E_AUTHELIA_USERNAME`/`E2E_AUTHELIA_PASSWORD`, submit the primary login button, wait for redirect back to the app, then save an accessibility snapshot to `.sisyphus/evidence/task-T7-login-flow.md` - Expected: Final URL is inside the app, the protected page renders, and the session cookie exists - Evidence: .sisyphus/evidence/task-T7-login-flow.md - - Scenario: Expired or refresh-failed session redirects back to login - Tool: Playwright - Steps: Start from an authenticated session, replace the `innercontext_session` cookie with one containing an expired access token or invalidate the refresh endpoint in the browser session, reload `/products`, and save a snapshot to `.sisyphus/evidence/task-T7-refresh-failure.md` - Expected: The app clears the session cookie and redirects to `/auth/login` - Evidence: .sisyphus/evidence/task-T7-refresh-failure.md - ``` - - **Commit**: YES | Message: `feat(frontend): add Authelia OIDC session flow` | Files: `frontend/src/hooks.server.ts`, `frontend/src/app.d.ts`, `frontend/src/lib/server/auth.ts`, `frontend/src/routes/auth/*` - -- [x] T8. Refactor frontend data access, route guards, and shell state around the server session - - **What to do**: Refactor frontend API access so protected backend calls always originate from SvelteKit server loads/actions/endpoints using the access token from `event.locals.session`. Convert browser-side direct `$lib/api` usage to server actions or same-origin SvelteKit endpoints, add a `+layout.server.ts` that exposes authenticated user data to the shell, and update `+layout.svelte` to show the current user role/name plus a logout action. Regenerate OpenAPI types if backend response models change and keep `$lib/types` as the canonical import surface. - **Must NOT do**: Do not keep browser-side bearer-token fetches, do not bypass the server session by calling backend APIs directly from components, and do not hardcode English auth labels without Paraglide message keys. - - **Recommended Agent Profile**: - - Category: `unspecified-high` - Reason: SvelteKit route plumbing plus shell-state integration - - Skills: [`svelte-code-writer`] - Required because this task edits `.svelte` and SvelteKit route modules - - Omitted: `frontend-design` - Preserve the existing editorial shell instead of redesigning it - - **Parallelization**: Can Parallel: YES | Wave 2 | Blocks: T11 | Blocked By: T7 - - **References** (executor has NO interview context - be exhaustive): - - Pattern: `frontend/src/lib/api.ts:25` - Current request helper branching between browser and server; replace with session-aware server usage. - - Pattern: `frontend/src/routes/+layout.svelte:63` - Existing app shell where user state/logout should appear without breaking navigation. - - Pattern: `frontend/src/routes/+page.server.ts` - Representative server-load pattern already used throughout the app. - - Pattern: `frontend/src/routes/skin/new/+page.svelte` - Existing browser-side API import to eliminate or proxy through server logic. - - Pattern: `frontend/src/routes/routines/[id]/+page.svelte` - Another browser-side API import that must stop calling the backend directly. - - Pattern: `frontend/src/routes/products/suggest/+page.server.ts:4` - Server action pattern to reuse for auth-aware fetches. - - API/Type: `frontend/src/lib/types.ts` - Keep as the only frontend import surface after any `pnpm generate:api` run. - - **Acceptance Criteria** (agent-executable only): - - [ ] Protected backend calls in frontend code use the server session access token and no longer depend on browser token storage. - - [ ] Direct component-level `$lib/api` usage on protected paths is removed or wrapped behind same-origin server endpoints/actions. - - [ ] App shell receives authenticated user/session data from server load and exposes a logout affordance. - - [ ] `pnpm generate:api` is run if backend auth/API response changes require regenerated frontend types. - - **QA Scenarios** (MANDATORY - task incomplete without these): - ``` - Scenario: Authenticated user navigates protected pages and sees session-aware shell state - Tool: Playwright - Steps: Log in, visit `/`, `/products`, `/profile`, and `/routines`; capture an accessibility snapshot to `.sisyphus/evidence/task-T8-protected-nav.md` - Expected: Each page loads without redirect loops, and the shell shows the current user plus logout control - Evidence: .sisyphus/evidence/task-T8-protected-nav.md - - Scenario: Unauthenticated browser access cannot hit protected data paths directly - Tool: Playwright - Steps: Start from a signed-out browser, open a page that previously imported `$lib/api` from a component, attempt the same interaction, capture console/network output to `.sisyphus/evidence/task-T8-signed-out-network.txt` - Expected: The app redirects or blocks cleanly without leaking backend JSON responses into the UI - Evidence: .sisyphus/evidence/task-T8-signed-out-network.txt - ``` - - **Commit**: YES | Message: `refactor(frontend): route protected API access through server session` | Files: `frontend/src/lib/api.ts`, `frontend/src/routes/**/*.server.ts`, `frontend/src/routes/+layout.*`, selected `.svelte` files, `frontend/src/lib/types.ts` - -- [x] T9. Add admin-only household management API without a frontend console - - **What to do**: Add a small admin-only backend router for household administration so the app can support real household sharing without a management UI. Provide endpoints to list local users who have logged in, create a household, assign a user to a household, move a user between households, and remove a membership. Enforce the v1 rule that a user can belong to at most one household. Do not manage identity creation here; Authelia remains the identity source, and only locally synced users may be assigned. Non-bootstrap users should remain household-less until an admin assigns them. - **Must NOT do**: Do not add Svelte pages for household management, do not let non-admins call these endpoints, and do not allow membership assignment for users who have never authenticated into the app. - - **Recommended Agent Profile**: - - Category: `unspecified-high` - Reason: contained backend admin surface with sensitive authorization logic - - Skills: `[]` - Backend conventions already exist in repo - - Omitted: `frontend-design` - Explicitly no console/UI in scope - - **Parallelization**: Can Parallel: YES | Wave 3 | Blocks: T11 | Blocked By: T1, T2, T3, T4 - - **References** (executor has NO interview context - be exhaustive): - - Pattern: `backend/main.py:50` - Router registration area; add a dedicated admin router here. - - Pattern: `backend/innercontext/api/profile.py:41` - Simple patch/upsert route style for small admin mutation endpoints. - - Pattern: `backend/innercontext/api/utils.py:9` - Error-handling pattern to preserve with tenant-aware replacements. - - API/Type: `backend/innercontext/models/profile.py:13` - Example of owned record exposed without extra wrapper models. - - Test: `backend/tests/conftest.py:34` - Dependency-override style for admin/member API tests. - - **Acceptance Criteria** (agent-executable only): - - [ ] Backend exposes admin-only household endpoints for list/create/assign/move/remove operations. - - [ ] Membership moves preserve the one-household-per-user rule. - - [ ] Membership assignment only works for users already present in the local `users` table. - - [ ] Admin-route tests cover admin success, member denial, and attempted assignment of unsynced users. - - **QA Scenarios** (MANDATORY - task incomplete without these): - ``` - Scenario: Admin can create a household and assign a logged-in member - Tool: Bash - Steps: Run `cd backend && uv run pytest tests/test_admin_households.py -k 'create_household or assign_member' > ../.sisyphus/evidence/task-T9-admin-households.txt` - Expected: Tests pass and evidence shows admin-only success cases - Evidence: .sisyphus/evidence/task-T9-admin-households.txt - - Scenario: Member cannot manage households and unsynced users cannot be assigned - Tool: Bash - Steps: Run `cd backend && uv run pytest tests/test_admin_households.py -k 'forbidden or unsynced' > ../.sisyphus/evidence/task-T9-admin-households-denied.txt` - Expected: Tests pass and evidence shows `403`/validation failures for forbidden assignments - Evidence: .sisyphus/evidence/task-T9-admin-households-denied.txt - ``` - - **Commit**: YES | Message: `feat(api): add admin household management endpoints` | Files: `backend/main.py`, `backend/innercontext/api/admin*.py`, related tests - -- [x] T10. Update runtime configuration, validation scripts, deploy checks, and operator docs for OIDC - - **What to do**: Update runtime configuration for both services so frontend and backend receive the new OIDC/session env vars at runtime, and document the exact Authelia client/server setup required. Keep nginx in a pure reverse-proxy role (no `auth_request`), but make sure forwarded host/proto information remains sufficient for callback URL generation. Extend `scripts/validate-env.sh` and deploy validation so missing auth env vars fail fast, and update `scripts/healthcheck.sh` plus `deploy.sh` health expectations because authenticated pages may now redirect to login instead of returning `200` for signed-out probes. Document bootstrap-admin env usage for the migration. - **Must NOT do**: Do not add proxy-level auth, do not require manual post-deploy DB edits, and do not leave deploy health checks assuming `/` must return `200` when the app intentionally redirects signed-out users. - - **Recommended Agent Profile**: - - Category: `writing` - Reason: configuration, deployment, and operator-facing documentation dominate this task - - Skills: `[]` - Repo docs and service files are the governing references - - Omitted: `svelte-code-writer` - No Svelte component changes needed - - **Parallelization**: Can Parallel: YES | Wave 3 | Blocks: T11 | Blocked By: T3, T7 - - **References** (executor has NO interview context - be exhaustive): - - Pattern: `nginx/innercontext.conf:1` - Current reverse-proxy setup that must remain proxy-only. - - Pattern: `deploy.sh:313` - Service-wait and health-check functions to update for signed-out redirects and auth env validation. - - Pattern: `deploy.sh:331` - Backend/frontend health-check behavior currently assuming public app pages. - - Pattern: `scripts/validate-env.sh:57` - Existing required-env validation script to extend with OIDC/session/bootstrap keys. - - Pattern: `scripts/healthcheck.sh:10` - Current frontend health check that assumes `/` returns `200`. - - Pattern: `systemd/innercontext.service` - Backend runtime env injection point. - - Pattern: `systemd/innercontext-node.service` - Frontend runtime env injection point. - - Pattern: `docs/DEPLOYMENT.md` - Canonical operator runbook to update. - - **Acceptance Criteria** (agent-executable only): - - [ ] Backend and frontend runtime configs declare/document all required OIDC/session/bootstrap env vars. - - [ ] Deploy validation fails fast when required auth env vars are missing. - - [ ] Frontend health checks accept the signed-out auth redirect behavior or target a public route that remains intentionally available. - - [ ] Deployment docs describe Authelia client config, callback/logout URLs, JWKS/issuer envs, and bootstrap-migration envs. - - **QA Scenarios** (MANDATORY - task incomplete without these): - ``` - Scenario: Deploy validation rejects missing auth configuration - Tool: Bash - Steps: Run `scripts/validate-env.sh` (or the deploy wrapper that calls it) with one required OIDC/session variable removed, and redirect output to `.sisyphus/evidence/task-T10-missing-env.txt` - Expected: Validation exits non-zero and names the missing variable - Evidence: .sisyphus/evidence/task-T10-missing-env.txt - - Scenario: Signed-out frontend health behavior matches updated deployment expectations - Tool: Bash - Steps: Run the updated `scripts/healthcheck.sh` or deploy health-check path and save output to `.sisyphus/evidence/task-T10-health-check.txt` - Expected: Evidence shows a successful probe despite protected app routes (either via accepted redirect or a dedicated public health target) - Evidence: .sisyphus/evidence/task-T10-health-check.txt - ``` - - **Commit**: YES | Message: `chore(deploy): wire OIDC runtime configuration` | Files: `nginx/innercontext.conf`, `deploy.sh`, `scripts/validate-env.sh`, `scripts/healthcheck.sh`, `systemd/*`, `docs/DEPLOYMENT.md` - -- [ ] T11. Add shared auth fixtures, full regression coverage, and CI enforcement - - **What to do**: Build reusable backend test fixtures for authenticated users, roles, households, and shared inventory, then add regression tests covering auth sync, unauthenticated access, admin/member authorization, household inventory sharing, routine/product visibility, and migration-sensitive ownership behavior. Use dependency overrides in tests instead of hitting a live Authelia server. Enable the existing backend CI job so these tests run in Forgejo, and make sure the final verification command set includes backend tests, lint, frontend check/lint/build, and any required API type generation. - **Must NOT do**: Do not depend on a live Authelia instance in CI, do not leave the backend test job disabled, and do not add a committed frontend browser test suite in this pass. - - **Recommended Agent Profile**: - - Category: `unspecified-high` - Reason: broad regression coverage plus CI wiring across the monorepo - - Skills: `[]` - Existing pytest/CI patterns are sufficient - - Omitted: `playwright` - Browser QA stays agent-executed, not repository-committed - - **Parallelization**: Can Parallel: NO | Wave 3 | Blocks: Final verification | Blocked By: T2, T3, T4, T5, T6, T7, T8, T9, T10 - - **References** (executor has NO interview context - be exhaustive): - - Pattern: `backend/tests/conftest.py:16` - Per-test DB isolation and dependency override technique. - - Pattern: `backend/tests/test_products.py:4` - Existing endpoint-test style to mirror for authz coverage. - - Pattern: `.forgejo/workflows/ci.yml:83` - Disabled backend test job that must be enabled. - - Pattern: `frontend/package.json:6` - Final frontend verification commands available in the repo. - - Pattern: `backend/pyproject.toml` - Pytest command/config surface for any new test files. - - **Acceptance Criteria** (agent-executable only): - - [ ] Shared auth fixtures exist for admin/member identities, household membership, and shared inventory setup. - - [ ] Backend tests cover `401`, owner success, admin override, same-household shared inventory update, and different-household denial across representative routes. - - [ ] Forgejo backend tests run by default instead of being gated by `if: false`. - - [ ] Final command set passes: backend tests + lint, frontend check + lint + build, and API type generation only if required by backend schema changes. - - **QA Scenarios** (MANDATORY - task incomplete without these): - ``` - Scenario: Full backend auth regression suite passes locally - Tool: Bash - Steps: Run `cd backend && uv run pytest > ../.sisyphus/evidence/task-T11-backend-regression.txt` - Expected: Evidence file shows the full suite passing, including new auth/tenancy tests - Evidence: .sisyphus/evidence/task-T11-backend-regression.txt - - Scenario: CI config now runs backend tests instead of skipping them - Tool: Bash - Steps: Read `.forgejo/workflows/ci.yml`, confirm the backend-test job no longer contains `if: false`, and save a grep extract to `.sisyphus/evidence/task-T11-ci-enabled.txt` - Expected: Evidence shows the backend-test job is active and executes `uv run pytest` - Evidence: .sisyphus/evidence/task-T11-ci-enabled.txt - ``` - - **Commit**: YES | Message: `test(auth): add multi-user regression coverage` | Files: `backend/tests/*`, `.forgejo/workflows/ci.yml` - -## Final Verification Wave (4 parallel agents, ALL must APPROVE) -- [ ] F1. Plan Compliance Audit - oracle -- [ ] F2. Code Quality Review - unspecified-high -- [ ] F3. Real Manual QA - unspecified-high (+ playwright if UI) -- [ ] F4. Scope Fidelity Check - deep - -## Commit Strategy -- Use atomic commits after stable checkpoints: Wave 1 foundation, Wave 2 application integration, Wave 3 infra/tests. -- Prefer conventional commits with monorepo scopes such as `feat(auth): ...`, `feat(frontend): ...`, `feat(api): ...`, `test(auth): ...`, `chore(deploy): ...`. -- Do not merge unrelated refactors into auth/tenancy commits; keep schema, auth flow, frontend session, and infra/test changes reviewable. - -## Success Criteria -- Every protected route and API request resolves a concrete current user before touching owned data. -- Non-admin users cannot read or mutate records outside their ownership, except household-shared inventory entries. -- Household members can view/update shared inventory without gaining product edit rights. -- Existing single-user data survives migration and becomes accessible to the bootstrap admin account after first login. -- Frontend protected navigation/login/logout flow works without browser-stored bearer tokens. -- Backend test suite and CI catch auth regressions before deploy. diff --git a/backend/.coverage b/backend/.coverage index ab842f1..da6d65f 100644 Binary files a/backend/.coverage and b/backend/.coverage differ diff --git a/backend/innercontext/api/admin.py b/backend/innercontext/api/admin.py deleted file mode 100644 index 0a5c1fd..0000000 --- a/backend/innercontext/api/admin.py +++ /dev/null @@ -1,206 +0,0 @@ -from datetime import datetime -from typing import Annotated -from uuid import UUID - -from fastapi import APIRouter, Depends, HTTPException, Response, status -from sqlmodel import Session, SQLModel, select - -from db import get_session -from innercontext.api.auth_deps import require_admin -from innercontext.api.utils import get_or_404 -from innercontext.models import ( - Household, - HouseholdMembership, - HouseholdRole, - Role, - User, -) - -router = APIRouter(dependencies=[Depends(require_admin)]) -SessionDep = Annotated[Session, Depends(get_session)] - - -class AdminHouseholdPublic(SQLModel): - id: UUID - created_at: datetime - updated_at: datetime - - -class AdminHouseholdMembershipPublic(SQLModel): - id: UUID - user_id: UUID - household_id: UUID - role: HouseholdRole - created_at: datetime - updated_at: datetime - - -class AdminUserPublic(SQLModel): - id: UUID - oidc_issuer: str - oidc_subject: str - role: Role - created_at: datetime - updated_at: datetime - household_membership: AdminHouseholdMembershipPublic | None = None - - -class AdminHouseholdMembershipCreate(SQLModel): - user_id: UUID - role: HouseholdRole = HouseholdRole.MEMBER - - -def _membership_public( - membership: HouseholdMembership, -) -> AdminHouseholdMembershipPublic: - return AdminHouseholdMembershipPublic( - id=membership.id, - user_id=membership.user_id, - household_id=membership.household_id, - role=membership.role, - created_at=membership.created_at, - updated_at=membership.updated_at, - ) - - -def _household_public(household: Household) -> AdminHouseholdPublic: - return AdminHouseholdPublic( - id=household.id, - created_at=household.created_at, - updated_at=household.updated_at, - ) - - -def _user_public( - user: User, - membership: HouseholdMembership | None, -) -> AdminUserPublic: - return AdminUserPublic( - id=user.id, - oidc_issuer=user.oidc_issuer, - oidc_subject=user.oidc_subject, - role=user.role, - created_at=user.created_at, - updated_at=user.updated_at, - household_membership=( - _membership_public(membership) if membership is not None else None - ), - ) - - -def _get_membership_for_user( - session: Session, - user_id: UUID, -) -> HouseholdMembership | None: - return session.exec( - select(HouseholdMembership).where(HouseholdMembership.user_id == user_id) - ).first() - - -@router.get("/users", response_model=list[AdminUserPublic]) -def list_users(session: SessionDep): - users = sorted( - session.exec(select(User)).all(), - key=lambda user: (user.created_at, str(user.id)), - ) - memberships = session.exec(select(HouseholdMembership)).all() - memberships_by_user_id = { - membership.user_id: membership for membership in memberships - } - return [_user_public(user, memberships_by_user_id.get(user.id)) for user in users] - - -@router.post( - "/households", - response_model=AdminHouseholdPublic, - status_code=status.HTTP_201_CREATED, -) -def create_household(session: SessionDep): - household = Household() - session.add(household) - session.commit() - session.refresh(household) - return _household_public(household) - - -@router.post( - "/households/{household_id}/members", - response_model=AdminHouseholdMembershipPublic, - status_code=status.HTTP_201_CREATED, -) -def assign_household_member( - household_id: UUID, - payload: AdminHouseholdMembershipCreate, - session: SessionDep, -): - _ = get_or_404(session, Household, household_id) - _ = get_or_404(session, User, payload.user_id) - existing_membership = _get_membership_for_user(session, payload.user_id) - if existing_membership is not None: - detail = "User already belongs to a household" - if existing_membership.household_id == household_id: - detail = "User already belongs to this household" - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail) - - membership = HouseholdMembership( - user_id=payload.user_id, - household_id=household_id, - role=payload.role, - ) - session.add(membership) - session.commit() - session.refresh(membership) - return _membership_public(membership) - - -@router.patch( - "/households/{household_id}/members/{user_id}", - response_model=AdminHouseholdMembershipPublic, -) -def move_household_member( - household_id: UUID, - user_id: UUID, - session: SessionDep, -): - _ = get_or_404(session, Household, household_id) - _ = get_or_404(session, User, user_id) - membership = _get_membership_for_user(session, user_id) - if membership is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="HouseholdMembership not found", - ) - if membership.household_id == household_id: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="User already belongs to this household", - ) - - membership.household_id = household_id - session.add(membership) - session.commit() - session.refresh(membership) - return _membership_public(membership) - - -@router.delete( - "/households/{household_id}/members/{user_id}", - status_code=status.HTTP_204_NO_CONTENT, -) -def remove_household_member( - household_id: UUID, - user_id: UUID, - session: SessionDep, -): - _ = get_or_404(session, Household, household_id) - _ = get_or_404(session, User, user_id) - membership = _get_membership_for_user(session, user_id) - if membership is None or membership.household_id != household_id: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="HouseholdMembership not found", - ) - - session.delete(membership) - session.commit() - return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/innercontext/api/ai_logs.py b/backend/innercontext/api/ai_logs.py index b407006..040d47e 100644 --- a/backend/innercontext/api/ai_logs.py +++ b/backend/innercontext/api/ai_logs.py @@ -2,13 +2,10 @@ import json from typing import Any, Optional from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException from sqlmodel import Session, SQLModel, col, select from db import get_session -from innercontext.api.auth_deps import get_current_user -from innercontext.auth import CurrentUser -from innercontext.models.enums import Role from innercontext.models.ai_log import AICallLog router = APIRouter() @@ -46,33 +43,14 @@ class AICallLogPublic(SQLModel): error_detail: Optional[str] = None -def _resolve_target_user_id( - current_user: CurrentUser, - user_id: UUID | None, -) -> UUID: - if user_id is None: - return current_user.user_id - if current_user.role is not Role.ADMIN: - raise HTTPException(status_code=403, detail="Admin role required") - return user_id - - @router.get("", response_model=list[AICallLogPublic]) def list_ai_logs( endpoint: Optional[str] = None, success: Optional[bool] = None, limit: int = 50, - user_id: UUID | None = Query(default=None), session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), ): - target_user_id = _resolve_target_user_id(current_user, user_id) - stmt = ( - select(AICallLog) - .where(AICallLog.user_id == target_user_id) - .order_by(col(AICallLog.created_at).desc()) - .limit(limit) - ) + stmt = select(AICallLog).order_by(col(AICallLog.created_at).desc()).limit(limit) if endpoint is not None: stmt = stmt.where(AICallLog.endpoint == endpoint) if success is not None: @@ -97,17 +75,9 @@ def list_ai_logs( @router.get("/{log_id}", response_model=AICallLog) -def get_ai_log( - log_id: UUID, - user_id: UUID | None = Query(default=None), - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), -): - target_user_id = _resolve_target_user_id(current_user, user_id) +def get_ai_log(log_id: UUID, session: Session = Depends(get_session)): log = session.get(AICallLog, log_id) if log is None: raise HTTPException(status_code=404, detail="Log not found") - if log.user_id != target_user_id: - raise HTTPException(status_code=404, detail="Log not found") log.tool_trace = _normalize_tool_trace(getattr(log, "tool_trace", None)) return log diff --git a/backend/innercontext/api/auth.py b/backend/innercontext/api/auth.py deleted file mode 100644 index 877289e..0000000 --- a/backend/innercontext/api/auth.py +++ /dev/null @@ -1,166 +0,0 @@ -from __future__ import annotations - -from datetime import date, datetime -from uuid import UUID - -from fastapi import APIRouter, Depends, HTTPException, status -from sqlmodel import Field, Session, SQLModel, select - -from db import get_session -from innercontext.api.auth_deps import get_current_user -from innercontext.auth import CurrentUser, IdentityData, sync_current_user -from innercontext.models import HouseholdRole, Role, UserProfile - -router = APIRouter() - - -class SessionSyncRequest(SQLModel): - iss: str | None = None - sub: str | None = None - email: str | None = None - name: str | None = None - preferred_username: str | None = None - groups: list[str] | None = None - - -class AuthHouseholdMembershipPublic(SQLModel): - household_id: UUID - role: HouseholdRole - - -class AuthUserPublic(SQLModel): - id: UUID - role: Role - household_membership: AuthHouseholdMembershipPublic | None = None - - -class AuthIdentityPublic(SQLModel): - issuer: str - subject: str - email: str | None = None - name: str | None = None - preferred_username: str | None = None - groups: list[str] = Field(default_factory=list) - - -class AuthProfilePublic(SQLModel): - id: UUID - user_id: UUID | None - birth_date: date | None = None - sex_at_birth: str | None = None - created_at: datetime - updated_at: datetime - - -class AuthSessionResponse(SQLModel): - user: AuthUserPublic - identity: AuthIdentityPublic - profile: AuthProfilePublic | None = None - - -def _build_identity( - current_user: CurrentUser, - payload: SessionSyncRequest | None, -) -> IdentityData: - if payload is None: - return current_user.identity - - if payload.iss is not None and payload.iss != current_user.identity.issuer: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Session sync issuer does not match bearer token", - ) - if payload.sub is not None and payload.sub != current_user.identity.subject: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Session sync subject does not match bearer token", - ) - - return IdentityData( - issuer=current_user.identity.issuer, - subject=current_user.identity.subject, - email=( - payload.email if payload.email is not None else current_user.identity.email - ), - name=payload.name if payload.name is not None else current_user.identity.name, - preferred_username=( - payload.preferred_username - if payload.preferred_username is not None - else current_user.identity.preferred_username - ), - groups=( - tuple(payload.groups) - if payload.groups is not None - else current_user.identity.groups - ), - ) - - -def _get_profile(session: Session, user_id: UUID) -> UserProfile | None: - return session.exec( - select(UserProfile).where(UserProfile.user_id == user_id) - ).first() - - -def _profile_public(profile: UserProfile | None) -> AuthProfilePublic | None: - if profile is None: - return None - - return AuthProfilePublic( - id=profile.id, - user_id=profile.user_id, - birth_date=profile.birth_date, - sex_at_birth=( - profile.sex_at_birth.value if profile.sex_at_birth is not None else None - ), - created_at=profile.created_at, - updated_at=profile.updated_at, - ) - - -def _response(session: Session, current_user: CurrentUser) -> AuthSessionResponse: - household_membership = None - if current_user.household_membership is not None: - household_membership = AuthHouseholdMembershipPublic( - household_id=current_user.household_membership.household_id, - role=current_user.household_membership.role, - ) - - return AuthSessionResponse( - user=AuthUserPublic( - id=current_user.user_id, - role=current_user.role, - household_membership=household_membership, - ), - identity=AuthIdentityPublic( - issuer=current_user.identity.issuer, - subject=current_user.identity.subject, - email=current_user.identity.email, - name=current_user.identity.name, - preferred_username=current_user.identity.preferred_username, - groups=list(current_user.identity.groups), - ), - profile=_profile_public(_get_profile(session, current_user.user_id)), - ) - - -@router.post("/session/sync", response_model=AuthSessionResponse) -def sync_session( - payload: SessionSyncRequest | None = None, - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), -): - synced_user = sync_current_user( - session, - current_user.claims, - identity=_build_identity(current_user, payload), - ) - return _response(session, synced_user) - - -@router.get("/me", response_model=AuthSessionResponse) -def get_me( - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), -): - return _response(session, current_user) diff --git a/backend/innercontext/api/auth_deps.py b/backend/innercontext/api/auth_deps.py deleted file mode 100644 index a71a57a..0000000 --- a/backend/innercontext/api/auth_deps.py +++ /dev/null @@ -1,57 +0,0 @@ -from __future__ import annotations - -from typing import Annotated - -from fastapi import Depends, HTTPException, status -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from sqlmodel import Session - -from db import get_session -from innercontext.auth import ( - AuthConfigurationError, - CurrentUser, - TokenValidationError, - sync_current_user, - validate_access_token, -) -from innercontext.models import Role - -_bearer_scheme = HTTPBearer(auto_error=False) - - -def _unauthorized(detail: str) -> HTTPException: - return HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=detail, - headers={"WWW-Authenticate": "Bearer"}, - ) - - -def get_current_user( - credentials: Annotated[ - HTTPAuthorizationCredentials | None, Depends(_bearer_scheme) - ], - session: Session = Depends(get_session), -) -> CurrentUser: - if credentials is None or credentials.scheme.lower() != "bearer": - raise _unauthorized("Missing bearer token") - - try: - claims = validate_access_token(credentials.credentials) - return sync_current_user(session, claims) - except AuthConfigurationError as exc: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=str(exc), - ) from exc - except TokenValidationError as exc: - raise _unauthorized(str(exc)) from exc - - -def require_admin(current_user: CurrentUser = Depends(get_current_user)) -> CurrentUser: - if current_user.role is not Role.ADMIN: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Admin role required", - ) - return current_user diff --git a/backend/innercontext/api/authz.py b/backend/innercontext/api/authz.py deleted file mode 100644 index 82558e3..0000000 --- a/backend/innercontext/api/authz.py +++ /dev/null @@ -1,177 +0,0 @@ -from __future__ import annotations - -from typing import TypeVar, cast -from uuid import UUID - -from fastapi import HTTPException -from sqlmodel import Session, select - -from innercontext.auth import CurrentUser -from innercontext.models import HouseholdMembership, Product, ProductInventory, Role - -_T = TypeVar("_T") - - -def _not_found(model_name: str) -> HTTPException: - return HTTPException(status_code=404, detail=f"{model_name} not found") - - -def _user_scoped_model_name(model: type[object]) -> str: - return getattr(model, "__name__", str(model)) - - -def _record_user_id(model: type[object], record: object) -> object: - if not hasattr(record, "user_id"): - model_name = _user_scoped_model_name(model) - raise TypeError(f"{model_name} does not expose user_id") - return cast(object, getattr(record, "user_id")) - - -def _is_admin(current_user: CurrentUser) -> bool: - return current_user.role is Role.ADMIN - - -def _owner_household_id(session: Session, owner_user_id: UUID) -> UUID | None: - membership = session.exec( - select(HouseholdMembership).where(HouseholdMembership.user_id == owner_user_id) - ).first() - if membership is None: - return None - return membership.household_id - - -def _is_same_household( - session: Session, - owner_user_id: UUID, - current_user: CurrentUser, -) -> bool: - if current_user.household_membership is None: - return False - owner_household_id = _owner_household_id(session, owner_user_id) - return owner_household_id == current_user.household_membership.household_id - - -def get_owned_or_404( - session: Session, - model: type[_T], - record_id: object, - current_user: CurrentUser, -) -> _T: - obj = session.get(model, record_id) - model_name = _user_scoped_model_name(model) - if obj is None: - raise _not_found(model_name) - if _record_user_id(model, obj) != current_user.user_id: - raise _not_found(model_name) - return obj - - -def get_owned_or_404_admin_override( - session: Session, - model: type[_T], - record_id: object, - current_user: CurrentUser, -) -> _T: - obj = session.get(model, record_id) - model_name = _user_scoped_model_name(model) - if obj is None: - raise _not_found(model_name) - if _is_admin(current_user): - return obj - if _record_user_id(model, obj) != current_user.user_id: - raise _not_found(model_name) - return obj - - -def list_owned( - session: Session, model: type[_T], current_user: CurrentUser -) -> list[_T]: - model_name = _user_scoped_model_name(model) - if not hasattr(model, "user_id"): - raise TypeError(f"{model_name} does not expose user_id") - records = cast(list[_T], session.exec(select(model)).all()) - return [ - record - for record in records - if _record_user_id(model, record) == current_user.user_id - ] - - -def list_owned_admin_override( - session: Session, - model: type[_T], - current_user: CurrentUser, -) -> list[_T]: - if _is_admin(current_user): - statement = select(model) - return cast(list[_T], session.exec(statement).all()) - return list_owned(session, model, current_user) - - -def check_household_inventory_access( - session: Session, - inventory_id: UUID, - current_user: CurrentUser, -) -> ProductInventory: - inventory = session.get(ProductInventory, inventory_id) - if inventory is None: - raise _not_found(ProductInventory.__name__) - - if _is_admin(current_user): - return inventory - - owner_user_id = inventory.user_id - if owner_user_id == current_user.user_id: - return inventory - - if not inventory.is_household_shared or owner_user_id is None: - raise _not_found(ProductInventory.__name__) - - if not _is_same_household(session, owner_user_id, current_user): - raise _not_found(ProductInventory.__name__) - - return inventory - - -def can_update_inventory( - session: Session, - inventory_id: UUID, - current_user: CurrentUser, -) -> bool: - inventory = session.get(ProductInventory, inventory_id) - if inventory is None: - return False - if _is_admin(current_user): - return True - if inventory.user_id == current_user.user_id: - return True - if not inventory.is_household_shared or inventory.user_id is None: - return False - return _is_same_household(session, inventory.user_id, current_user) - - -def is_product_visible( - session: Session, product_id: UUID, current_user: CurrentUser -) -> bool: - product = session.get(Product, product_id) - if product is None: - return False - - if _is_admin(current_user): - return True - - if product.user_id == current_user.user_id: - return True - - if current_user.household_membership is None: - return False - - inventories = session.exec( - select(ProductInventory).where(ProductInventory.product_id == product_id) - ).all() - for inventory in inventories: - if not inventory.is_household_shared or inventory.user_id is None: - continue - if _is_same_household(session, inventory.user_id, current_user): - return True - return False diff --git a/backend/innercontext/api/health.py b/backend/innercontext/api/health.py index 9f2334e..d3e8064 100644 --- a/backend/innercontext/api/health.py +++ b/backend/innercontext/api/health.py @@ -3,17 +3,15 @@ from datetime import datetime from typing import Optional from uuid import UUID, uuid4 -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, Query from pydantic import field_validator from sqlalchemy import Integer, cast, func, or_ from sqlmodel import Session, SQLModel, col, select from db import get_session -from innercontext.api.auth_deps import get_current_user -from innercontext.api.utils import get_owned_or_404 -from innercontext.auth import CurrentUser +from innercontext.api.utils import get_or_404 from innercontext.models import LabResult, MedicationEntry, MedicationUsage -from innercontext.models.enums import MedicationKind, ResultFlag, Role +from innercontext.models.enums import MedicationKind, ResultFlag router = APIRouter() @@ -135,34 +133,6 @@ class LabResultListResponse(SQLModel): # --------------------------------------------------------------------------- -def _resolve_target_user_id( - current_user: CurrentUser, - user_id: UUID | None, -) -> UUID: - if user_id is None: - return current_user.user_id - if current_user.role is not Role.ADMIN: - raise HTTPException(status_code=403, detail="Admin role required") - return user_id - - -def _get_owned_or_admin_override( - session: Session, - model: type[MedicationEntry] | type[MedicationUsage] | type[LabResult], - record_id: UUID, - current_user: CurrentUser, - user_id: UUID | None, -): - if user_id is None: - return get_owned_or_404(session, model, record_id, current_user) - record = session.get(model, record_id) - if record is None or record.user_id != _resolve_target_user_id( - current_user, user_id - ): - raise HTTPException(status_code=404, detail=f"{model.__name__} not found") - return record - - # --------------------------------------------------------------------------- # Medication routes # --------------------------------------------------------------------------- @@ -172,12 +142,9 @@ def _get_owned_or_admin_override( def list_medications( kind: Optional[MedicationKind] = None, product_name: Optional[str] = None, - user_id: UUID | None = Query(default=None), session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), ): - target_user_id = _resolve_target_user_id(current_user, user_id) - stmt = select(MedicationEntry).where(MedicationEntry.user_id == target_user_id) + stmt = select(MedicationEntry) if kind is not None: stmt = stmt.where(MedicationEntry.kind == kind) if product_name is not None: @@ -186,18 +153,8 @@ def list_medications( @router.post("/medications", response_model=MedicationEntry, status_code=201) -def create_medication( - data: MedicationCreate, - user_id: UUID | None = Query(default=None), - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), -): - target_user_id = _resolve_target_user_id(current_user, user_id) - entry = MedicationEntry( - record_id=uuid4(), - user_id=target_user_id, - **data.model_dump(), - ) +def create_medication(data: MedicationCreate, session: Session = Depends(get_session)): + entry = MedicationEntry(record_id=uuid4(), **data.model_dump()) session.add(entry) session.commit() session.refresh(entry) @@ -205,36 +162,17 @@ def create_medication( @router.get("/medications/{medication_id}", response_model=MedicationEntry) -def get_medication( - medication_id: UUID, - user_id: UUID | None = Query(default=None), - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), -): - return _get_owned_or_admin_override( - session, - MedicationEntry, - medication_id, - current_user, - user_id, - ) +def get_medication(medication_id: UUID, session: Session = Depends(get_session)): + return get_or_404(session, MedicationEntry, medication_id) @router.patch("/medications/{medication_id}", response_model=MedicationEntry) def update_medication( medication_id: UUID, data: MedicationUpdate, - user_id: UUID | None = Query(default=None), session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), ): - entry = _get_owned_or_admin_override( - session, - MedicationEntry, - medication_id, - current_user, - user_id, - ) + entry = get_or_404(session, MedicationEntry, medication_id) for key, value in data.model_dump(exclude_unset=True).items(): setattr(entry, key, value) session.add(entry) @@ -244,25 +182,13 @@ def update_medication( @router.delete("/medications/{medication_id}", status_code=204) -def delete_medication( - medication_id: UUID, - user_id: UUID | None = Query(default=None), - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), -): - target_user_id = _resolve_target_user_id(current_user, user_id) - entry = _get_owned_or_admin_override( - session, - MedicationEntry, - medication_id, - current_user, - user_id, - ) +def delete_medication(medication_id: UUID, session: Session = Depends(get_session)): + entry = get_or_404(session, MedicationEntry, medication_id) # Delete usages first (no cascade configured at DB level) usages = session.exec( - select(MedicationUsage) - .where(MedicationUsage.medication_record_id == medication_id) - .where(MedicationUsage.user_id == target_user_id) + select(MedicationUsage).where( + MedicationUsage.medication_record_id == medication_id + ) ).all() for u in usages: session.delete(u) @@ -276,24 +202,10 @@ def delete_medication( @router.get("/medications/{medication_id}/usages", response_model=list[MedicationUsage]) -def list_usages( - medication_id: UUID, - user_id: UUID | None = Query(default=None), - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), -): - target_user_id = _resolve_target_user_id(current_user, user_id) - _ = _get_owned_or_admin_override( - session, - MedicationEntry, - medication_id, - current_user, - user_id, - ) - stmt = ( - select(MedicationUsage) - .where(MedicationUsage.medication_record_id == medication_id) - .where(MedicationUsage.user_id == target_user_id) +def list_usages(medication_id: UUID, session: Session = Depends(get_session)): + get_or_404(session, MedicationEntry, medication_id) + stmt = select(MedicationUsage).where( + MedicationUsage.medication_record_id == medication_id ) return session.exec(stmt).all() @@ -306,21 +218,11 @@ def list_usages( def create_usage( medication_id: UUID, data: UsageCreate, - user_id: UUID | None = Query(default=None), session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), ): - target_user_id = _resolve_target_user_id(current_user, user_id) - _ = _get_owned_or_admin_override( - session, - MedicationEntry, - medication_id, - current_user, - user_id, - ) + get_or_404(session, MedicationEntry, medication_id) usage = MedicationUsage( record_id=uuid4(), - user_id=target_user_id, medication_record_id=medication_id, **data.model_dump(), ) @@ -334,17 +236,9 @@ def create_usage( def update_usage( usage_id: UUID, data: UsageUpdate, - user_id: UUID | None = Query(default=None), session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), ): - usage = _get_owned_or_admin_override( - session, - MedicationUsage, - usage_id, - current_user, - user_id, - ) + usage = get_or_404(session, MedicationUsage, usage_id) for key, value in data.model_dump(exclude_unset=True).items(): setattr(usage, key, value) session.add(usage) @@ -354,19 +248,8 @@ def update_usage( @router.delete("/usages/{usage_id}", status_code=204) -def delete_usage( - usage_id: UUID, - user_id: UUID | None = Query(default=None), - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), -): - usage = _get_owned_or_admin_override( - session, - MedicationUsage, - usage_id, - current_user, - user_id, - ) +def delete_usage(usage_id: UUID, session: Session = Depends(get_session)): + usage = get_or_404(session, MedicationUsage, usage_id) session.delete(usage) session.commit() @@ -388,35 +271,29 @@ def list_lab_results( latest_only: bool = False, limit: int = Query(default=50, ge=1, le=200), offset: int = Query(default=0, ge=0), - user_id: UUID | None = Query(default=None), session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), ): - target_user_id = _resolve_target_user_id(current_user, user_id) - - def _apply_filters(statement): - statement = statement.where(col(LabResult.user_id) == target_user_id) - if q is not None and q.strip(): - query = f"%{q.strip()}%" - statement = statement.where( - or_( - col(LabResult.test_code).ilike(query), - col(LabResult.test_name_original).ilike(query), - ) + filters = [] + if q is not None and q.strip(): + query = f"%{q.strip()}%" + filters.append( + or_( + col(LabResult.test_code).ilike(query), + col(LabResult.test_name_original).ilike(query), ) - if test_code is not None: - statement = statement.where(col(LabResult.test_code) == test_code) - if flag is not None: - statement = statement.where(col(LabResult.flag) == flag) - if flags: - statement = statement.where(col(LabResult.flag).in_(flags)) - if without_flag: - statement = statement.where(col(LabResult.flag).is_(None)) - if from_date is not None: - statement = statement.where(col(LabResult.collected_at) >= from_date) - if to_date is not None: - statement = statement.where(col(LabResult.collected_at) <= to_date) - return statement + ) + if test_code is not None: + filters.append(LabResult.test_code == test_code) + if flag is not None: + filters.append(LabResult.flag == flag) + if flags: + filters.append(col(LabResult.flag).in_(flags)) + if without_flag: + filters.append(col(LabResult.flag).is_(None)) + if from_date is not None: + filters.append(LabResult.collected_at >= from_date) + if to_date is not None: + filters.append(LabResult.collected_at <= to_date) if latest_only: ranked_stmt = select( @@ -432,7 +309,8 @@ def list_lab_results( ) .label("rank"), ) - ranked_stmt = _apply_filters(ranked_stmt) + if filters: + ranked_stmt = ranked_stmt.where(*filters) ranked_subquery = ranked_stmt.subquery() latest_ids = select(ranked_subquery.c.record_id).where( @@ -445,8 +323,11 @@ def list_lab_results( .subquery() ) else: - stmt = _apply_filters(select(LabResult)) - count_stmt = _apply_filters(select(func.count()).select_from(LabResult)) + stmt = select(LabResult) + count_stmt = select(func.count()).select_from(LabResult) + if filters: + stmt = stmt.where(*filters) + count_stmt = count_stmt.where(*filters) test_code_numeric = cast( func.replace(col(LabResult.test_code), "-", ""), @@ -464,18 +345,8 @@ def list_lab_results( @router.post("/lab-results", response_model=LabResult, status_code=201) -def create_lab_result( - data: LabResultCreate, - user_id: UUID | None = Query(default=None), - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), -): - target_user_id = _resolve_target_user_id(current_user, user_id) - result = LabResult( - record_id=uuid4(), - user_id=target_user_id, - **data.model_dump(), - ) +def create_lab_result(data: LabResultCreate, session: Session = Depends(get_session)): + result = LabResult(record_id=uuid4(), **data.model_dump()) session.add(result) session.commit() session.refresh(result) @@ -483,36 +354,17 @@ def create_lab_result( @router.get("/lab-results/{result_id}", response_model=LabResult) -def get_lab_result( - result_id: UUID, - user_id: UUID | None = Query(default=None), - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), -): - return _get_owned_or_admin_override( - session, - LabResult, - result_id, - current_user, - user_id, - ) +def get_lab_result(result_id: UUID, session: Session = Depends(get_session)): + return get_or_404(session, LabResult, result_id) @router.patch("/lab-results/{result_id}", response_model=LabResult) def update_lab_result( result_id: UUID, data: LabResultUpdate, - user_id: UUID | None = Query(default=None), session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), ): - result = _get_owned_or_admin_override( - session, - LabResult, - result_id, - current_user, - user_id, - ) + result = get_or_404(session, LabResult, result_id) for key, value in data.model_dump(exclude_unset=True).items(): setattr(result, key, value) session.add(result) @@ -522,18 +374,7 @@ def update_lab_result( @router.delete("/lab-results/{result_id}", status_code=204) -def delete_lab_result( - result_id: UUID, - user_id: UUID | None = Query(default=None), - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), -): - result = _get_owned_or_admin_override( - session, - LabResult, - result_id, - current_user, - user_id, - ) +def delete_lab_result(result_id: UUID, session: Session = Depends(get_session)): + result = get_or_404(session, LabResult, result_id) session.delete(result) session.commit() diff --git a/backend/innercontext/api/inventory.py b/backend/innercontext/api/inventory.py index 6c3d797..9d50034 100644 --- a/backend/innercontext/api/inventory.py +++ b/backend/innercontext/api/inventory.py @@ -1,29 +1,19 @@ from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends from sqlmodel import Session from db import get_session -from innercontext.api.auth_deps import get_current_user -from innercontext.api.authz import ( - can_update_inventory, - check_household_inventory_access, -) from innercontext.api.products import InventoryUpdate -from innercontext.api.utils import get_or_404, get_owned_or_404_admin_override -from innercontext.auth import CurrentUser +from innercontext.api.utils import get_or_404 from innercontext.models import ProductInventory router = APIRouter() @router.get("/{inventory_id}", response_model=ProductInventory) -def get_inventory( - inventory_id: UUID, - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), -): - return check_household_inventory_access(session, inventory_id, current_user) +def get_inventory(inventory_id: UUID, session: Session = Depends(get_session)): + return get_or_404(session, ProductInventory, inventory_id) @router.patch("/{inventory_id}", response_model=ProductInventory) @@ -31,10 +21,7 @@ def update_inventory( inventory_id: UUID, data: InventoryUpdate, session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), ): - if not can_update_inventory(session, inventory_id, current_user): - raise HTTPException(status_code=404, detail="ProductInventory not found") entry = get_or_404(session, ProductInventory, inventory_id) for key, value in data.model_dump(exclude_unset=True).items(): setattr(entry, key, value) @@ -45,16 +32,7 @@ def update_inventory( @router.delete("/{inventory_id}", status_code=204) -def delete_inventory( - inventory_id: UUID, - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), -): - entry = get_owned_or_404_admin_override( - session, - ProductInventory, - inventory_id, - current_user, - ) +def delete_inventory(inventory_id: UUID, session: Session = Depends(get_session)): + entry = get_or_404(session, ProductInventory, inventory_id) session.delete(entry) session.commit() diff --git a/backend/innercontext/api/llm_context.py b/backend/innercontext/api/llm_context.py index af93be7..6ffc68e 100644 --- a/backend/innercontext/api/llm_context.py +++ b/backend/innercontext/api/llm_context.py @@ -2,41 +2,14 @@ from datetime import date from typing import Any from uuid import UUID -from fastapi import HTTPException from sqlmodel import Session, col, select -from innercontext.auth import CurrentUser from innercontext.models import Product, UserProfile -from innercontext.models.enums import Role -def _resolve_target_user_id( - current_user: CurrentUser, - user_id: UUID | None, -) -> UUID: - if user_id is None: - return current_user.user_id - if current_user.role is not Role.ADMIN: - raise HTTPException(status_code=403, detail="Admin role required") - return user_id - - -def get_user_profile( - session: Session, - current_user: CurrentUser | None = None, - *, - user_id: UUID | None = None, -) -> UserProfile | None: - if current_user is None: - return session.exec( - select(UserProfile).order_by(col(UserProfile.created_at).desc()) - ).first() - - target_user_id = _resolve_target_user_id(current_user, user_id) +def get_user_profile(session: Session) -> UserProfile | None: return session.exec( - select(UserProfile) - .where(UserProfile.user_id == target_user_id) - .order_by(col(UserProfile.created_at).desc()) + select(UserProfile).order_by(col(UserProfile.created_at).desc()) ).first() @@ -47,14 +20,8 @@ def calculate_age(birth_date: date, reference_date: date) -> int: return years -def build_user_profile_context( - session: Session, - reference_date: date, - current_user: CurrentUser | None = None, - *, - user_id: UUID | None = None, -) -> str: - profile = get_user_profile(session, current_user, user_id=user_id) +def build_user_profile_context(session: Session, reference_date: date) -> str: + profile = get_user_profile(session) if profile is None: return "USER PROFILE: no data\n" @@ -102,9 +69,8 @@ def build_product_context_summary(product: Product, has_inventory: bool = False) # Get effect profile scores if available effects = [] - effect_profile = getattr(product, "product_effect_profile", None) - if effect_profile: - profile = effect_profile + if hasattr(product, "effect_profile") and product.effect_profile: + profile = product.effect_profile # Only include notable effects (score > 0) # Handle both dict (from DB) and object (from Pydantic) if isinstance(profile, dict): @@ -199,12 +165,11 @@ def build_product_context_detailed( # Effect profile effect_profile = None - product_effect_profile = getattr(product, "effect_profile", None) - if product_effect_profile: - if isinstance(product_effect_profile, dict): - effect_profile = product_effect_profile + if hasattr(product, "effect_profile") and product.effect_profile: + if isinstance(product.effect_profile, dict): + effect_profile = product.effect_profile else: - effect_profile = product_effect_profile.model_dump() + effect_profile = product.effect_profile.model_dump() # Context rules context_rules = None diff --git a/backend/innercontext/api/products.py b/backend/innercontext/api/products.py index b453c14..7391a42 100644 --- a/backend/innercontext/api/products.py +++ b/backend/innercontext/api/products.py @@ -1,5 +1,3 @@ -# pyright: reportImportCycles=false, reportIncompatibleVariableOverride=false - import json import logging from datetime import date @@ -15,8 +13,6 @@ from sqlalchemy import select as sa_select from sqlmodel import Field, Session, SQLModel, col, select from db import get_session -from innercontext.api.auth_deps import get_current_user -from innercontext.api.authz import is_product_visible from innercontext.api.llm_context import build_user_profile_context from innercontext.api.product_llm_tools import ( PRODUCT_DETAILS_FUNCTION_DECLARATION, @@ -28,8 +24,7 @@ from innercontext.api.product_llm_tools import ( build_last_used_on_by_product, build_product_details_tool_handler, ) -from innercontext.api.utils import get_or_404, get_owned_or_404_admin_override -from innercontext.auth import CurrentUser +from innercontext.api.utils import get_or_404 from innercontext.llm import ( call_gemini, call_gemini_with_function_tools, @@ -47,7 +42,6 @@ from innercontext.models import ( SkinConcern, SkinConditionSnapshot, ) -from innercontext.models import Role from innercontext.models.ai_log import AICallLog from innercontext.models.api_metadata import ResponseMetadata, TokenMetrics from innercontext.models.enums import ( @@ -73,34 +67,6 @@ logger = logging.getLogger(__name__) router = APIRouter() -def _is_inventory_visible_to_user( - inventory: ProductInventory, - session: Session, - current_user: CurrentUser, -) -> bool: - if current_user.role is Role.ADMIN: - return True - if inventory.user_id == current_user.user_id: - return True - if not inventory.is_household_shared: - return False - if inventory.user_id is None: - return False - return is_product_visible(session, inventory.product_id, current_user) - - -def _visible_inventory_for_product( - inventories: list[ProductInventory], - session: Session, - current_user: CurrentUser, -) -> list[ProductInventory]: - return [ - inventory - for inventory in inventories - if _is_inventory_visible_to_user(inventory, session, current_user) - ] - - def _build_response_metadata(session: Session, log_id: Any) -> ResponseMetadata | None: """Build ResponseMetadata from AICallLog for Phase 3 observability.""" if not log_id: @@ -248,15 +214,15 @@ class ProductListItem(SQLModel): class AIActiveIngredient(ActiveIngredient): # Gemini API rejects int-enum values in response_schema; override with plain int. - strength_level: Optional[int] = None # pyright: ignore[reportIncompatibleVariableOverride] - irritation_potential: Optional[int] = None # pyright: ignore[reportIncompatibleVariableOverride] + strength_level: Optional[int] = None # type: ignore[assignment] + irritation_potential: Optional[int] = None # type: ignore[assignment] class ProductParseLLMResponse(ProductParseResponse): # Gemini response schema currently requires enum values to be strings. # Strength fields are numeric in our domain (1-3), so keep them as ints here # and convert via ProductParseResponse validation afterward. - actives: Optional[list[AIActiveIngredient]] = None # pyright: ignore[reportIncompatibleVariableOverride] + actives: Optional[list[AIActiveIngredient]] = None # type: ignore[assignment] class InventoryCreate(SQLModel): @@ -644,7 +610,6 @@ def list_products( is_medication: Optional[bool] = None, is_tool: Optional[bool] = None, session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), ): stmt = select(Product) if category is not None: @@ -657,12 +622,6 @@ def list_products( stmt = stmt.where(Product.is_tool == is_tool) products = list(session.exec(stmt).all()) - if current_user.role is not Role.ADMIN: - products = [ - product - for product in products - if is_product_visible(session, product.id, current_user) - ] # Filter by targets (JSON column — done in Python) if targets: @@ -687,28 +646,20 @@ def list_products( if product_ids else [] ) - inv_by_product: dict[UUID, list[ProductInventory]] = {} + inv_by_product: dict = {} for inv in inventory_rows: inv_by_product.setdefault(inv.product_id, []).append(inv) results = [] for p in products: r = ProductWithInventory.model_validate(p, from_attributes=True) - r.inventory = _visible_inventory_for_product( - inv_by_product.get(p.id, []), - session, - current_user, - ) + r.inventory = inv_by_product.get(p.id, []) results.append(r) return results @router.post("", response_model=ProductPublic, status_code=201) -def create_product( - data: ProductCreate, - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), -): +def create_product(data: ProductCreate, session: Session = Depends(get_session)): payload = data.model_dump() if payload.get("price_currency"): payload["price_currency"] = str(payload["price_currency"]).upper() @@ -716,7 +667,6 @@ def create_product( product_id = uuid4() product = Product( id=product_id, - user_id=current_user.user_id, short_id=str(product_id)[:8], **payload, ) @@ -899,12 +849,10 @@ def list_products_summary( is_medication: Optional[bool] = None, is_tool: Optional[bool] = None, session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), ): product_table = inspect(Product).local_table stmt = sa_select( product_table.c.id, - product_table.c.user_id, product_table.c.name, product_table.c.brand, product_table.c.category, @@ -924,10 +872,6 @@ def list_products_summary( stmt = stmt.where(product_table.c.is_tool == is_tool) rows = list(session.execute(stmt).all()) - if current_user.role is not Role.ADMIN: - rows = [ - row for row in rows if is_product_visible(session, row[0], current_user) - ] if targets: target_values = {t.value for t in targets} @@ -940,11 +884,26 @@ def list_products_summary( ) ] + product_ids = [row[0] for row in rows] + inventory_rows = ( + session.exec( + select(ProductInventory).where( + col(ProductInventory.product_id).in_(product_ids) + ) + ).all() + if product_ids + else [] + ) + owned_ids = { + inv.product_id + for inv in inventory_rows + if inv.product_id is not None and inv.finished_at is None + } + results: list[ProductListItem] = [] for row in rows: ( product_id, - product_user_id, name, brand_value, category_value, @@ -962,7 +921,7 @@ def list_products_summary( category=category_value, recommended_time=recommended_time, targets=row_targets or [], - is_owned=product_user_id == current_user.user_id, + is_owned=product_id in owned_ids, price_tier=price_tier, price_per_use_pln=price_per_use_pln, price_tier_source=price_tier_source, @@ -973,35 +932,22 @@ def list_products_summary( @router.get("/{product_id}", response_model=ProductWithInventory) -def get_product( - product_id: UUID, - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), -): +def get_product(product_id: UUID, session: Session = Depends(get_session)): product = get_or_404(session, Product, product_id) - if not is_product_visible(session, product_id, current_user): - raise HTTPException(status_code=404, detail="Product not found") inventory = session.exec( select(ProductInventory).where(ProductInventory.product_id == product_id) ).all() result = ProductWithInventory.model_validate(product, from_attributes=True) - result.inventory = _visible_inventory_for_product( - list(inventory), session, current_user - ) + result.inventory = list(inventory) return result @router.patch("/{product_id}", response_model=ProductPublic) def update_product( - product_id: UUID, - data: ProductUpdate, - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), + product_id: UUID, data: ProductUpdate, session: Session = Depends(get_session) ): - product = get_owned_or_404_admin_override( - session, Product, product_id, current_user - ) + product = get_or_404(session, Product, product_id) patch_data = data.model_dump(exclude_unset=True) if patch_data.get("price_currency"): patch_data["price_currency"] = str(patch_data["price_currency"]).upper() @@ -1016,14 +962,8 @@ def update_product( @router.delete("/{product_id}", status_code=204) -def delete_product( - product_id: UUID, - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), -): - product = get_owned_or_404_admin_override( - session, Product, product_id, current_user - ) +def delete_product(product_id: UUID, session: Session = Depends(get_session)): + product = get_or_404(session, Product, product_id) session.delete(product) enqueue_pricing_recalc(session) session.commit() @@ -1035,17 +975,10 @@ def delete_product( @router.get("/{product_id}/inventory", response_model=list[ProductInventory]) -def list_product_inventory( - product_id: UUID, - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), -): +def list_product_inventory(product_id: UUID, session: Session = Depends(get_session)): get_or_404(session, Product, product_id) - if not is_product_visible(session, product_id, current_user): - raise HTTPException(status_code=404, detail="Product not found") stmt = select(ProductInventory).where(ProductInventory.product_id == product_id) - inventories = list(session.exec(stmt).all()) - return _visible_inventory_for_product(inventories, session, current_user) + return session.exec(stmt).all() @router.post( @@ -1055,14 +988,10 @@ def create_product_inventory( product_id: UUID, data: InventoryCreate, session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), ): - product = get_owned_or_404_admin_override( - session, Product, product_id, current_user - ) + get_or_404(session, Product, product_id) entry = ProductInventory( id=uuid4(), - user_id=product.user_id or current_user.user_id, product_id=product_id, **data.model_dump(), ) @@ -1089,16 +1018,11 @@ def _ev(v: object) -> str: def _build_shopping_context( session: Session, reference_date: date, - current_user: CurrentUser, *, products: list[Product] | None = None, last_used_on_by_product: dict[str, date] | None = None, ) -> str: - profile_ctx = build_user_profile_context( - session, - reference_date=reference_date, - current_user=current_user, - ) + profile_ctx = build_user_profile_context(session, reference_date=reference_date) snapshot = session.exec( select(SkinConditionSnapshot).order_by( col(SkinConditionSnapshot.snapshot_date).desc() @@ -1137,7 +1061,7 @@ def _build_shopping_context( if product_ids else [] ) - inv_by_product: dict[UUID, list[ProductInventory]] = {} + inv_by_product: dict = {} for inv in inventory_rows: inv_by_product.setdefault(inv.product_id, []).append(inv) @@ -1289,10 +1213,7 @@ Format odpowiedzi - zwróć wyłącznie JSON zgodny z podanym schematem.""" @router.post("/suggest", response_model=ShoppingSuggestionResponse) -def suggest_shopping( - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), -): +def suggest_shopping(session: Session = Depends(get_session)): reference_date = date.today() shopping_products = _get_shopping_products(session) last_used_on_by_product = build_last_used_on_by_product( @@ -1302,7 +1223,6 @@ def suggest_shopping( context = _build_shopping_context( session, reference_date=reference_date, - current_user=current_user, products=shopping_products, last_used_on_by_product=last_used_on_by_product, ) diff --git a/backend/innercontext/api/profile.py b/backend/innercontext/api/profile.py index ebadbd4..52e8e14 100644 --- a/backend/innercontext/api/profile.py +++ b/backend/innercontext/api/profile.py @@ -1,14 +1,11 @@ from datetime import date, datetime from typing import Optional -from uuid import UUID -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends from sqlmodel import Session, SQLModel from db import get_session from innercontext.api.llm_context import get_user_profile -from innercontext.api.auth_deps import get_current_user -from innercontext.auth import CurrentUser from innercontext.models import SexAtBirth, UserProfile router = APIRouter() @@ -28,12 +25,8 @@ class UserProfilePublic(SQLModel): @router.get("", response_model=UserProfilePublic | None) -def get_profile( - user_id: UUID | None = Query(default=None), - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), -): - profile = get_user_profile(session, current_user, user_id=user_id) +def get_profile(session: Session = Depends(get_session)): + profile = get_user_profile(session) if profile is None: return None return UserProfilePublic( @@ -46,18 +39,12 @@ def get_profile( @router.patch("", response_model=UserProfilePublic) -def upsert_profile( - data: UserProfileUpdate, - user_id: UUID | None = Query(default=None), - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), -): - target_user_id = user_id if user_id is not None else current_user.user_id - profile = get_user_profile(session, current_user, user_id=user_id) +def upsert_profile(data: UserProfileUpdate, session: Session = Depends(get_session)): + profile = get_user_profile(session) payload = data.model_dump(exclude_unset=True) if profile is None: - profile = UserProfile(user_id=target_user_id, **payload) + profile = UserProfile(**payload) else: for key, value in payload.items(): setattr(profile, key, value) diff --git a/backend/innercontext/api/routines.py b/backend/innercontext/api/routines.py index 4f4264f..9f98b27 100644 --- a/backend/innercontext/api/routines.py +++ b/backend/innercontext/api/routines.py @@ -5,15 +5,12 @@ from datetime import date, timedelta from typing import Any, Optional from uuid import UUID, uuid4 -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException from google.genai import types as genai_types from pydantic import BaseModel as PydanticBase - from sqlmodel import Field, Session, SQLModel, col, select from db import get_session -from innercontext.api.auth_deps import get_current_user -from innercontext.api.authz import is_product_visible from innercontext.api.llm_context import ( build_products_context_summary_list, build_user_profile_context, @@ -28,8 +25,7 @@ from innercontext.api.product_llm_tools import ( build_last_used_on_by_product, build_product_details_tool_handler, ) -from innercontext.api.utils import get_owned_or_404 -from innercontext.auth import CurrentUser +from innercontext.api.utils import get_or_404 from innercontext.llm import ( call_gemini, call_gemini_with_function_tools, @@ -37,7 +33,6 @@ from innercontext.llm import ( ) from innercontext.llm_safety import isolate_user_input, sanitize_user_input from innercontext.models import ( - HouseholdMembership, GroomingSchedule, Product, ProductInventory, @@ -48,7 +43,6 @@ from innercontext.models import ( from innercontext.models.ai_log import AICallLog from innercontext.models.api_metadata import ResponseMetadata, TokenMetrics from innercontext.models.enums import GroomingAction, PartOfDay -from innercontext.models.enums import Role from innercontext.validators import BatchValidator, RoutineSuggestionValidator from innercontext.validators.batch_validator import BatchValidationContext from innercontext.validators.routine_validator import RoutineValidationContext @@ -92,47 +86,6 @@ def _build_response_metadata(session: Session, log_id: Any) -> ResponseMetadata router = APIRouter() -def _resolve_target_user_id( - current_user: CurrentUser, - user_id: UUID | None, -) -> UUID: - if user_id is None: - return current_user.user_id - if current_user.role is not Role.ADMIN: - raise HTTPException(status_code=403, detail="Admin role required") - return user_id - - -def _shared_household_user_ids( - session: Session, current_user: CurrentUser -) -> set[UUID]: - membership = current_user.household_membership - if membership is None: - return set() - user_ids = session.exec( - select(HouseholdMembership.user_id).where( - HouseholdMembership.household_id == membership.household_id - ) - ).all() - return {uid for uid in user_ids if uid != current_user.user_id} - - -def _get_owned_or_admin_override( - session: Session, - model: type[Routine] | type[RoutineStep] | type[GroomingSchedule], - record_id: UUID, - current_user: CurrentUser, - user_id: UUID | None, -): - if user_id is None: - return get_owned_or_404(session, model, record_id, current_user) - target_user_id = _resolve_target_user_id(current_user, user_id) - record = session.get(model, record_id) - if record is None or record.user_id != target_user_id: - raise HTTPException(status_code=404, detail=f"{model.__name__} not found") - return record - - # --------------------------------------------------------------------------- # Schemas # --------------------------------------------------------------------------- @@ -336,7 +289,6 @@ def _ev(v: object) -> str: def _get_recent_skin_snapshot( session: Session, - target_user_id: UUID, reference_date: date, window_days: int = HISTORY_WINDOW_DAYS, fallback_days: int = SNAPSHOT_FALLBACK_DAYS, @@ -346,7 +298,6 @@ def _get_recent_skin_snapshot( snapshot = session.exec( select(SkinConditionSnapshot) - .where(SkinConditionSnapshot.user_id == target_user_id) .where(SkinConditionSnapshot.snapshot_date <= reference_date) .where(SkinConditionSnapshot.snapshot_date >= window_cutoff) .order_by(col(SkinConditionSnapshot.snapshot_date).desc()) @@ -356,7 +307,6 @@ def _get_recent_skin_snapshot( return session.exec( select(SkinConditionSnapshot) - .where(SkinConditionSnapshot.user_id == target_user_id) .where(SkinConditionSnapshot.snapshot_date <= reference_date) .where(SkinConditionSnapshot.snapshot_date >= fallback_cutoff) .order_by(col(SkinConditionSnapshot.snapshot_date).desc()) @@ -365,14 +315,12 @@ def _get_recent_skin_snapshot( def _get_latest_skin_snapshot_within_days( session: Session, - target_user_id: UUID, reference_date: date, max_age_days: int = SNAPSHOT_FALLBACK_DAYS, ) -> SkinConditionSnapshot | None: cutoff = reference_date - timedelta(days=max_age_days) return session.exec( select(SkinConditionSnapshot) - .where(SkinConditionSnapshot.user_id == target_user_id) .where(SkinConditionSnapshot.snapshot_date <= reference_date) .where(SkinConditionSnapshot.snapshot_date >= cutoff) .order_by(col(SkinConditionSnapshot.snapshot_date).desc()) @@ -381,14 +329,12 @@ def _get_latest_skin_snapshot_within_days( def _build_skin_context( session: Session, - target_user_id: UUID, reference_date: date, window_days: int = HISTORY_WINDOW_DAYS, fallback_days: int = SNAPSHOT_FALLBACK_DAYS, ) -> str: snapshot = _get_recent_skin_snapshot( session, - target_user_id=target_user_id, reference_date=reference_date, window_days=window_days, fallback_days=fallback_days, @@ -408,14 +354,10 @@ def _build_skin_context( def _build_grooming_context( - session: Session, - target_user_id: UUID, - weekdays: Optional[list[int]] = None, + session: Session, weekdays: Optional[list[int]] = None ) -> str: entries = session.exec( - select(GroomingSchedule) - .where(GroomingSchedule.user_id == target_user_id) - .order_by(col(GroomingSchedule.day_of_week)) + select(GroomingSchedule).order_by(col(GroomingSchedule.day_of_week)) ).all() if not entries: return "GROOMING SCHEDULE: none\n" @@ -436,14 +378,11 @@ def _build_grooming_context( def _build_upcoming_grooming_context( session: Session, - target_user_id: UUID, start_date: date, days: int = 7, ) -> str: entries = session.exec( - select(GroomingSchedule) - .where(GroomingSchedule.user_id == target_user_id) - .order_by(col(GroomingSchedule.day_of_week)) + select(GroomingSchedule).order_by(col(GroomingSchedule.day_of_week)) ).all() if not entries: return f"UPCOMING GROOMING (next {days} days): none\n" @@ -481,14 +420,12 @@ def _build_upcoming_grooming_context( def _build_recent_history( session: Session, - target_user_id: UUID, reference_date: date, window_days: int = HISTORY_WINDOW_DAYS, ) -> str: cutoff = reference_date - timedelta(days=window_days) routines = session.exec( select(Routine) - .where(Routine.user_id == target_user_id) .where(Routine.routine_date <= reference_date) .where(Routine.routine_date >= cutoff) .order_by(col(Routine.routine_date).desc()) @@ -500,7 +437,6 @@ def _build_recent_history( steps = session.exec( select(RoutineStep) .where(RoutineStep.routine_id == r.id) - .where(RoutineStep.user_id == target_user_id) .order_by(col(RoutineStep.order_index)) ).all() step_names = [] @@ -522,37 +458,11 @@ def _build_recent_history( def _get_available_products( session: Session, - current_user: CurrentUser, time_filter: Optional[str] = None, include_minoxidil: bool = True, ) -> list[Product]: stmt = select(Product).where(col(Product.is_tool).is_(False)) - if current_user.role is not Role.ADMIN: - owned_products = session.exec( - stmt.where(col(Product.user_id) == current_user.user_id) - ).all() - shared_user_ids = _shared_household_user_ids(session, current_user) - shared_product_ids = ( - session.exec( - select(ProductInventory.product_id) - .where(col(ProductInventory.is_household_shared).is_(True)) - .where(col(ProductInventory.user_id).in_(list(shared_user_ids))) - .distinct() - ).all() - if shared_user_ids - else [] - ) - shared_products = ( - session.exec(stmt.where(col(Product.id).in_(shared_product_ids))).all() - if shared_product_ids - else [] - ) - products_by_id = {p.id: p for p in owned_products} - for product in shared_products: - products_by_id.setdefault(product.id, product) - products = list(products_by_id.values()) - else: - products = session.exec(stmt).all() + products = session.exec(stmt).all() result: list[Product] = [] for p in products: if p.is_medication and not _is_minoxidil_product(p): @@ -607,9 +517,7 @@ def _extract_requested_product_ids( def _get_products_with_inventory( - session: Session, - current_user: CurrentUser, - product_ids: list[UUID], + session: Session, product_ids: list[UUID] ) -> set[UUID]: """ Return set of product IDs that have active (non-finished) inventory. @@ -619,33 +527,17 @@ def _get_products_with_inventory( if not product_ids: return set() - stmt = ( + inventory_rows = session.exec( select(ProductInventory.product_id) .where(col(ProductInventory.product_id).in_(product_ids)) .where(col(ProductInventory.finished_at).is_(None)) - ) - if current_user.role is not Role.ADMIN: - owned_inventory_rows = session.exec( - stmt.where(col(ProductInventory.user_id) == current_user.user_id).distinct() - ).all() - shared_user_ids = _shared_household_user_ids(session, current_user) - shared_inventory_rows = session.exec( - stmt.where(col(ProductInventory.is_household_shared).is_(True)) - .where(col(ProductInventory.user_id).in_(list(shared_user_ids))) - .distinct() - ).all() - inventory_rows = set(owned_inventory_rows) - inventory_rows.update(shared_inventory_rows) - return inventory_rows - inventory_rows = session.exec(stmt.distinct()).all() + .distinct() + ).all() + return set(inventory_rows) -def _expand_product_id( - session: Session, - current_user: CurrentUser, - short_or_full_id: str, -) -> UUID | None: +def _expand_product_id(session: Session, short_or_full_id: str) -> UUID | None: """ Expand 8-char short_id to full UUID, or validate full UUID. @@ -666,13 +558,7 @@ def _expand_product_id( uuid_obj = UUID(short_or_full_id) # Verify it exists product = session.get(Product, uuid_obj) - if product is None: - return None - return ( - uuid_obj - if is_product_visible(session, uuid_obj, current_user) - else None - ) + return uuid_obj if product else None except (ValueError, TypeError): return None @@ -681,13 +567,7 @@ def _expand_product_id( product = session.exec( select(Product).where(Product.short_id == short_or_full_id) ).first() - if product is None: - return None - return ( - product.id - if is_product_visible(session, product.id, current_user) - else None - ) + return product.id if product else None # Invalid length return None @@ -710,17 +590,6 @@ def _build_day_context(leaving_home: Optional[bool]) -> str: return f"DAY CONTEXT:\n Leaving home: {val}\n" -def _coerce_action_type(value: object) -> GroomingAction | None: - if isinstance(value, GroomingAction): - return value - if isinstance(value, str): - try: - return GroomingAction(value) - except ValueError: - return None - return None - - _ROUTINES_SYSTEM_PROMPT = """\ Jesteś ekspertem planowania pielęgnacji. @@ -807,12 +676,9 @@ def list_routines( from_date: Optional[date] = None, to_date: Optional[date] = None, part_of_day: Optional[PartOfDay] = None, - user_id: UUID | None = Query(default=None), session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), ): - target_user_id = _resolve_target_user_id(current_user, user_id) - stmt = select(Routine).where(Routine.user_id == target_user_id) + stmt = select(Routine) if from_date is not None: stmt = stmt.where(Routine.routine_date >= from_date) if to_date is not None: @@ -822,12 +688,10 @@ def list_routines( routines = session.exec(stmt).all() routine_ids = [r.id for r in routines] - steps_by_routine: dict[UUID, list[RoutineStep]] = {} + steps_by_routine: dict = {} if routine_ids: all_steps = session.exec( - select(RoutineStep) - .where(col(RoutineStep.routine_id).in_(routine_ids)) - .where(RoutineStep.user_id == target_user_id) + select(RoutineStep).where(col(RoutineStep.routine_id).in_(routine_ids)) ).all() for step in all_steps: steps_by_routine.setdefault(step.routine_id, []).append(step) @@ -843,14 +707,8 @@ def list_routines( @router.post("", response_model=Routine, status_code=201) -def create_routine( - data: RoutineCreate, - user_id: UUID | None = Query(default=None), - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), -): - target_user_id = _resolve_target_user_id(current_user, user_id) - routine = Routine(id=uuid4(), user_id=target_user_id, **data.model_dump()) +def create_routine(data: RoutineCreate, session: Session = Depends(get_session)): + routine = Routine(id=uuid4(), **data.model_dump()) session.add(routine) session.commit() session.refresh(routine) @@ -866,35 +724,19 @@ def create_routine( def suggest_routine( data: SuggestRoutineRequest, session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), ): - target_user_id = current_user.user_id weekday = data.routine_date.weekday() - skin_ctx = _build_skin_context( - session, - target_user_id=target_user_id, - reference_date=data.routine_date, - ) - profile_ctx = build_user_profile_context( - session, - reference_date=data.routine_date, - current_user=current_user, - ) + skin_ctx = _build_skin_context(session, reference_date=data.routine_date) + profile_ctx = build_user_profile_context(session, reference_date=data.routine_date) upcoming_grooming_ctx = _build_upcoming_grooming_context( session, - target_user_id=target_user_id, start_date=data.routine_date, days=7, ) - history_ctx = _build_recent_history( - session, - target_user_id=target_user_id, - reference_date=data.routine_date, - ) + history_ctx = _build_recent_history(session, reference_date=data.routine_date) day_ctx = _build_day_context(data.leaving_home) available_products = _get_available_products( session, - current_user=current_user, time_filter=data.part_of_day.value, include_minoxidil=data.include_minoxidil_beard, ) @@ -910,9 +752,7 @@ def suggest_routine( # Phase 2: Use tiered context (summary mode for initial prompt) products_with_inventory = _get_products_with_inventory( - session, - current_user, - [p.id for p in available_products], + session, [p.id for p in available_products] ) products_ctx = build_products_context_summary_list( available_products, products_with_inventory @@ -1025,35 +865,22 @@ def suggest_routine( # Translation layer: Expand short_ids (8 chars) to full UUIDs (36 chars) steps = [] - raw_steps = parsed.get("steps", []) - if not isinstance(raw_steps, list): - raw_steps = [] - for s in raw_steps: - if not isinstance(s, dict): - continue + for s in parsed.get("steps", []): product_id_str = s.get("product_id") product_id_uuid = None - if isinstance(product_id_str, str) and product_id_str: + if product_id_str: # Expand short_id or validate full UUID - product_id_uuid = _expand_product_id(session, current_user, product_id_str) - - action_type = s.get("action_type") - action_notes = s.get("action_notes") - region = s.get("region") - why_this_step = s.get("why_this_step") - optional = s.get("optional") + product_id_uuid = _expand_product_id(session, product_id_str) steps.append( SuggestedStep( product_id=product_id_uuid, - action_type=_coerce_action_type(action_type), - action_notes=action_notes if isinstance(action_notes, str) else None, - region=region if isinstance(region, str) else None, - why_this_step=( - why_this_step if isinstance(why_this_step, str) else None - ), - optional=optional if isinstance(optional, bool) else None, + action_type=s.get("action_type") or None, + action_notes=s.get("action_notes"), + region=s.get("region"), + why_this_step=s.get("why_this_step"), + optional=s.get("optional"), ) ) @@ -1077,7 +904,6 @@ def suggest_routine( # Get skin snapshot for barrier state skin_snapshot = _get_latest_skin_snapshot_within_days( session, - target_user_id=target_user_id, reference_date=data.routine_date, ) @@ -1138,9 +964,7 @@ def suggest_routine( def suggest_batch( data: SuggestBatchRequest, session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), ): - target_user_id = current_user.user_id delta = (data.to_date - data.from_date).days + 1 if delta > 14: raise HTTPException( @@ -1152,37 +976,18 @@ def suggest_batch( weekdays = list( {(data.from_date + timedelta(days=i)).weekday() for i in range(delta)} ) - profile_ctx = build_user_profile_context( - session, - reference_date=data.from_date, - current_user=current_user, - ) - skin_ctx = _build_skin_context( - session, - target_user_id=target_user_id, - reference_date=data.from_date, - ) - grooming_ctx = _build_grooming_context( - session, - target_user_id=target_user_id, - weekdays=weekdays, - ) - history_ctx = _build_recent_history( - session, - target_user_id=target_user_id, - reference_date=data.from_date, - ) + profile_ctx = build_user_profile_context(session, reference_date=data.from_date) + skin_ctx = _build_skin_context(session, reference_date=data.from_date) + grooming_ctx = _build_grooming_context(session, weekdays=weekdays) + history_ctx = _build_recent_history(session, reference_date=data.from_date) batch_products = _get_available_products( session, - current_user=current_user, include_minoxidil=data.include_minoxidil_beard, ) # Phase 2: Use tiered context (summary mode for batch planning) products_with_inventory = _get_products_with_inventory( - session, - current_user, - [p.id for p in batch_products], + session, [p.id for p in batch_products] ) products_ctx = build_products_context_summary_list( batch_products, products_with_inventory @@ -1240,39 +1045,25 @@ def suggest_batch( except json.JSONDecodeError as e: raise HTTPException(status_code=502, detail=f"LLM returned invalid JSON: {e}") - def _parse_steps(raw_steps: list[dict[str, object]]) -> list[SuggestedStep]: + def _parse_steps(raw_steps: list) -> list[SuggestedStep]: """Parse steps and expand short_ids to full UUIDs.""" result = [] for s in raw_steps: product_id_str = s.get("product_id") product_id_uuid = None - if isinstance(product_id_str, str) and product_id_str: + if product_id_str: # Translation layer: expand short_id to full UUID - product_id_uuid = _expand_product_id( - session, - current_user, - product_id_str, - ) - - action_type = s.get("action_type") - action_notes = s.get("action_notes") - region = s.get("region") - why_this_step = s.get("why_this_step") - optional = s.get("optional") + product_id_uuid = _expand_product_id(session, product_id_str) result.append( SuggestedStep( product_id=product_id_uuid, - action_type=_coerce_action_type(action_type), - action_notes=( - action_notes if isinstance(action_notes, str) else None - ), - region=region if isinstance(region, str) else None, - why_this_step=( - why_this_step if isinstance(why_this_step, str) else None - ), - optional=optional if isinstance(optional, bool) else None, + action_type=s.get("action_type") or None, + action_notes=s.get("action_notes"), + region=s.get("region"), + why_this_step=s.get("why_this_step"), + optional=s.get("optional"), ) ) return result @@ -1295,7 +1086,6 @@ def suggest_batch( # Get skin snapshot for barrier state skin_snapshot = _get_latest_skin_snapshot_within_days( session, - target_user_id=target_user_id, reference_date=data.from_date, ) @@ -1350,36 +1140,15 @@ def suggest_batch( # Grooming-schedule GET must appear before /{routine_id} to avoid being shadowed @router.get("/grooming-schedule", response_model=list[GroomingSchedule]) -def list_grooming_schedule( - user_id: UUID | None = Query(default=None), - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), -): - target_user_id = _resolve_target_user_id(current_user, user_id) - return session.exec( - select(GroomingSchedule).where(GroomingSchedule.user_id == target_user_id) - ).all() +def list_grooming_schedule(session: Session = Depends(get_session)): + return session.exec(select(GroomingSchedule)).all() @router.get("/{routine_id}") -def get_routine( - routine_id: UUID, - user_id: UUID | None = Query(default=None), - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), -): - target_user_id = _resolve_target_user_id(current_user, user_id) - routine = _get_owned_or_admin_override( - session, - Routine, - routine_id, - current_user, - user_id, - ) +def get_routine(routine_id: UUID, session: Session = Depends(get_session)): + routine = get_or_404(session, Routine, routine_id) steps = session.exec( - select(RoutineStep) - .where(RoutineStep.routine_id == routine_id) - .where(RoutineStep.user_id == target_user_id) + select(RoutineStep).where(RoutineStep.routine_id == routine_id) ).all() data = routine.model_dump(mode="json") data["steps"] = [step.model_dump(mode="json") for step in steps] @@ -1390,17 +1159,9 @@ def get_routine( def update_routine( routine_id: UUID, data: RoutineUpdate, - user_id: UUID | None = Query(default=None), session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), ): - routine = _get_owned_or_admin_override( - session, - Routine, - routine_id, - current_user, - user_id, - ) + routine = get_or_404(session, Routine, routine_id) for key, value in data.model_dump(exclude_unset=True).items(): setattr(routine, key, value) session.add(routine) @@ -1410,19 +1171,8 @@ def update_routine( @router.delete("/{routine_id}", status_code=204) -def delete_routine( - routine_id: UUID, - user_id: UUID | None = Query(default=None), - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), -): - routine = _get_owned_or_admin_override( - session, - Routine, - routine_id, - current_user, - user_id, - ) +def delete_routine(routine_id: UUID, session: Session = Depends(get_session)): + routine = get_or_404(session, Routine, routine_id) session.delete(routine) session.commit() @@ -1436,28 +1186,10 @@ def delete_routine( def add_step( routine_id: UUID, data: RoutineStepCreate, - user_id: UUID | None = Query(default=None), session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), ): - target_user_id = _resolve_target_user_id(current_user, user_id) - _ = _get_owned_or_admin_override( - session, - Routine, - routine_id, - current_user, - user_id, - ) - if data.product_id and not is_product_visible( - session, data.product_id, current_user - ): - raise HTTPException(status_code=404, detail="Product not found") - step = RoutineStep( - id=uuid4(), - user_id=target_user_id, - routine_id=routine_id, - **data.model_dump(), - ) + get_or_404(session, Routine, routine_id) + step = RoutineStep(id=uuid4(), routine_id=routine_id, **data.model_dump()) session.add(step) session.commit() session.refresh(step) @@ -1468,21 +1200,9 @@ def add_step( def update_step( step_id: UUID, data: RoutineStepUpdate, - user_id: UUID | None = Query(default=None), session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), ): - step = _get_owned_or_admin_override( - session, - RoutineStep, - step_id, - current_user, - user_id, - ) - if data.product_id and not is_product_visible( - session, data.product_id, current_user - ): - raise HTTPException(status_code=404, detail="Product not found") + step = get_or_404(session, RoutineStep, step_id) for key, value in data.model_dump(exclude_unset=True).items(): setattr(step, key, value) session.add(step) @@ -1492,19 +1212,8 @@ def update_step( @router.delete("/steps/{step_id}", status_code=204) -def delete_step( - step_id: UUID, - user_id: UUID | None = Query(default=None), - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), -): - step = _get_owned_or_admin_override( - session, - RoutineStep, - step_id, - current_user, - user_id, - ) +def delete_step(step_id: UUID, session: Session = Depends(get_session)): + step = get_or_404(session, RoutineStep, step_id) session.delete(step) session.commit() @@ -1516,13 +1225,9 @@ def delete_step( @router.post("/grooming-schedule", response_model=GroomingSchedule, status_code=201) def create_grooming_schedule( - data: GroomingScheduleCreate, - user_id: UUID | None = Query(default=None), - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), + data: GroomingScheduleCreate, session: Session = Depends(get_session) ): - target_user_id = _resolve_target_user_id(current_user, user_id) - entry = GroomingSchedule(id=uuid4(), user_id=target_user_id, **data.model_dump()) + entry = GroomingSchedule(id=uuid4(), **data.model_dump()) session.add(entry) session.commit() session.refresh(entry) @@ -1533,17 +1238,9 @@ def create_grooming_schedule( def update_grooming_schedule( entry_id: UUID, data: GroomingScheduleUpdate, - user_id: UUID | None = Query(default=None), session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), ): - entry = _get_owned_or_admin_override( - session, - GroomingSchedule, - entry_id, - current_user, - user_id, - ) + entry = get_or_404(session, GroomingSchedule, entry_id) for key, value in data.model_dump(exclude_unset=True).items(): setattr(entry, key, value) session.add(entry) @@ -1553,18 +1250,7 @@ def update_grooming_schedule( @router.delete("/grooming-schedule/{entry_id}", status_code=204) -def delete_grooming_schedule( - entry_id: UUID, - user_id: UUID | None = Query(default=None), - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), -): - entry = _get_owned_or_admin_override( - session, - GroomingSchedule, - entry_id, - current_user, - user_id, - ) +def delete_grooming_schedule(entry_id: UUID, session: Session = Depends(get_session)): + entry = get_or_404(session, GroomingSchedule, entry_id) session.delete(entry) session.commit() diff --git a/backend/innercontext/api/skincare.py b/backend/innercontext/api/skincare.py index 4984bf9..730db1e 100644 --- a/backend/innercontext/api/skincare.py +++ b/backend/innercontext/api/skincare.py @@ -4,17 +4,15 @@ from datetime import date from typing import Optional from uuid import UUID, uuid4 -from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from google.genai import types as genai_types from pydantic import BaseModel as PydanticBase from pydantic import ValidationError from sqlmodel import Session, SQLModel, select from db import get_session -from innercontext.api.auth_deps import get_current_user from innercontext.api.llm_context import build_user_profile_context -from innercontext.api.utils import get_owned_or_404 -from innercontext.auth import CurrentUser +from innercontext.api.utils import get_or_404 from innercontext.llm import call_gemini, get_extraction_config from innercontext.models import ( SkinConditionSnapshot, @@ -28,7 +26,6 @@ from innercontext.models.enums import ( SkinTexture, SkinType, ) -from innercontext.models.enums import Role from innercontext.validators import PhotoValidator logger = logging.getLogger(__name__) @@ -138,34 +135,6 @@ OUTPUT (all fields optional): # --------------------------------------------------------------------------- -def _resolve_target_user_id( - current_user: CurrentUser, - user_id: UUID | None, -) -> UUID: - if user_id is None: - return current_user.user_id - if current_user.role is not Role.ADMIN: - raise HTTPException(status_code=403, detail="Admin role required") - return user_id - - -def _get_owned_or_admin_override( - session: Session, - snapshot_id: UUID, - current_user: CurrentUser, - user_id: UUID | None, -) -> SkinConditionSnapshot: - if user_id is None: - return get_owned_or_404( - session, SkinConditionSnapshot, snapshot_id, current_user - ) - target_user_id = _resolve_target_user_id(current_user, user_id) - snapshot = session.get(SkinConditionSnapshot, snapshot_id) - if snapshot is None or snapshot.user_id != target_user_id: - raise HTTPException(status_code=404, detail="SkinConditionSnapshot not found") - return snapshot - - MAX_IMAGE_BYTES = 5 * 1024 * 1024 # 5 MB @@ -173,7 +142,6 @@ MAX_IMAGE_BYTES = 5 * 1024 * 1024 # 5 MB async def analyze_skin_photos( photos: list[UploadFile] = File(...), session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), ) -> SkinPhotoAnalysisResponse: if not (1 <= len(photos) <= 3): raise HTTPException(status_code=422, detail="Send between 1 and 3 photos.") @@ -206,11 +174,7 @@ async def analyze_skin_photos( ) parts.append( genai_types.Part.from_text( - text=build_user_profile_context( - session, - reference_date=date.today(), - current_user=current_user, - ) + text=build_user_profile_context(session, reference_date=date.today()) ) ) @@ -260,14 +224,9 @@ def list_snapshots( from_date: Optional[date] = None, to_date: Optional[date] = None, overall_state: Optional[OverallSkinState] = None, - user_id: UUID | None = Query(default=None), session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), ): - target_user_id = _resolve_target_user_id(current_user, user_id) - stmt = select(SkinConditionSnapshot).where( - SkinConditionSnapshot.user_id == target_user_id - ) + stmt = select(SkinConditionSnapshot) if from_date is not None: stmt = stmt.where(SkinConditionSnapshot.snapshot_date >= from_date) if to_date is not None: @@ -278,18 +237,8 @@ def list_snapshots( @router.post("", response_model=SkinConditionSnapshotPublic, status_code=201) -def create_snapshot( - data: SnapshotCreate, - user_id: UUID | None = Query(default=None), - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), -): - target_user_id = _resolve_target_user_id(current_user, user_id) - snapshot = SkinConditionSnapshot( - id=uuid4(), - user_id=target_user_id, - **data.model_dump(), - ) +def create_snapshot(data: SnapshotCreate, session: Session = Depends(get_session)): + snapshot = SkinConditionSnapshot(id=uuid4(), **data.model_dump()) session.add(snapshot) session.commit() session.refresh(snapshot) @@ -297,34 +246,17 @@ def create_snapshot( @router.get("/{snapshot_id}", response_model=SkinConditionSnapshotPublic) -def get_snapshot( - snapshot_id: UUID, - user_id: UUID | None = Query(default=None), - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), -): - return _get_owned_or_admin_override( - session, - snapshot_id, - current_user, - user_id, - ) +def get_snapshot(snapshot_id: UUID, session: Session = Depends(get_session)): + return get_or_404(session, SkinConditionSnapshot, snapshot_id) @router.patch("/{snapshot_id}", response_model=SkinConditionSnapshotPublic) def update_snapshot( snapshot_id: UUID, data: SnapshotUpdate, - user_id: UUID | None = Query(default=None), session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), ): - snapshot = _get_owned_or_admin_override( - session, - snapshot_id, - current_user, - user_id, - ) + snapshot = get_or_404(session, SkinConditionSnapshot, snapshot_id) for key, value in data.model_dump(exclude_unset=True).items(): setattr(snapshot, key, value) session.add(snapshot) @@ -334,17 +266,7 @@ def update_snapshot( @router.delete("/{snapshot_id}", status_code=204) -def delete_snapshot( - snapshot_id: UUID, - user_id: UUID | None = Query(default=None), - session: Session = Depends(get_session), - current_user: CurrentUser = Depends(get_current_user), -): - snapshot = _get_owned_or_admin_override( - session, - snapshot_id, - current_user, - user_id, - ) +def delete_snapshot(snapshot_id: UUID, session: Session = Depends(get_session)): + snapshot = get_or_404(session, SkinConditionSnapshot, snapshot_id) session.delete(snapshot) session.commit() diff --git a/backend/innercontext/api/utils.py b/backend/innercontext/api/utils.py index af40248..6321f07 100644 --- a/backend/innercontext/api/utils.py +++ b/backend/innercontext/api/utils.py @@ -3,18 +3,6 @@ from typing import TypeVar from fastapi import HTTPException from sqlmodel import Session -from innercontext.api.authz import ( - get_owned_or_404 as authz_get_owned_or_404, -) -from innercontext.api.authz import ( - get_owned_or_404_admin_override as authz_get_owned_or_404_admin_override, -) -from innercontext.api.authz import list_owned as authz_list_owned -from innercontext.api.authz import ( - list_owned_admin_override as authz_list_owned_admin_override, -) -from innercontext.auth import CurrentUser - _T = TypeVar("_T") @@ -23,37 +11,3 @@ def get_or_404(session: Session, model: type[_T], record_id: object) -> _T: if obj is None: raise HTTPException(status_code=404, detail=f"{model.__name__} not found") return obj - - -def get_owned_or_404( - session: Session, - model: type[_T], - record_id: object, - current_user: CurrentUser, -) -> _T: - return authz_get_owned_or_404(session, model, record_id, current_user) - - -def get_owned_or_404_admin_override( - session: Session, - model: type[_T], - record_id: object, - current_user: CurrentUser, -) -> _T: - return authz_get_owned_or_404_admin_override( - session, model, record_id, current_user - ) - - -def list_owned( - session: Session, model: type[_T], current_user: CurrentUser -) -> list[_T]: - return authz_list_owned(session, model, current_user) - - -def list_owned_admin_override( - session: Session, - model: type[_T], - current_user: CurrentUser, -) -> list[_T]: - return authz_list_owned_admin_override(session, model, current_user) diff --git a/backend/innercontext/auth.py b/backend/innercontext/auth.py deleted file mode 100644 index b672d43..0000000 --- a/backend/innercontext/auth.py +++ /dev/null @@ -1,384 +0,0 @@ -from __future__ import annotations - -import os -import time -from dataclasses import dataclass, field -from datetime import UTC, datetime -from functools import lru_cache -from threading import Lock -from typing import Any, Mapping -from uuid import UUID - -import httpx -import jwt -from jwt import InvalidTokenError, PyJWKSet -from sqlmodel import Session, select - -from innercontext.models import HouseholdMembership, HouseholdRole, Role, User - -_DISCOVERY_PATH = "/.well-known/openid-configuration" -_SUPPORTED_ALGORITHMS = frozenset( - {"RS256", "RS384", "RS512", "ES256", "ES384", "ES512"} -) - - -class AuthConfigurationError(RuntimeError): - pass - - -class TokenValidationError(ValueError): - pass - - -@dataclass(frozen=True, slots=True) -class AuthSettings: - issuer: str - client_id: str - audiences: tuple[str, ...] - discovery_url: str - jwks_url: str | None - groups_claim: str - admin_groups: tuple[str, ...] - member_groups: tuple[str, ...] - jwks_cache_ttl_seconds: int - http_timeout_seconds: float - clock_skew_seconds: int - - -@dataclass(frozen=True, slots=True) -class TokenClaims: - issuer: str - subject: str - audience: tuple[str, ...] - expires_at: datetime - groups: tuple[str, ...] = () - email: str | None = None - name: str | None = None - preferred_username: str | None = None - raw_claims: Mapping[str, Any] = field(default_factory=dict, repr=False) - - @classmethod - def from_payload( - cls, payload: Mapping[str, Any], settings: AuthSettings - ) -> "TokenClaims": - audience = payload.get("aud") - if isinstance(audience, str): - audiences = (audience,) - elif isinstance(audience, list): - audiences = tuple(str(item) for item in audience) - else: - audiences = () - - groups = _normalize_groups(payload.get(settings.groups_claim)) - exp = payload.get("exp") - if not isinstance(exp, (int, float)): - raise TokenValidationError("Access token missing exp claim") - - return cls( - issuer=str(payload["iss"]), - subject=str(payload["sub"]), - audience=audiences, - expires_at=datetime.fromtimestamp(exp, tz=UTC), - groups=groups, - email=_optional_str(payload.get("email")), - name=_optional_str(payload.get("name")), - preferred_username=_optional_str(payload.get("preferred_username")), - raw_claims=dict(payload), - ) - - -@dataclass(frozen=True, slots=True) -class IdentityData: - issuer: str - subject: str - email: str | None = None - name: str | None = None - preferred_username: str | None = None - groups: tuple[str, ...] = () - - @classmethod - def from_claims(cls, claims: TokenClaims) -> "IdentityData": - return cls( - issuer=claims.issuer, - subject=claims.subject, - email=claims.email, - name=claims.name, - preferred_username=claims.preferred_username, - groups=claims.groups, - ) - - -@dataclass(frozen=True, slots=True) -class CurrentHouseholdMembership: - household_id: UUID - role: HouseholdRole - - -@dataclass(frozen=True, slots=True) -class CurrentUser: - user_id: UUID - role: Role - identity: IdentityData - claims: TokenClaims = field(repr=False) - household_membership: CurrentHouseholdMembership | None = None - - -def _split_csv(value: str | None) -> tuple[str, ...]: - if value is None: - return () - return tuple(item.strip() for item in value.split(",") if item.strip()) - - -def _optional_str(value: Any) -> str | None: - if value is None: - return None - if isinstance(value, str): - return value - return str(value) - - -def _normalize_groups(value: Any) -> tuple[str, ...]: - if value is None: - return () - if isinstance(value, str): - return (value,) - if isinstance(value, list): - return tuple(str(item) for item in value) - if isinstance(value, tuple): - return tuple(str(item) for item in value) - return (str(value),) - - -def _required_env(name: str) -> str: - value = os.environ.get(name) - if value: - return value - raise AuthConfigurationError(f"Missing required auth environment variable: {name}") - - -@lru_cache -def get_auth_settings() -> AuthSettings: - issuer = _required_env("OIDC_ISSUER") - client_id = _required_env("OIDC_CLIENT_ID") - audiences = _split_csv(os.environ.get("OIDC_AUDIENCE")) or (client_id,) - discovery_url = os.environ.get("OIDC_DISCOVERY_URL") or ( - issuer.rstrip("/") + _DISCOVERY_PATH - ) - - return AuthSettings( - issuer=issuer, - client_id=client_id, - audiences=audiences, - discovery_url=discovery_url, - jwks_url=os.environ.get("OIDC_JWKS_URL"), - groups_claim=os.environ.get("OIDC_GROUPS_CLAIM", "groups"), - admin_groups=_split_csv(os.environ.get("OIDC_ADMIN_GROUPS")), - member_groups=_split_csv(os.environ.get("OIDC_MEMBER_GROUPS")), - jwks_cache_ttl_seconds=int( - os.environ.get("OIDC_JWKS_CACHE_TTL_SECONDS", "300") - ), - http_timeout_seconds=float(os.environ.get("OIDC_HTTP_TIMEOUT_SECONDS", "5")), - clock_skew_seconds=int(os.environ.get("OIDC_CLOCK_SKEW_SECONDS", "30")), - ) - - -class CachedJwksClient: - def __init__(self, settings: AuthSettings): - self._settings = settings - self._lock = Lock() - self._jwks: PyJWKSet | None = None - self._jwks_fetched_at = 0.0 - self._discovery_jwks_url: str | None = None - self._discovery_fetched_at = 0.0 - - def get_signing_key(self, kid: str) -> Any: - with self._lock: - jwks = self._get_jwks_locked() - key = self._find_key(jwks, kid) - if key is not None: - return key - - self._refresh_jwks_locked( - force_discovery_refresh=self._settings.jwks_url is None - ) - if self._jwks is None: - raise TokenValidationError("JWKS cache is empty") - - key = self._find_key(self._jwks, kid) - if key is None: - raise TokenValidationError(f"No signing key found for kid '{kid}'") - return key - - def _get_jwks_locked(self) -> PyJWKSet: - if self._jwks is None or self._is_stale(self._jwks_fetched_at): - self._refresh_jwks_locked(force_discovery_refresh=False) - if self._jwks is None: - raise TokenValidationError("Unable to load JWKS") - return self._jwks - - def _refresh_jwks_locked(self, force_discovery_refresh: bool) -> None: - jwks_url = self._resolve_jwks_url_locked(force_refresh=force_discovery_refresh) - data = self._fetch_json(jwks_url) - try: - self._jwks = PyJWKSet.from_dict(data) - except Exception as exc: - raise TokenValidationError( - "OIDC provider returned an invalid JWKS payload" - ) from exc - self._jwks_fetched_at = time.monotonic() - - def _resolve_jwks_url_locked(self, force_refresh: bool) -> str: - if self._settings.jwks_url: - return self._settings.jwks_url - - if ( - force_refresh - or self._discovery_jwks_url is None - or self._is_stale(self._discovery_fetched_at) - ): - discovery = self._fetch_json(self._settings.discovery_url) - jwks_uri = discovery.get("jwks_uri") - if not isinstance(jwks_uri, str) or not jwks_uri: - raise TokenValidationError("OIDC discovery document missing jwks_uri") - self._discovery_jwks_url = jwks_uri - self._discovery_fetched_at = time.monotonic() - - if self._discovery_jwks_url is None: - raise TokenValidationError("Unable to resolve JWKS URL") - return self._discovery_jwks_url - - def _fetch_json(self, url: str) -> dict[str, Any]: - try: - response = httpx.get(url, timeout=self._settings.http_timeout_seconds) - response.raise_for_status() - except httpx.HTTPError as exc: - raise TokenValidationError( - f"Failed to fetch OIDC metadata from {url}" - ) from exc - - data = response.json() - if not isinstance(data, dict): - raise TokenValidationError( - f"OIDC metadata from {url} must be a JSON object" - ) - return data - - def _is_stale(self, fetched_at: float) -> bool: - return (time.monotonic() - fetched_at) >= self._settings.jwks_cache_ttl_seconds - - @staticmethod - def _find_key(jwks: PyJWKSet, kid: str) -> Any | None: - for jwk in jwks.keys: - if jwk.key_id == kid: - return jwk.key - return None - - -@lru_cache -def get_jwks_client() -> CachedJwksClient: - return CachedJwksClient(get_auth_settings()) - - -def reset_auth_caches() -> None: - get_auth_settings.cache_clear() - get_jwks_client.cache_clear() - - -def validate_access_token(token: str) -> TokenClaims: - settings = get_auth_settings() - - try: - unverified_header = jwt.get_unverified_header(token) - except InvalidTokenError as exc: - raise TokenValidationError("Malformed access token header") from exc - - kid = unverified_header.get("kid") - algorithm = unverified_header.get("alg") - if not isinstance(kid, str) or not kid: - raise TokenValidationError("Access token missing kid header") - if not isinstance(algorithm, str) or algorithm not in _SUPPORTED_ALGORITHMS: - raise TokenValidationError("Access token uses an unsupported signing algorithm") - - signing_key = get_jwks_client().get_signing_key(kid) - - try: - payload = jwt.decode( - token, - key=signing_key, - algorithms=[algorithm], - audience=settings.audiences, - issuer=settings.issuer, - options={"require": ["exp", "iss", "sub"]}, - leeway=settings.clock_skew_seconds, - ) - except InvalidTokenError as exc: - raise TokenValidationError("Invalid access token") from exc - - return TokenClaims.from_payload(payload, settings) - - -def sync_current_user( - session: Session, - claims: TokenClaims, - identity: IdentityData | None = None, -) -> CurrentUser: - effective_identity = identity or IdentityData.from_claims(claims) - statement = select(User).where( - User.oidc_issuer == effective_identity.issuer, - User.oidc_subject == effective_identity.subject, - ) - user = session.exec(statement).first() - existing_role = user.role if user is not None else None - resolved_role = resolve_role(effective_identity.groups, existing_role=existing_role) - needs_commit = False - - if user is None: - user = User( - oidc_issuer=effective_identity.issuer, - oidc_subject=effective_identity.subject, - role=resolved_role, - ) - session.add(user) - needs_commit = True - elif user.role != resolved_role: - user.role = resolved_role - session.add(user) - needs_commit = True - - if needs_commit: - session.commit() - session.refresh(user) - - membership = session.exec( - select(HouseholdMembership).where(HouseholdMembership.user_id == user.id) - ).first() - - household_membership = None - if membership is not None: - household_membership = CurrentHouseholdMembership( - household_id=membership.household_id, - role=membership.role, - ) - - return CurrentUser( - user_id=user.id, - role=user.role, - identity=effective_identity, - claims=claims, - household_membership=household_membership, - ) - - -def resolve_role(groups: tuple[str, ...], existing_role: Role | None = None) -> Role: - settings = get_auth_settings() - if groups: - group_set = set(groups) - if settings.admin_groups and group_set.intersection(settings.admin_groups): - return Role.ADMIN - if settings.member_groups: - if group_set.intersection(settings.member_groups): - return Role.MEMBER - return Role.MEMBER - return Role.MEMBER - - return existing_role or Role.MEMBER diff --git a/backend/innercontext/services/pricing_jobs.py b/backend/innercontext/services/pricing_jobs.py index 9d6a24e..9e9c9dd 100644 --- a/backend/innercontext/services/pricing_jobs.py +++ b/backend/innercontext/services/pricing_jobs.py @@ -1,5 +1,4 @@ from datetime import datetime -from uuid import UUID from sqlmodel import Session, col, select @@ -67,53 +66,9 @@ def _apply_pricing_snapshot(session: Session, computed_at: datetime) -> int: return len(products) -def _scope_user_id(scope: str) -> UUID | None: - prefix = "user:" - if not scope.startswith(prefix): - return None - raw_user_id = scope[len(prefix) :].strip() - if not raw_user_id: - return None - try: - return UUID(raw_user_id) - except ValueError: - return None - - -def _apply_pricing_snapshot_for_scope( - session: Session, - *, - computed_at: datetime, - scope: str, -) -> int: - from innercontext.api.products import _compute_pricing_outputs - - scoped_user_id = _scope_user_id(scope) - stmt = select(Product) - if scoped_user_id is not None: - stmt = stmt.where(Product.user_id == scoped_user_id) - products = list(session.exec(stmt).all()) - pricing_outputs = _compute_pricing_outputs(products) - - for product in products: - tier, price_per_use_pln, tier_source = pricing_outputs.get( - product.id, (None, None, None) - ) - product.price_tier = tier - product.price_per_use_pln = price_per_use_pln - product.price_tier_source = tier_source - product.pricing_computed_at = computed_at - - return len(products) - - def process_pricing_job(session: Session, job: PricingRecalcJob) -> int: try: - updated_count = _apply_pricing_snapshot_for_scope( - session, - computed_at=utc_now(), - scope=job.scope, - ) + updated_count = _apply_pricing_snapshot(session, computed_at=utc_now()) job.status = "succeeded" job.finished_at = utc_now() job.error = None diff --git a/backend/main.py b/backend/main.py index 3d74443..10fb73b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,19 +1,17 @@ -from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from typing import AsyncIterator from dotenv import load_dotenv -_ = load_dotenv() # load .env before db.py reads DATABASE_URL +load_dotenv() # load .env before db.py reads DATABASE_URL -from fastapi import Depends, FastAPI # noqa: E402 +from fastapi import FastAPI # noqa: E402 from fastapi.middleware.cors import CORSMiddleware # noqa: E402 from sqlmodel import Session # noqa: E402 from db import create_db_and_tables, engine # noqa: E402 from innercontext.api import ( # noqa: E402 - admin, ai_logs, - auth, health, inventory, products, @@ -21,16 +19,15 @@ from innercontext.api import ( # noqa: E402 routines, skincare, ) -from innercontext.api.auth_deps import get_current_user # noqa: E402 from innercontext.services.pricing_jobs import enqueue_pricing_recalc # noqa: E402 @asynccontextmanager -async def lifespan(_app: FastAPI) -> AsyncIterator[None]: +async def lifespan(app: FastAPI) -> AsyncIterator[None]: create_db_and_tables() try: with Session(engine) as session: - _ = enqueue_pricing_recalc(session) + enqueue_pricing_recalc(session) session.commit() except Exception as exc: # pragma: no cover print(f"[startup] failed to enqueue pricing recalculation job: {exc}") @@ -50,52 +47,13 @@ app.add_middleware( allow_headers=["*"], ) -protected = [Depends(get_current_user)] - -app.include_router(auth.router, prefix="/auth", tags=["auth"]) -app.include_router(admin.router, prefix="/admin", tags=["admin"]) -app.include_router( - products.router, - prefix="/products", - tags=["products"], - dependencies=protected, -) -app.include_router( - inventory.router, - prefix="/inventory", - tags=["inventory"], - dependencies=protected, -) -app.include_router( - profile.router, - prefix="/profile", - tags=["profile"], - dependencies=protected, -) -app.include_router( - health.router, - prefix="/health", - tags=["health"], - dependencies=protected, -) -app.include_router( - routines.router, - prefix="/routines", - tags=["routines"], - dependencies=protected, -) -app.include_router( - skincare.router, - prefix="/skincare", - tags=["skincare"], - dependencies=protected, -) -app.include_router( - ai_logs.router, - prefix="/ai-logs", - tags=["ai-logs"], - dependencies=protected, -) +app.include_router(products.router, prefix="/products", tags=["products"]) +app.include_router(inventory.router, prefix="/inventory", tags=["inventory"]) +app.include_router(profile.router, prefix="/profile", tags=["profile"]) +app.include_router(health.router, prefix="/health", tags=["health"]) +app.include_router(routines.router, prefix="/routines", tags=["routines"]) +app.include_router(skincare.router, prefix="/skincare", tags=["skincare"]) +app.include_router(ai_logs.router, prefix="/ai-logs", tags=["ai-logs"]) @app.get("/health-check") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 6b9a55c..eeddb55 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -8,7 +8,6 @@ dependencies = [ "alembic>=1.14", "fastapi>=0.132.0", "google-genai>=1.65.0", - "pyjwt[crypto]>=2.10.1", "psycopg[binary]>=3.3.3", "python-dotenv>=1.2.1", "python-multipart>=0.0.22", diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index b8831bb..3c4f465 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,6 +1,4 @@ import os -from datetime import UTC, datetime, timedelta -from uuid import uuid4 # Must be set before importing db (which calls create_engine at module level) os.environ.setdefault("DATABASE_URL", "sqlite://") @@ -12,9 +10,6 @@ from sqlmodel.pool import StaticPool import db as db_module from db import get_session -from innercontext.api.auth_deps import get_current_user -from innercontext.auth import CurrentUser, IdentityData, TokenClaims -from innercontext.models import Role from main import app @@ -37,35 +32,13 @@ def session(monkeypatch): @pytest.fixture() -def current_user() -> CurrentUser: - claims = TokenClaims( - issuer="https://auth.test", - subject="test-user", - audience=("innercontext-web",), - expires_at=datetime.now(UTC) + timedelta(hours=1), - groups=("innercontext-admin",), - raw_claims={"iss": "https://auth.test", "sub": "test-user"}, - ) - return CurrentUser( - user_id=uuid4(), - role=Role.ADMIN, - identity=IdentityData.from_claims(claims), - claims=claims, - ) - - -@pytest.fixture() -def client(session, monkeypatch, current_user): +def client(session, monkeypatch): """TestClient using the per-test session for every request.""" def _override(): yield session - def _current_user_override(): - return current_user - app.dependency_overrides[get_session] = _override - app.dependency_overrides[get_current_user] = _current_user_override with TestClient(app) as c: yield c app.dependency_overrides.clear() diff --git a/backend/tests/test_admin_households.py b/backend/tests/test_admin_households.py deleted file mode 100644 index 31b1f16..0000000 --- a/backend/tests/test_admin_households.py +++ /dev/null @@ -1,354 +0,0 @@ -from __future__ import annotations - -from collections.abc import Generator -from datetime import UTC, datetime, timedelta -from typing import cast -from uuid import UUID, uuid4 - -import pytest -from fastapi.testclient import TestClient -from sqlmodel import Session - -from db import get_session -from innercontext.api.auth_deps import get_current_user -from innercontext.auth import ( - CurrentHouseholdMembership, - CurrentUser, - IdentityData, - TokenClaims, -) -from innercontext.models import ( - Household, - HouseholdMembership, - HouseholdRole, - Role, - User, -) -from main import app - - -def _current_user( - user_id: UUID, - *, - role: Role = Role.ADMIN, - household_id: UUID | None = None, -) -> CurrentUser: - claims = TokenClaims( - issuer="https://auth.test", - subject=str(user_id), - audience=("innercontext-web",), - expires_at=datetime.now(UTC) + timedelta(hours=1), - groups=( - ("innercontext-admin",) if role is Role.ADMIN else ("innercontext-member",) - ), - raw_claims={"iss": "https://auth.test", "sub": str(user_id)}, - ) - membership = None - if household_id is not None: - membership = CurrentHouseholdMembership( - household_id=household_id, - role=HouseholdRole.MEMBER, - ) - return CurrentUser( - user_id=user_id, - role=role, - identity=IdentityData.from_claims(claims), - claims=claims, - household_membership=membership, - ) - - -def _create_user( - session: Session, - *, - role: Role = Role.MEMBER, - subject: str | None = None, -) -> User: - user = User( - oidc_issuer="https://auth.test", - oidc_subject=subject or str(uuid4()), - role=role, - ) - session.add(user) - session.commit() - session.refresh(user) - return user - - -def _create_household(session: Session) -> Household: - household = Household() - session.add(household) - session.commit() - session.refresh(household) - return household - - -def _create_membership( - session: Session, - *, - user_id: UUID, - household_id: UUID, - role: HouseholdRole = HouseholdRole.MEMBER, -) -> HouseholdMembership: - membership = HouseholdMembership( - user_id=user_id, - household_id=household_id, - role=role, - ) - session.add(membership) - session.commit() - session.refresh(membership) - return membership - - -@pytest.fixture() -def auth_client( - session: Session, -) -> Generator[tuple[TestClient, dict[str, CurrentUser]], None, None]: - auth_state = {"current_user": _current_user(uuid4(), role=Role.ADMIN)} - - def _session_override(): - yield session - - def _current_user_override(): - return auth_state["current_user"] - - app.dependency_overrides[get_session] = _session_override - app.dependency_overrides[get_current_user] = _current_user_override - with TestClient(app) as client: - yield client, auth_state - app.dependency_overrides.clear() - - -def test_list_users_returns_local_users_with_memberships( - auth_client: tuple[TestClient, dict[str, CurrentUser]], - session: Session, -): - client, _ = auth_client - - unassigned_user = _create_user(session, subject="member-a") - assigned_user = _create_user(session, subject="member-b") - household = _create_household(session) - membership = _create_membership( - session, - user_id=assigned_user.id, - household_id=household.id, - role=HouseholdRole.OWNER, - ) - - response = client.get("/admin/users") - - assert response.status_code == 200 - response_users = cast(list[dict[str, object]], response.json()) - users = {item["id"]: item for item in response_users} - assert users[str(unassigned_user.id)]["household_membership"] is None - assert users[str(assigned_user.id)]["household_membership"] == { - "id": str(membership.id), - "user_id": str(assigned_user.id), - "household_id": str(household.id), - "role": "owner", - "created_at": membership.created_at.isoformat(), - "updated_at": membership.updated_at.isoformat(), - } - - -def test_create_household_returns_new_household( - auth_client: tuple[TestClient, dict[str, CurrentUser]], - session: Session, -): - client, _ = auth_client - - response = client.post("/admin/households") - - assert response.status_code == 201 - payload = cast(dict[str, object], response.json()) - household_id = UUID(cast(str, payload["id"])) - created = session.get(Household, household_id) - assert created is not None - - -def test_assign_member_creates_membership( - auth_client: tuple[TestClient, dict[str, CurrentUser]], - session: Session, -): - client, _ = auth_client - - user = _create_user(session) - household = _create_household(session) - - response = client.post( - f"/admin/households/{household.id}/members", - json={"user_id": str(user.id), "role": "owner"}, - ) - - assert response.status_code == 201 - payload = cast(dict[str, object], response.json()) - assert payload["user_id"] == str(user.id) - assert payload["household_id"] == str(household.id) - assert payload["role"] == "owner" - - membership = session.get(HouseholdMembership, UUID(cast(str, payload["id"]))) - assert membership is not None - assert membership.user_id == user.id - assert membership.household_id == household.id - assert membership.role is HouseholdRole.OWNER - - -def test_assign_member_rejects_already_assigned_user( - auth_client: tuple[TestClient, dict[str, CurrentUser]], - session: Session, -): - client, _ = auth_client - - user = _create_user(session) - current_household = _create_household(session) - target_household = _create_household(session) - _ = _create_membership(session, user_id=user.id, household_id=current_household.id) - - response = client.post( - f"/admin/households/{target_household.id}/members", - json={"user_id": str(user.id)}, - ) - - assert response.status_code == 409 - assert response.json()["detail"] == "User already belongs to a household" - - -def test_assign_member_rejects_unsynced_user( - auth_client: tuple[TestClient, dict[str, CurrentUser]], - session: Session, -): - client, _ = auth_client - household = _create_household(session) - user_id = uuid4() - - response = client.post( - f"/admin/households/{household.id}/members", - json={"user_id": str(user_id)}, - ) - - assert response.status_code == 404 - assert response.json()["detail"] == "User not found" - - -def test_move_member_moves_user_between_households( - auth_client: tuple[TestClient, dict[str, CurrentUser]], - session: Session, -): - client, _ = auth_client - - user = _create_user(session) - source_household = _create_household(session) - target_household = _create_household(session) - membership = _create_membership( - session, - user_id=user.id, - household_id=source_household.id, - role=HouseholdRole.OWNER, - ) - - response = client.patch( - f"/admin/households/{target_household.id}/members/{user.id}" - ) - - assert response.status_code == 200 - payload = cast(dict[str, object], response.json()) - assert payload["id"] == str(membership.id) - assert payload["household_id"] == str(target_household.id) - assert payload["role"] == "owner" - - session.refresh(membership) - assert membership.household_id == target_household.id - - -def test_move_member_rejects_user_without_membership( - auth_client: tuple[TestClient, dict[str, CurrentUser]], - session: Session, -): - client, _ = auth_client - - user = _create_user(session) - target_household = _create_household(session) - - response = client.patch( - f"/admin/households/{target_household.id}/members/{user.id}" - ) - - assert response.status_code == 404 - assert response.json()["detail"] == "HouseholdMembership not found" - - -def test_move_member_rejects_same_household_target( - auth_client: tuple[TestClient, dict[str, CurrentUser]], - session: Session, -): - client, _ = auth_client - - user = _create_user(session) - household = _create_household(session) - _ = _create_membership(session, user_id=user.id, household_id=household.id) - - response = client.patch(f"/admin/households/{household.id}/members/{user.id}") - - assert response.status_code == 409 - assert response.json()["detail"] == "User already belongs to this household" - - -def test_remove_membership_deletes_membership( - auth_client: tuple[TestClient, dict[str, CurrentUser]], - session: Session, -): - client, _ = auth_client - - user = _create_user(session) - household = _create_household(session) - membership = _create_membership(session, user_id=user.id, household_id=household.id) - - response = client.delete(f"/admin/households/{household.id}/members/{user.id}") - - assert response.status_code == 204 - assert session.get(HouseholdMembership, membership.id) is None - - -def test_remove_membership_requires_matching_household( - auth_client: tuple[TestClient, dict[str, CurrentUser]], - session: Session, -): - client, _ = auth_client - - user = _create_user(session) - household = _create_household(session) - other_household = _create_household(session) - _ = _create_membership(session, user_id=user.id, household_id=household.id) - - response = client.delete( - f"/admin/households/{other_household.id}/members/{user.id}" - ) - - assert response.status_code == 404 - assert response.json()["detail"] == "HouseholdMembership not found" - - -@pytest.mark.parametrize( - ("method", "path", "json_body"), - [ - ("get", "/admin/users", None), - ("post", "/admin/households", None), - ("post", f"/admin/households/{uuid4()}/members", {"user_id": str(uuid4())}), - ("patch", f"/admin/households/{uuid4()}/members/{uuid4()}", None), - ("delete", f"/admin/households/{uuid4()}/members/{uuid4()}", None), - ], -) -def test_admin_household_routes_forbidden_for_member( - auth_client: tuple[TestClient, dict[str, CurrentUser]], - method: str, - path: str, - json_body: dict[str, str] | None, -): - client, auth_state = auth_client - auth_state["current_user"] = _current_user(uuid4(), role=Role.MEMBER) - - response = client.request(method, path, json=json_body) - - assert response.status_code == 403 - assert response.json()["detail"] == "Admin role required" diff --git a/backend/tests/test_ai_logs.py b/backend/tests/test_ai_logs.py index 8fe2a60..47a168e 100644 --- a/backend/tests/test_ai_logs.py +++ b/backend/tests/test_ai_logs.py @@ -4,13 +4,12 @@ from typing import Any, cast from innercontext.models.ai_log import AICallLog -def test_list_ai_logs_normalizes_tool_trace_string(client, session, current_user): +def test_list_ai_logs_normalizes_tool_trace_string(client, session): log = AICallLog( id=uuid.uuid4(), endpoint="routines/suggest", model="gemini-3-flash-preview", success=True, - user_id=current_user.user_id, ) log.tool_trace = cast( Any, @@ -27,13 +26,12 @@ def test_list_ai_logs_normalizes_tool_trace_string(client, session, current_user assert data[0]["tool_trace"]["events"][0]["function"] == "get_product_inci" -def test_get_ai_log_normalizes_tool_trace_string(client, session, current_user): +def test_get_ai_log_normalizes_tool_trace_string(client, session): log = AICallLog( id=uuid.uuid4(), endpoint="routines/suggest", model="gemini-3-flash-preview", success=True, - user_id=current_user.user_id, ) log.tool_trace = cast(Any, '{"mode":"function_tools","round":1}') session.add(log) diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py deleted file mode 100644 index 0ed16a5..0000000 --- a/backend/tests/test_auth.py +++ /dev/null @@ -1,275 +0,0 @@ -from __future__ import annotations - -import json -from datetime import UTC, datetime, timedelta -from uuid import UUID, uuid4 - -import jwt -import pytest -from cryptography.hazmat.primitives.asymmetric import rsa -from fastapi import HTTPException -from fastapi.testclient import TestClient -from jwt import algorithms -from sqlmodel import Session, SQLModel, create_engine -from sqlmodel.pool import StaticPool - -import db as db_module -from db import get_session -from innercontext.api.auth_deps import require_admin -from innercontext.auth import ( - CurrentHouseholdMembership, - CurrentUser, - IdentityData, - TokenClaims, - reset_auth_caches, - validate_access_token, -) -from innercontext.models import ( - Household, - HouseholdMembership, - HouseholdRole, - Role, - User, -) -from main import app - - -class _MockResponse: - def __init__(self, payload: dict[str, object], status_code: int = 200): - self._payload = payload - self.status_code = status_code - - def raise_for_status(self) -> None: - if self.status_code >= 400: - raise RuntimeError(f"unexpected status {self.status_code}") - - def json(self) -> dict[str, object]: - return self._payload - - -@pytest.fixture() -def auth_env(monkeypatch): - monkeypatch.setenv("OIDC_ISSUER", "https://auth.example.test") - monkeypatch.setenv("OIDC_CLIENT_ID", "innercontext-web") - monkeypatch.setenv( - "OIDC_DISCOVERY_URL", - "https://auth.example.test/.well-known/openid-configuration", - ) - monkeypatch.setenv("OIDC_ADMIN_GROUPS", "innercontext-admin") - monkeypatch.setenv("OIDC_MEMBER_GROUPS", "innercontext-member") - monkeypatch.setenv("OIDC_JWKS_CACHE_TTL_SECONDS", "3600") - reset_auth_caches() - yield - reset_auth_caches() - - -@pytest.fixture() -def rsa_keypair(): - private_key = rsa.generate_private_key( - public_exponent=65537, - key_size=2048, - ) - return private_key, private_key.public_key() - - -@pytest.fixture() -def auth_session(monkeypatch): - engine = create_engine( - "sqlite://", - connect_args={"check_same_thread": False}, - poolclass=StaticPool, - ) - monkeypatch.setattr(db_module, "engine", engine) - import innercontext.models # noqa: F401 - - SQLModel.metadata.create_all(engine) - with Session(engine) as session: - yield session - - -@pytest.fixture() -def auth_client(auth_session): - def _override(): - yield auth_session - - app.dependency_overrides[get_session] = _override - with TestClient(app) as client: - yield client - app.dependency_overrides.clear() - - -def _public_jwk(public_key, kid: str) -> dict[str, object]: - jwk = json.loads(algorithms.RSAAlgorithm.to_jwk(public_key)) - jwk["kid"] = kid - jwk["use"] = "sig" - jwk["alg"] = "RS256" - return jwk - - -def _sign_token(private_key, kid: str, **claims_overrides: object) -> str: - now = datetime.now(UTC) - payload: dict[str, object] = { - "iss": "https://auth.example.test", - "sub": "user-123", - "aud": "innercontext-web", - "exp": int((now + timedelta(hours=1)).timestamp()), - "iat": int(now.timestamp()), - "groups": ["innercontext-admin"], - "email": "user@example.test", - "name": "Inner Context User", - "preferred_username": "ictx-user", - } - payload.update(claims_overrides) - return jwt.encode(payload, private_key, algorithm="RS256", headers={"kid": kid}) - - -def _mock_oidc(monkeypatch, public_key, *, fetch_counts: dict[str, int] | None = None): - def _fake_get(url: str, timeout: float): - if fetch_counts is not None: - fetch_counts[url] = fetch_counts.get(url, 0) + 1 - if url.endswith("/.well-known/openid-configuration"): - return _MockResponse({"jwks_uri": "https://auth.example.test/jwks.json"}) - if url.endswith("/jwks.json"): - return _MockResponse({"keys": [_public_jwk(public_key, "kid-1")]}) - raise AssertionError(f"unexpected URL {url} with timeout {timeout}") - - monkeypatch.setattr("innercontext.auth.httpx.get", _fake_get) - - -def test_validate_access_token_uses_cached_jwks(auth_env, rsa_keypair, monkeypatch): - private_key, public_key = rsa_keypair - fetch_counts: dict[str, int] = {} - _mock_oidc(monkeypatch, public_key, fetch_counts=fetch_counts) - - validate_access_token(_sign_token(private_key, "kid-1", sub="user-a")) - validate_access_token(_sign_token(private_key, "kid-1", sub="user-b")) - - assert ( - fetch_counts["https://auth.example.test/.well-known/openid-configuration"] == 1 - ) - assert fetch_counts["https://auth.example.test/jwks.json"] == 1 - - -@pytest.mark.parametrize( - ("path", "payload"), - [ - ( - "/auth/session/sync", - { - "email": "sync@example.test", - "name": "Synced User", - "preferred_username": "synced-user", - "groups": ["innercontext-admin"], - }, - ), - ("/auth/me", None), - ], - ids=["/auth/session/sync", "/auth/me"], -) -def test_sync_protected_endpoints_create_or_resolve_current_user( - auth_env, - auth_client, - auth_session, - rsa_keypair, - monkeypatch, - path: str, - payload: dict[str, object] | None, -): - private_key, public_key = rsa_keypair - _mock_oidc(monkeypatch, public_key) - token = _sign_token(private_key, "kid-1") - - if path == "/auth/me": - user = User( - oidc_issuer="https://auth.example.test", - oidc_subject="user-123", - role=Role.ADMIN, - ) - auth_session.add(user) - auth_session.commit() - auth_session.refresh(user) - - household = Household() - auth_session.add(household) - auth_session.commit() - auth_session.refresh(household) - - membership = HouseholdMembership( - user_id=user.id, - household_id=household.id, - role=HouseholdRole.OWNER, - ) - auth_session.add(membership) - auth_session.commit() - - response = auth_client.request( - "POST" if path.endswith("sync") else "GET", - path, - headers={"Authorization": f"Bearer {token}"}, - json=payload, - ) - - assert response.status_code == 200 - data = response.json() - assert data["user"]["role"] == "admin" - assert data["identity"]["issuer"] == "https://auth.example.test" - assert data["identity"]["subject"] == "user-123" - - synced_user = auth_session.get(User, UUID(data["user"]["id"])) - assert synced_user is not None - assert synced_user.oidc_issuer == "https://auth.example.test" - assert synced_user.oidc_subject == "user-123" - - if path == "/auth/session/sync": - assert data["identity"]["email"] == "sync@example.test" - assert data["identity"]["groups"] == ["innercontext-admin"] - else: - assert data["user"]["household_membership"]["role"] == "owner" - - -@pytest.mark.parametrize( - "path", - ["/auth/me", "/profile"], - ids=["/auth/me expects 401", "/profile expects 401"], -) -def test_unauthorized_protected_endpoints_return_401(auth_env, auth_client, path: str): - response = auth_client.get(path) - assert response.status_code == 401 - assert response.json()["detail"] == "Missing bearer token" - - -def test_unauthorized_invalid_bearer_token_is_rejected( - auth_env, auth_client, rsa_keypair, monkeypatch -): - _, public_key = rsa_keypair - _mock_oidc(monkeypatch, public_key) - response = auth_client.get( - "/auth/me", - headers={"Authorization": "Bearer not-a-jwt"}, - ) - assert response.status_code == 401 - - -def test_require_admin_raises_for_member(): - claims = TokenClaims( - issuer="https://auth.example.test", - subject="member-1", - audience=("innercontext-web",), - expires_at=datetime.now(UTC) + timedelta(hours=1), - raw_claims={"iss": "https://auth.example.test", "sub": "member-1"}, - ) - current_user = CurrentUser( - user_id=uuid4(), - role=Role.MEMBER, - identity=IdentityData.from_claims(claims), - claims=claims, - household_membership=CurrentHouseholdMembership( - household_id=uuid4(), - role=HouseholdRole.MEMBER, - ), - ) - - with pytest.raises(HTTPException) as exc_info: - require_admin(current_user) - - assert exc_info.value.status_code == 403 diff --git a/backend/tests/test_authz.py b/backend/tests/test_authz.py deleted file mode 100644 index 34a59c0..0000000 --- a/backend/tests/test_authz.py +++ /dev/null @@ -1,293 +0,0 @@ -from __future__ import annotations - -from datetime import UTC, datetime, timedelta -from uuid import UUID, uuid4 - -import pytest -from fastapi import HTTPException -from sqlmodel import Session - -from innercontext.api.authz import ( - can_update_inventory, - check_household_inventory_access, - get_owned_or_404, - get_owned_or_404_admin_override, - is_product_visible, - list_owned, - list_owned_admin_override, -) -from innercontext.auth import ( - CurrentHouseholdMembership, - CurrentUser, - IdentityData, - TokenClaims, -) -from innercontext.models import ( - Household, - HouseholdMembership, - HouseholdRole, - DayTime, - MedicationEntry, - MedicationKind, - Product, - ProductCategory, - ProductInventory, - Role, -) - - -def _claims(subject: str) -> TokenClaims: - return TokenClaims( - issuer="https://auth.example.test", - subject=subject, - audience=("innercontext-web",), - expires_at=datetime.now(UTC) + timedelta(hours=1), - raw_claims={"iss": "https://auth.example.test", "sub": subject}, - ) - - -def _current_user( - user_id: UUID, - *, - role: Role = Role.MEMBER, - household_id: UUID | None = None, -) -> CurrentUser: - claims = _claims(str(user_id)) - membership = None - if household_id is not None: - membership = CurrentHouseholdMembership( - household_id=household_id, - role=HouseholdRole.MEMBER, - ) - return CurrentUser( - user_id=user_id, - role=role, - identity=IdentityData.from_claims(claims), - claims=claims, - household_membership=membership, - ) - - -def _create_household(session: Session) -> Household: - household = Household() - session.add(household) - session.commit() - session.refresh(household) - return household - - -def _create_membership( - session: Session, user_id: UUID, household_id: UUID -) -> HouseholdMembership: - membership = HouseholdMembership(user_id=user_id, household_id=household_id) - session.add(membership) - session.commit() - session.refresh(membership) - return membership - - -def _create_medication(session: Session, user_id: UUID) -> MedicationEntry: - entry = MedicationEntry( - user_id=user_id, - kind=MedicationKind.PRESCRIPTION, - product_name="Test medication", - ) - session.add(entry) - session.commit() - session.refresh(entry) - return entry - - -def _create_product(session: Session, user_id: UUID, short_id: str) -> Product: - product = Product( - user_id=user_id, - short_id=short_id, - name="Shared product", - brand="Test brand", - category=ProductCategory.MOISTURIZER, - recommended_time=DayTime.BOTH, - leave_on=True, - ) - setattr(product, "product_effect_profile", {}) - session.add(product) - session.commit() - session.refresh(product) - return product - - -def _create_inventory( - session: Session, - *, - user_id: UUID, - product_id: UUID, - is_household_shared: bool, -) -> ProductInventory: - inventory = ProductInventory( - user_id=user_id, - product_id=product_id, - is_household_shared=is_household_shared, - ) - session.add(inventory) - session.commit() - session.refresh(inventory) - return inventory - - -def test_owner_helpers_return_only_owned_records(session: Session): - owner_id = uuid4() - other_id = uuid4() - owner_user = _current_user(owner_id) - owner_entry = _create_medication(session, owner_id) - _ = _create_medication(session, other_id) - - fetched = get_owned_or_404( - session, MedicationEntry, owner_entry.record_id, owner_user - ) - owned_entries = list_owned(session, MedicationEntry, owner_user) - - assert fetched.record_id == owner_entry.record_id - assert len(owned_entries) == 1 - assert owned_entries[0].user_id == owner_id - - -def test_admin_helpers_allow_admin_override_for_lookup_and_list(session: Session): - owner_id = uuid4() - admin_user = _current_user(uuid4(), role=Role.ADMIN) - owner_entry = _create_medication(session, owner_id) - - fetched = get_owned_or_404_admin_override( - session, - MedicationEntry, - owner_entry.record_id, - admin_user, - ) - listed = list_owned_admin_override(session, MedicationEntry, admin_user) - - assert fetched.record_id == owner_entry.record_id - assert len(listed) == 1 - - -def test_owner_denied_for_non_owned_lookup_returns_404(session: Session): - owner_id = uuid4() - intruder = _current_user(uuid4()) - owner_entry = _create_medication(session, owner_id) - - with pytest.raises(HTTPException) as exc_info: - _ = get_owned_or_404(session, MedicationEntry, owner_entry.record_id, intruder) - - assert exc_info.value.status_code == 404 - - -def test_household_shared_inventory_access_allows_same_household_member( - session: Session, -): - owner_id = uuid4() - household_member_id = uuid4() - household = _create_household(session) - _ = _create_membership(session, owner_id, household.id) - _ = _create_membership(session, household_member_id, household.id) - - product = _create_product(session, owner_id, short_id="abcd0001") - inventory = _create_inventory( - session, - user_id=owner_id, - product_id=product.id, - is_household_shared=True, - ) - - current_user = _current_user(household_member_id, household_id=household.id) - fetched = check_household_inventory_access(session, inventory.id, current_user) - - assert fetched.id == inventory.id - - -def test_household_shared_inventory_denied_for_cross_household_member(session: Session): - owner_id = uuid4() - outsider_id = uuid4() - owner_household = _create_household(session) - outsider_household = _create_household(session) - _ = _create_membership(session, owner_id, owner_household.id) - _ = _create_membership(session, outsider_id, outsider_household.id) - - product = _create_product(session, owner_id, short_id="abcd0002") - inventory = _create_inventory( - session, - user_id=owner_id, - product_id=product.id, - is_household_shared=True, - ) - - outsider = _current_user(outsider_id, household_id=outsider_household.id) - - with pytest.raises(HTTPException) as exc_info: - _ = check_household_inventory_access(session, inventory.id, outsider) - - assert exc_info.value.status_code == 404 - - -def test_household_inventory_update_rules_owner_admin_and_member(session: Session): - owner_id = uuid4() - member_id = uuid4() - household = _create_household(session) - _ = _create_membership(session, owner_id, household.id) - _ = _create_membership(session, member_id, household.id) - - product = _create_product(session, owner_id, short_id="abcd0003") - inventory = _create_inventory( - session, - user_id=owner_id, - product_id=product.id, - is_household_shared=True, - ) - - owner = _current_user(owner_id, household_id=household.id) - admin = _current_user(uuid4(), role=Role.ADMIN) - member = _current_user(member_id, household_id=household.id) - - assert can_update_inventory(session, inventory.id, owner) is True - assert can_update_inventory(session, inventory.id, admin) is True - assert can_update_inventory(session, inventory.id, member) is True - - -def test_product_visibility_for_owner_admin_and_household_shared(session: Session): - owner_id = uuid4() - member_id = uuid4() - household = _create_household(session) - _ = _create_membership(session, owner_id, household.id) - _ = _create_membership(session, member_id, household.id) - - product = _create_product(session, owner_id, short_id="abcd0004") - _ = _create_inventory( - session, - user_id=owner_id, - product_id=product.id, - is_household_shared=True, - ) - - owner = _current_user(owner_id, household_id=household.id) - admin = _current_user(uuid4(), role=Role.ADMIN) - member = _current_user(member_id, household_id=household.id) - - assert is_product_visible(session, product.id, owner) is True - assert is_product_visible(session, product.id, admin) is True - assert is_product_visible(session, product.id, member) is True - - -def test_product_visibility_denied_for_cross_household_member(session: Session): - owner_id = uuid4() - outsider_id = uuid4() - owner_household = _create_household(session) - outsider_household = _create_household(session) - _ = _create_membership(session, owner_id, owner_household.id) - _ = _create_membership(session, outsider_id, outsider_household.id) - - product = _create_product(session, owner_id, short_id="abcd0005") - _ = _create_inventory( - session, - user_id=owner_id, - product_id=product.id, - is_household_shared=True, - ) - outsider = _current_user(outsider_id, household_id=outsider_household.id) - - assert is_product_visible(session, product.id, outsider) is False diff --git a/backend/tests/test_products_auth.py b/backend/tests/test_products_auth.py deleted file mode 100644 index c5cc18a..0000000 --- a/backend/tests/test_products_auth.py +++ /dev/null @@ -1,370 +0,0 @@ -from __future__ import annotations - -from datetime import UTC, datetime, timedelta -from uuid import UUID, uuid4 - -import pytest -from fastapi.testclient import TestClient - -from db import get_session -from innercontext.api.auth_deps import get_current_user -from innercontext.auth import ( - CurrentHouseholdMembership, - CurrentUser, - IdentityData, - TokenClaims, -) -from innercontext.models import ( - DayTime, - Household, - HouseholdMembership, - HouseholdRole, - Product, - ProductCategory, - ProductInventory, - Role, -) -from main import app - - -def _current_user( - user_id: UUID, - *, - role: Role = Role.MEMBER, - household_id: UUID | None = None, -) -> CurrentUser: - claims = TokenClaims( - issuer="https://auth.test", - subject=str(user_id), - audience=("innercontext-web",), - expires_at=datetime.now(UTC) + timedelta(hours=1), - groups=("innercontext-member",), - raw_claims={"iss": "https://auth.test", "sub": str(user_id)}, - ) - membership = None - if household_id is not None: - membership = CurrentHouseholdMembership( - household_id=household_id, - role=HouseholdRole.MEMBER, - ) - return CurrentUser( - user_id=user_id, - role=role, - identity=IdentityData.from_claims(claims), - claims=claims, - household_membership=membership, - ) - - -def _create_membership(session, user_id: UUID, household_id: UUID) -> None: - membership = HouseholdMembership( - user_id=user_id, - household_id=household_id, - role=HouseholdRole.MEMBER, - ) - session.add(membership) - session.commit() - - -def _create_product(session, *, user_id: UUID, short_id: str, name: str) -> Product: - product = Product( - user_id=user_id, - short_id=short_id, - name=name, - brand="Brand", - category=ProductCategory.SERUM, - recommended_time=DayTime.BOTH, - leave_on=True, - ) - setattr(product, "product_effect_profile", {}) - session.add(product) - session.commit() - session.refresh(product) - return product - - -def _create_inventory( - session, - *, - user_id: UUID, - product_id: UUID, - is_household_shared: bool, -) -> ProductInventory: - entry = ProductInventory( - user_id=user_id, - product_id=product_id, - is_household_shared=is_household_shared, - ) - session.add(entry) - session.commit() - session.refresh(entry) - return entry - - -@pytest.fixture() -def auth_client(session): - auth_state = {"current_user": _current_user(uuid4(), role=Role.ADMIN)} - - def _session_override(): - yield session - - def _current_user_override(): - return auth_state["current_user"] - - app.dependency_overrides[get_session] = _session_override - app.dependency_overrides[get_current_user] = _current_user_override - with TestClient(app) as client: - yield client, auth_state - app.dependency_overrides.clear() - - -def test_product_endpoints_require_authentication(session): - def _session_override(): - yield session - - app.dependency_overrides[get_session] = _session_override - app.dependency_overrides.pop(get_current_user, None) - with TestClient(app) as client: - response = client.get("/products") - app.dependency_overrides.clear() - - assert response.status_code == 401 - assert response.json()["detail"] == "Missing bearer token" - - -def test_shared_product_visible_in_summary_marks_is_owned_false(auth_client, session): - client, auth_state = auth_client - - owner_id = uuid4() - member_id = uuid4() - household = Household() - session.add(household) - session.commit() - session.refresh(household) - _create_membership(session, owner_id, household.id) - _create_membership(session, member_id, household.id) - - shared_product = _create_product( - session, - user_id=owner_id, - short_id="shprd001", - name="Shared Product", - ) - _ = _create_inventory( - session, - user_id=owner_id, - product_id=shared_product.id, - is_household_shared=True, - ) - - auth_state["current_user"] = _current_user(member_id, household_id=household.id) - response = client.get("/products/summary") - - assert response.status_code == 200 - items = response.json() - shared_item = next(item for item in items if item["id"] == str(shared_product.id)) - assert shared_item["is_owned"] is False - - -def test_shared_product_visible_filters_private_inventory_rows(auth_client, session): - client, auth_state = auth_client - - owner_id = uuid4() - member_id = uuid4() - household = Household() - session.add(household) - session.commit() - session.refresh(household) - _create_membership(session, owner_id, household.id) - _create_membership(session, member_id, household.id) - - product = _create_product( - session, - user_id=owner_id, - short_id="shprd002", - name="Shared Inventory Product", - ) - shared_row = _create_inventory( - session, - user_id=owner_id, - product_id=product.id, - is_household_shared=True, - ) - _ = _create_inventory( - session, - user_id=owner_id, - product_id=product.id, - is_household_shared=False, - ) - - auth_state["current_user"] = _current_user(member_id, household_id=household.id) - response = client.get(f"/products/{product.id}") - - assert response.status_code == 200 - inventory_ids = {entry["id"] for entry in response.json()["inventory"]} - assert str(shared_row.id) in inventory_ids - assert len(inventory_ids) == 1 - - -def test_shared_inventory_update_allows_household_member(auth_client, session): - client, auth_state = auth_client - - owner_id = uuid4() - member_id = uuid4() - household = Household() - session.add(household) - session.commit() - session.refresh(household) - _create_membership(session, owner_id, household.id) - _create_membership(session, member_id, household.id) - - product = _create_product( - session, - user_id=owner_id, - short_id="shprd003", - name="Shared Update Product", - ) - inventory = _create_inventory( - session, - user_id=owner_id, - product_id=product.id, - is_household_shared=True, - ) - - auth_state["current_user"] = _current_user(member_id, household_id=household.id) - response = client.patch( - f"/inventory/{inventory.id}", - json={"is_opened": True, "remaining_level": "low"}, - ) - - assert response.status_code == 200 - assert response.json()["is_opened"] is True - assert response.json()["remaining_level"] == "low" - - -def test_household_member_cannot_edit_shared_product(auth_client, session): - client, auth_state = auth_client - - owner_id = uuid4() - member_id = uuid4() - household = Household() - session.add(household) - session.commit() - session.refresh(household) - _create_membership(session, owner_id, household.id) - _create_membership(session, member_id, household.id) - - product = _create_product( - session, - user_id=owner_id, - short_id="shprd004", - name="Shared No Edit", - ) - _ = _create_inventory( - session, - user_id=owner_id, - product_id=product.id, - is_household_shared=True, - ) - - auth_state["current_user"] = _current_user(member_id, household_id=household.id) - response = client.patch(f"/products/{product.id}", json={"name": "Intrusion"}) - - assert response.status_code == 404 - - -def test_household_member_cannot_delete_shared_product(auth_client, session): - client, auth_state = auth_client - - owner_id = uuid4() - member_id = uuid4() - household = Household() - session.add(household) - session.commit() - session.refresh(household) - _create_membership(session, owner_id, household.id) - _create_membership(session, member_id, household.id) - - product = _create_product( - session, - user_id=owner_id, - short_id="shprd005", - name="Shared No Delete", - ) - _ = _create_inventory( - session, - user_id=owner_id, - product_id=product.id, - is_household_shared=True, - ) - - auth_state["current_user"] = _current_user(member_id, household_id=household.id) - response = client.delete(f"/products/{product.id}") - - assert response.status_code == 404 - - -def test_household_member_cannot_create_or_delete_inventory_on_shared_product( - auth_client, session -): - client, auth_state = auth_client - - owner_id = uuid4() - member_id = uuid4() - household = Household() - session.add(household) - session.commit() - session.refresh(household) - _create_membership(session, owner_id, household.id) - _create_membership(session, member_id, household.id) - - product = _create_product( - session, - user_id=owner_id, - short_id="shprd006", - name="Shared Inventory Restrictions", - ) - inventory = _create_inventory( - session, - user_id=owner_id, - product_id=product.id, - is_household_shared=True, - ) - - auth_state["current_user"] = _current_user(member_id, household_id=household.id) - create_response = client.post(f"/products/{product.id}/inventory", json={}) - delete_response = client.delete(f"/inventory/{inventory.id}") - - assert create_response.status_code == 404 - assert delete_response.status_code == 404 - - -def test_household_member_cannot_update_non_shared_inventory(auth_client, session): - client, auth_state = auth_client - - owner_id = uuid4() - member_id = uuid4() - household = Household() - session.add(household) - session.commit() - session.refresh(household) - _create_membership(session, owner_id, household.id) - _create_membership(session, member_id, household.id) - - product = _create_product( - session, - user_id=owner_id, - short_id="shprd007", - name="Private Inventory", - ) - inventory = _create_inventory( - session, - user_id=owner_id, - product_id=product.id, - is_household_shared=False, - ) - - auth_state["current_user"] = _current_user(member_id, household_id=household.id) - response = client.patch(f"/inventory/{inventory.id}", json={"is_opened": True}) - - assert response.status_code == 404 diff --git a/backend/tests/test_products_helpers.py b/backend/tests/test_products_helpers.py index 9d5fb80..90f1c5f 100644 --- a/backend/tests/test_products_helpers.py +++ b/backend/tests/test_products_helpers.py @@ -28,27 +28,20 @@ from innercontext.validators.shopping_validator import ( ) -def test_build_shopping_context(session: Session, current_user): +def test_build_shopping_context(session: Session): # Empty context - ctx = _build_shopping_context( - session, reference_date=date.today(), current_user=current_user - ) + ctx = _build_shopping_context(session, reference_date=date.today()) assert "USER PROFILE: no data" in ctx assert "(brak danych)" in ctx assert "POSIADANE PRODUKTY" in ctx - profile = UserProfile( - user_id=current_user.user_id, - birth_date=date(1990, 1, 10), - sex_at_birth=SexAtBirth.MALE, - ) + profile = UserProfile(birth_date=date(1990, 1, 10), sex_at_birth=SexAtBirth.MALE) session.add(profile) session.commit() # Add snapshot snap = SkinConditionSnapshot( id=uuid.uuid4(), - user_id=current_user.user_id, snapshot_date=date.today(), overall_state="fair", skin_type="combination", @@ -86,9 +79,7 @@ def test_build_shopping_context(session: Session, current_user): session.add(inv) session.commit() - ctx = _build_shopping_context( - session, reference_date=date(2026, 3, 5), current_user=current_user - ) + ctx = _build_shopping_context(session, reference_date=date(2026, 3, 5)) assert "USER PROFILE:" in ctx assert "Age: 36" in ctx assert "Sex at birth: male" in ctx @@ -115,9 +106,7 @@ def test_build_shopping_context(session: Session, current_user): assert "repurchase_candidate=true" in ctx -def test_build_shopping_context_flags_replenishment_signal( - session: Session, current_user -): +def test_build_shopping_context_flags_replenishment_signal(session: Session): product = Product( id=uuid.uuid4(), short_id=str(uuid.uuid4())[:8], @@ -127,7 +116,6 @@ def test_build_shopping_context_flags_replenishment_signal( recommended_time="both", leave_on=False, product_effect_profile={}, - user_id=current_user.user_id, ) session.add(product) session.commit() @@ -142,9 +130,7 @@ def test_build_shopping_context_flags_replenishment_signal( ) session.commit() - ctx = _build_shopping_context( - session, reference_date=date.today(), current_user=current_user - ) + ctx = _build_shopping_context(session, reference_date=date.today()) assert "lowest_remaining_level=nearly_empty" in ctx assert "stock_state=urgent" in ctx assert "replenishment_priority_hint=high" in ctx @@ -301,7 +287,7 @@ def test_suggest_shopping_invalid_target_concern_returns_502(client): assert "suggestions/0/target_concerns/0" in r.json()["detail"] -def test_shopping_context_medication_skip(session: Session, current_user): +def test_shopping_context_medication_skip(session: Session): p = Product( id=uuid.uuid4(), short_id=str(uuid.uuid4())[:8], @@ -312,14 +298,11 @@ def test_shopping_context_medication_skip(session: Session, current_user): leave_on=True, is_medication=True, product_effect_profile={}, - user_id=current_user.user_id, ) session.add(p) session.commit() - ctx = _build_shopping_context( - session, reference_date=date.today(), current_user=current_user - ) + ctx = _build_shopping_context(session, reference_date=date.today()) assert "Epiduo" not in ctx diff --git a/backend/tests/test_routines.py b/backend/tests/test_routines.py index 28c885f..dae411f 100644 --- a/backend/tests/test_routines.py +++ b/backend/tests/test_routines.py @@ -3,7 +3,7 @@ from datetime import date from unittest.mock import patch from innercontext.models import Routine, SkinConditionSnapshot -from innercontext.models.enums import BarrierState, OverallSkinState, PartOfDay +from innercontext.models.enums import BarrierState, OverallSkinState # --------------------------------------------------------------------------- # Routines @@ -223,14 +223,13 @@ def test_delete_grooming_schedule_not_found(client): assert r.status_code == 404 -def test_suggest_routine(client, session, current_user): +def test_suggest_routine(client, session): with patch( "innercontext.api.routines.call_gemini_with_function_tools" ) as mock_gemini: session.add( SkinConditionSnapshot( id=uuid.uuid4(), - user_id=current_user.user_id, snapshot_date=date(2026, 2, 22), overall_state=OverallSkinState.GOOD, hydration_level=4, @@ -273,20 +272,18 @@ def test_suggest_routine(client, session, current_user): assert "get_product_details" in kwargs["function_handlers"] -def test_suggest_batch(client, session, current_user): +def test_suggest_batch(client, session): with patch("innercontext.api.routines.call_gemini") as mock_gemini: session.add( Routine( id=uuid.uuid4(), - user_id=current_user.user_id, routine_date=date(2026, 2, 27), - part_of_day=PartOfDay.PM, + part_of_day="pm", ) ) session.add( SkinConditionSnapshot( id=uuid.uuid4(), - user_id=current_user.user_id, snapshot_date=date(2026, 2, 20), overall_state=OverallSkinState.GOOD, hydration_level=4, diff --git a/backend/tests/test_routines_auth.py b/backend/tests/test_routines_auth.py deleted file mode 100644 index 9696556..0000000 --- a/backend/tests/test_routines_auth.py +++ /dev/null @@ -1,112 +0,0 @@ -from __future__ import annotations - -from datetime import UTC, datetime, timedelta -from unittest.mock import patch -from uuid import uuid4 - -from innercontext.api.auth_deps import get_current_user -from innercontext.auth import CurrentUser, IdentityData, TokenClaims -from innercontext.models import Role -from main import app - - -def _user(subject: str, *, role: Role = Role.MEMBER) -> CurrentUser: - claims = TokenClaims( - issuer="https://auth.test", - subject=subject, - audience=("innercontext-web",), - expires_at=datetime.now(UTC) + timedelta(hours=1), - raw_claims={"iss": "https://auth.test", "sub": subject}, - ) - return CurrentUser( - user_id=uuid4(), - role=role, - identity=IdentityData.from_claims(claims), - claims=claims, - ) - - -def _set_current_user(user: CurrentUser) -> None: - app.dependency_overrides[get_current_user] = lambda: user - - -def test_suggest_uses_current_user_profile_and_visible_products_only(client): - owner = _user("owner") - other = _user("other") - - _set_current_user(owner) - owner_profile = client.patch( - "/profile", json={"birth_date": "1991-01-15", "sex_at_birth": "male"} - ) - owner_product = client.post( - "/products", - json={ - "name": "Owner Serum", - "brand": "Test", - "category": "serum", - "recommended_time": "both", - "leave_on": True, - }, - ) - assert owner_profile.status_code == 200 - assert owner_product.status_code == 201 - - _set_current_user(other) - other_profile = client.patch( - "/profile", json={"birth_date": "1975-06-20", "sex_at_birth": "female"} - ) - other_product = client.post( - "/products", - json={ - "name": "Other Serum", - "brand": "Test", - "category": "serum", - "recommended_time": "both", - "leave_on": True, - }, - ) - assert other_profile.status_code == 200 - assert other_product.status_code == 201 - - _set_current_user(owner) - - with patch( - "innercontext.api.routines.call_gemini_with_function_tools" - ) as mock_gemini: - mock_response = type( - "Response", - (), - { - "text": '{"steps": [{"product_id": null, "action_type": "shaving_razor"}], "reasoning": "ok", "summary": {"primary_goal": "safe", "constraints_applied": [], "confidence": 0.7}}' - }, - ) - mock_gemini.return_value = (mock_response, None) - - response = client.post( - "/routines/suggest", - json={ - "routine_date": "2026-03-05", - "part_of_day": "am", - "include_minoxidil_beard": False, - }, - ) - assert response.status_code == 200 - - kwargs = mock_gemini.call_args.kwargs - prompt = kwargs["contents"] - assert "Birth date: 1991-01-15" in prompt - assert "Birth date: 1975-06-20" not in prompt - assert "Owner Serum" in prompt - assert "Other Serum" not in prompt - - handler = kwargs["function_handlers"]["get_product_details"] - payload = handler( - { - "product_ids": [ - owner_product.json()["id"], - other_product.json()["id"], - ] - } - ) - assert len(payload["products"]) == 1 - assert payload["products"][0]["name"] == "Owner Serum" diff --git a/backend/tests/test_routines_helpers.py b/backend/tests/test_routines_helpers.py index 5b70b41..b547f62 100644 --- a/backend/tests/test_routines_helpers.py +++ b/backend/tests/test_routines_helpers.py @@ -78,22 +78,17 @@ def test_ev(): assert _ev("string") == "string" -def test_build_skin_context(session: Session, current_user): +def test_build_skin_context(session: Session): # Empty reference_date = date(2026, 3, 10) assert ( - _build_skin_context( - session, - target_user_id=current_user.user_id, - reference_date=reference_date, - ) + _build_skin_context(session, reference_date=reference_date) == "SKIN CONDITION: no data\n" ) # With data snap = SkinConditionSnapshot( id=uuid.uuid4(), - user_id=current_user.user_id, snapshot_date=reference_date, overall_state=OverallSkinState.GOOD, hydration_level=4, @@ -105,11 +100,7 @@ def test_build_skin_context(session: Session, current_user): session.add(snap) session.commit() - ctx = _build_skin_context( - session, - target_user_id=current_user.user_id, - reference_date=reference_date, - ) + ctx = _build_skin_context(session, reference_date=reference_date) assert "SKIN CONDITION (snapshot from" in ctx assert "Overall state: good" in ctx assert "Hydration: 4/5" in ctx @@ -121,12 +112,10 @@ def test_build_skin_context(session: Session, current_user): def test_build_skin_context_falls_back_to_recent_snapshot_within_14_days( session: Session, - current_user, ): reference_date = date(2026, 3, 20) snap = SkinConditionSnapshot( id=uuid.uuid4(), - user_id=current_user.user_id, snapshot_date=reference_date - timedelta(days=10), overall_state=OverallSkinState.FAIR, hydration_level=3, @@ -137,23 +126,16 @@ def test_build_skin_context_falls_back_to_recent_snapshot_within_14_days( session.add(snap) session.commit() - ctx = _build_skin_context( - session, - target_user_id=current_user.user_id, - reference_date=reference_date, - ) + ctx = _build_skin_context(session, reference_date=reference_date) assert f"snapshot from {reference_date - timedelta(days=10)}" in ctx assert "Barrier: compromised" in ctx -def test_build_skin_context_ignores_snapshot_older_than_14_days( - session: Session, current_user -): +def test_build_skin_context_ignores_snapshot_older_than_14_days(session: Session): reference_date = date(2026, 3, 20) snap = SkinConditionSnapshot( id=uuid.uuid4(), - user_id=current_user.user_id, snapshot_date=reference_date - timedelta(days=15), overall_state=OverallSkinState.FAIR, hydration_level=3, @@ -163,20 +145,15 @@ def test_build_skin_context_ignores_snapshot_older_than_14_days( session.commit() assert ( - _build_skin_context( - session, - target_user_id=current_user.user_id, - reference_date=reference_date, - ) + _build_skin_context(session, reference_date=reference_date) == "SKIN CONDITION: no data\n" ) -def test_get_recent_skin_snapshot_prefers_window_match(session: Session, current_user): +def test_get_recent_skin_snapshot_prefers_window_match(session: Session): reference_date = date(2026, 3, 20) older = SkinConditionSnapshot( id=uuid.uuid4(), - user_id=current_user.user_id, snapshot_date=reference_date - timedelta(days=10), overall_state=OverallSkinState.POOR, hydration_level=2, @@ -184,7 +161,6 @@ def test_get_recent_skin_snapshot_prefers_window_match(session: Session, current ) newer = SkinConditionSnapshot( id=uuid.uuid4(), - user_id=current_user.user_id, snapshot_date=reference_date - timedelta(days=2), overall_state=OverallSkinState.GOOD, hydration_level=4, @@ -193,11 +169,7 @@ def test_get_recent_skin_snapshot_prefers_window_match(session: Session, current session.add_all([older, newer]) session.commit() - snapshot = _get_recent_skin_snapshot( - session, - target_user_id=current_user.user_id, - reference_date=reference_date, - ) + snapshot = _get_recent_skin_snapshot(session, reference_date=reference_date) assert snapshot is not None assert snapshot.id == newer.id @@ -205,12 +177,10 @@ def test_get_recent_skin_snapshot_prefers_window_match(session: Session, current def test_get_latest_skin_snapshot_within_days_uses_latest_within_14_days( session: Session, - current_user, ): reference_date = date(2026, 3, 20) older = SkinConditionSnapshot( id=uuid.uuid4(), - user_id=current_user.user_id, snapshot_date=reference_date - timedelta(days=10), overall_state=OverallSkinState.POOR, hydration_level=2, @@ -218,7 +188,6 @@ def test_get_latest_skin_snapshot_within_days_uses_latest_within_14_days( ) newer = SkinConditionSnapshot( id=uuid.uuid4(), - user_id=current_user.user_id, snapshot_date=reference_date - timedelta(days=2), overall_state=OverallSkinState.GOOD, hydration_level=4, @@ -229,7 +198,6 @@ def test_get_latest_skin_snapshot_within_days_uses_latest_within_14_days( snapshot = _get_latest_skin_snapshot_within_days( session, - target_user_id=current_user.user_id, reference_date=reference_date, ) @@ -237,65 +205,39 @@ def test_get_latest_skin_snapshot_within_days_uses_latest_within_14_days( assert snapshot.id == newer.id -def test_build_grooming_context(session: Session, current_user): - assert ( - _build_grooming_context(session, target_user_id=current_user.user_id) - == "GROOMING SCHEDULE: none\n" - ) +def test_build_grooming_context(session: Session): + assert _build_grooming_context(session) == "GROOMING SCHEDULE: none\n" sch = GroomingSchedule( - id=uuid.uuid4(), - user_id=current_user.user_id, - day_of_week=0, - action="shaving_oneblade", - notes="Morning", + id=uuid.uuid4(), day_of_week=0, action="shaving_oneblade", notes="Morning" ) session.add(sch) session.commit() - ctx = _build_grooming_context(session, target_user_id=current_user.user_id) + ctx = _build_grooming_context(session) assert "GROOMING SCHEDULE:" in ctx assert "poniedziałek: shaving_oneblade (Morning)" in ctx # Test weekdays filter - ctx2 = _build_grooming_context( - session, - target_user_id=current_user.user_id, - weekdays=[1], - ) # not monday + ctx2 = _build_grooming_context(session, weekdays=[1]) # not monday assert "(no entries for specified days)" in ctx2 -def test_build_upcoming_grooming_context(session: Session, current_user): +def test_build_upcoming_grooming_context(session: Session): assert ( - _build_upcoming_grooming_context( - session, - target_user_id=current_user.user_id, - start_date=date(2026, 3, 2), - days=7, - ) + _build_upcoming_grooming_context(session, start_date=date(2026, 3, 2), days=7) == "UPCOMING GROOMING (next 7 days): none\n" ) monday = GroomingSchedule( - id=uuid.uuid4(), - user_id=current_user.user_id, - day_of_week=0, - action="shaving_oneblade", - notes="Morning", - ) - wednesday = GroomingSchedule( - id=uuid.uuid4(), - user_id=current_user.user_id, - day_of_week=2, - action="dermarolling", + id=uuid.uuid4(), day_of_week=0, action="shaving_oneblade", notes="Morning" ) + wednesday = GroomingSchedule(id=uuid.uuid4(), day_of_week=2, action="dermarolling") session.add_all([monday, wednesday]) session.commit() ctx = _build_upcoming_grooming_context( session, - target_user_id=current_user.user_id, start_date=date(2026, 3, 2), days=7, ) @@ -304,23 +246,14 @@ def test_build_upcoming_grooming_context(session: Session, current_user): assert "za 2 dni (2026-03-04, środa): dermarolling" in ctx -def test_build_recent_history(session: Session, current_user): +def test_build_recent_history(session: Session): reference_date = date(2026, 3, 10) assert ( - _build_recent_history( - session, - target_user_id=current_user.user_id, - reference_date=reference_date, - ) + _build_recent_history(session, reference_date=reference_date) == "RECENT ROUTINES: none\n" ) - r = Routine( - id=uuid.uuid4(), - user_id=current_user.user_id, - routine_date=reference_date, - part_of_day="am", - ) + r = Routine(id=uuid.uuid4(), routine_date=reference_date, part_of_day="am") session.add(r) p = Product( id=uuid.uuid4(), @@ -335,37 +268,19 @@ def test_build_recent_history(session: Session, current_user): session.add(p) session.commit() - s1 = RoutineStep( - id=uuid.uuid4(), - user_id=current_user.user_id, - routine_id=r.id, - order_index=1, - product_id=p.id, - ) + s1 = RoutineStep(id=uuid.uuid4(), routine_id=r.id, order_index=1, product_id=p.id) s2 = RoutineStep( - id=uuid.uuid4(), - user_id=current_user.user_id, - routine_id=r.id, - order_index=2, - action_type="shaving_razor", + id=uuid.uuid4(), routine_id=r.id, order_index=2, action_type="shaving_razor" ) # Step with non-existent product s3 = RoutineStep( - id=uuid.uuid4(), - user_id=current_user.user_id, - routine_id=r.id, - order_index=3, - product_id=uuid.uuid4(), + id=uuid.uuid4(), routine_id=r.id, order_index=3, product_id=uuid.uuid4() ) session.add_all([s1, s2, s3]) session.commit() - ctx = _build_recent_history( - session, - target_user_id=current_user.user_id, - reference_date=reference_date, - ) + ctx = _build_recent_history(session, reference_date=reference_date) assert "RECENT ROUTINES:" in ctx assert "AM:" in ctx assert "cleanser [" in ctx @@ -373,38 +288,31 @@ def test_build_recent_history(session: Session, current_user): assert "unknown [" in ctx -def test_build_recent_history_uses_reference_window(session: Session, current_user): +def test_build_recent_history_uses_reference_window(session: Session): reference_date = date(2026, 3, 10) recent = Routine( id=uuid.uuid4(), - user_id=current_user.user_id, routine_date=reference_date - timedelta(days=3), part_of_day="pm", ) old = Routine( id=uuid.uuid4(), - user_id=current_user.user_id, routine_date=reference_date - timedelta(days=6), part_of_day="am", ) session.add_all([recent, old]) session.commit() - ctx = _build_recent_history( - session, - target_user_id=current_user.user_id, - reference_date=reference_date, - ) + ctx = _build_recent_history(session, reference_date=reference_date) assert str(recent.routine_date) in ctx assert str(old.routine_date) not in ctx -def test_build_recent_history_excludes_future_routines(session: Session, current_user): +def test_build_recent_history_excludes_future_routines(session: Session): reference_date = date(2026, 3, 10) future = Routine( id=uuid.uuid4(), - user_id=current_user.user_id, routine_date=reference_date + timedelta(days=1), part_of_day="am", ) @@ -412,16 +320,12 @@ def test_build_recent_history_excludes_future_routines(session: Session, current session.commit() assert ( - _build_recent_history( - session, - target_user_id=current_user.user_id, - reference_date=reference_date, - ) + _build_recent_history(session, reference_date=reference_date) == "RECENT ROUTINES: none\n" ) -def test_build_products_context_summary_list(session: Session, current_user): +def test_build_products_context_summary_list(session: Session): p1 = Product( id=uuid.uuid4(), short_id=str(uuid.uuid4())[:8], @@ -432,7 +336,6 @@ def test_build_products_context_summary_list(session: Session, current_user): recommended_time="both", leave_on=True, product_effect_profile={}, - user_id=current_user.user_id, ) p2 = Product( id=uuid.uuid4(), @@ -447,16 +350,11 @@ def test_build_products_context_summary_list(session: Session, current_user): context_rules={"safe_after_shaving": False}, min_interval_hours=12, max_frequency_per_week=7, - user_id=current_user.user_id, ) session.add_all([p1, p2]) session.commit() - products_am = _get_available_products( - session, - current_user=current_user, - time_filter="am", - ) + products_am = _get_available_products(session, time_filter="am") ctx = build_products_context_summary_list(products_am, {p2.id}) assert "Regaine Minoxidil" in ctx @@ -477,10 +375,9 @@ def test_build_day_context(): assert "Leaving home: no" in _build_day_context(False) -def test_get_available_products_respects_filters(session: Session, current_user): +def test_get_available_products_respects_filters(session: Session): regular_med = Product( id=uuid.uuid4(), - short_id=str(uuid.uuid4())[:8], name="Tretinoin", category="serum", is_medication=True, @@ -488,11 +385,9 @@ def test_get_available_products_respects_filters(session: Session, current_user) recommended_time="pm", leave_on=True, product_effect_profile={}, - user_id=current_user.user_id, ) minoxidil_med = Product( id=uuid.uuid4(), - short_id=str(uuid.uuid4())[:8], name="Minoxidil 5%", category="serum", is_medication=True, @@ -500,38 +395,29 @@ def test_get_available_products_respects_filters(session: Session, current_user) recommended_time="both", leave_on=True, product_effect_profile={}, - user_id=current_user.user_id, ) am_product = Product( id=uuid.uuid4(), - short_id=str(uuid.uuid4())[:8], name="AM SPF", category="spf", brand="Test", recommended_time="am", leave_on=True, product_effect_profile={}, - user_id=current_user.user_id, ) pm_product = Product( id=uuid.uuid4(), - short_id=str(uuid.uuid4())[:8], name="PM Cream", category="moisturizer", brand="Test", recommended_time="pm", leave_on=True, product_effect_profile={}, - user_id=current_user.user_id, ) session.add_all([regular_med, minoxidil_med, am_product, pm_product]) session.commit() - am_available = _get_available_products( - session, - current_user=current_user, - time_filter="am", - ) + am_available = _get_available_products(session, time_filter="am") am_names = {p.name for p in am_available} assert "Tretinoin" not in am_names assert "Minoxidil 5%" in am_names @@ -544,7 +430,6 @@ def test_build_product_details_tool_handler_returns_only_available_ids( ): available = Product( id=uuid.uuid4(), - short_id=str(uuid.uuid4())[:8], name="Available", category="serum", brand="Test", @@ -555,7 +440,6 @@ def test_build_product_details_tool_handler_returns_only_available_ids( ) unavailable = Product( id=uuid.uuid4(), - short_id=str(uuid.uuid4())[:8], name="Unavailable", category="serum", brand="Test", @@ -580,8 +464,9 @@ def test_build_product_details_tool_handler_returns_only_available_ids( assert "products" in payload products = payload["products"] assert len(products) == 1 - assert products[0]["id"] == available.short_id + assert products[0]["id"] == str(available.id) assert products[0]["name"] == "Available" + assert products[0]["inci"] == ["Water", "Niacinamide"] assert "actives" in products[0] assert "safety" in products[0] @@ -623,13 +508,9 @@ def test_extract_active_names_uses_compact_distinct_names(session: Session): assert names == ["Niacinamide", "Zinc PCA"] -def test_get_available_products_excludes_minoxidil_when_flag_false( - session: Session, - current_user, -): +def test_get_available_products_excludes_minoxidil_when_flag_false(session: Session): minoxidil = Product( id=uuid.uuid4(), - short_id=str(uuid.uuid4())[:8], name="Minoxidil 5%", category="hair_treatment", is_medication=True, @@ -637,38 +518,27 @@ def test_get_available_products_excludes_minoxidil_when_flag_false( recommended_time="both", leave_on=True, product_effect_profile={}, - user_id=current_user.user_id, ) regular = Product( id=uuid.uuid4(), - short_id=str(uuid.uuid4())[:8], name="Cleanser", category="cleanser", brand="Test", recommended_time="both", leave_on=False, product_effect_profile={}, - user_id=current_user.user_id, ) session.add_all([minoxidil, regular]) session.commit() # With flag True (default) - minoxidil included - products = _get_available_products( - session, - current_user=current_user, - include_minoxidil=True, - ) + products = _get_available_products(session, include_minoxidil=True) names = {p.name for p in products} assert "Minoxidil 5%" in names assert "Cleanser" in names # With flag False - minoxidil excluded - products = _get_available_products( - session, - current_user=current_user, - include_minoxidil=False, - ) + products = _get_available_products(session, include_minoxidil=False) names = {p.name for p in products} assert "Minoxidil 5%" not in names assert "Cleanser" in names diff --git a/backend/tests/test_skincare.py b/backend/tests/test_skincare.py index ac62a99..b6ce4b0 100644 --- a/backend/tests/test_skincare.py +++ b/backend/tests/test_skincare.py @@ -140,7 +140,7 @@ def test_analyze_photos_includes_user_profile_context(client, monkeypatch): def _fake_call_gemini(**kwargs): captured.update(kwargs) - return _FakeResponse(), None + return _FakeResponse() monkeypatch.setattr(skincare_api, "call_gemini", _fake_call_gemini) diff --git a/backend/tests/test_tenancy_domains.py b/backend/tests/test_tenancy_domains.py deleted file mode 100644 index bbe1ce9..0000000 --- a/backend/tests/test_tenancy_domains.py +++ /dev/null @@ -1,100 +0,0 @@ -from __future__ import annotations - -from datetime import UTC, datetime, timedelta -from uuid import uuid4 - -from innercontext.api.auth_deps import get_current_user -from innercontext.auth import CurrentUser, IdentityData, TokenClaims -from innercontext.models import Role -from innercontext.models.ai_log import AICallLog -from main import app - - -def _user(subject: str, *, role: Role = Role.MEMBER) -> CurrentUser: - claims = TokenClaims( - issuer="https://auth.test", - subject=subject, - audience=("innercontext-web",), - expires_at=datetime.now(UTC) + timedelta(hours=1), - raw_claims={"iss": "https://auth.test", "sub": subject}, - ) - return CurrentUser( - user_id=uuid4(), - role=role, - identity=IdentityData.from_claims(claims), - claims=claims, - ) - - -def _set_current_user(user: CurrentUser) -> None: - app.dependency_overrides[get_current_user] = lambda: user - - -def test_profile_health_routines_skincare_ai_logs_are_user_scoped_by_default( - client, session -): - owner = _user("owner") - intruder = _user("intruder") - - _set_current_user(owner) - profile = client.patch( - "/profile", json={"birth_date": "1991-01-15", "sex_at_birth": "male"} - ) - medication = client.post( - "/health/medications", json={"kind": "prescription", "product_name": "Owner Rx"} - ) - routine = client.post( - "/routines", json={"routine_date": "2026-03-01", "part_of_day": "am"} - ) - snapshot = client.post("/skincare", json={"snapshot_date": "2026-03-01"}) - log = AICallLog(endpoint="routines/suggest", model="gemini-3-flash-preview") - log.user_id = owner.user_id - session.add(log) - session.commit() - session.refresh(log) - - assert profile.status_code == 200 - assert medication.status_code == 201 - assert routine.status_code == 201 - assert snapshot.status_code == 201 - - medication_id = medication.json()["record_id"] - routine_id = routine.json()["id"] - snapshot_id = snapshot.json()["id"] - - _set_current_user(intruder) - assert client.get("/profile").json() is None - assert client.get("/health/medications").json() == [] - assert client.get("/routines").json() == [] - assert client.get("/skincare").json() == [] - assert client.get("/ai-logs").json() == [] - - assert client.get(f"/health/medications/{medication_id}").status_code == 404 - assert client.get(f"/routines/{routine_id}").status_code == 404 - assert client.get(f"/skincare/{snapshot_id}").status_code == 404 - assert client.get(f"/ai-logs/{log.id}").status_code == 404 - - -def test_health_admin_override_requires_explicit_user_id(client): - owner = _user("owner") - admin = _user("admin", role=Role.ADMIN) - - _set_current_user(owner) - created = client.post( - "/health/lab-results", - json={ - "collected_at": "2026-03-01T00:00:00", - "test_code": "718-7", - "test_name_original": "Hemoglobin", - }, - ) - assert created.status_code == 201 - - _set_current_user(admin) - default_scope = client.get("/health/lab-results") - assert default_scope.status_code == 200 - assert default_scope.json()["items"] == [] - - overridden = client.get(f"/health/lab-results?user_id={owner.user_id}") - assert overridden.status_code == 200 - assert len(overridden.json()["items"]) == 1 diff --git a/backend/uv.lock b/backend/uv.lock index f409e15..a911208 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -557,7 +557,6 @@ dependencies = [ { name = "fastapi" }, { name = "google-genai" }, { name = "psycopg", extra = ["binary"] }, - { name = "pyjwt", extra = ["crypto"] }, { name = "python-dotenv" }, { name = "python-multipart" }, { name = "sqlmodel" }, @@ -581,7 +580,6 @@ requires-dist = [ { name = "fastapi", specifier = ">=0.132.0" }, { name = "google-genai", specifier = ">=1.65.0" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.3.3" }, - { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10.1" }, { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "python-multipart", specifier = ">=0.0.22" }, { name = "sqlmodel", specifier = ">=0.0.37" }, @@ -911,20 +909,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] -[[package]] -name = "pyjwt" -version = "2.11.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, -] - -[package.optional-dependencies] -crypto = [ - { name = "cryptography" }, -] - [[package]] name = "pytest" version = "9.0.2" diff --git a/deploy.sh b/deploy.sh index 2ced385..39ef614 100755 --- a/deploy.sh +++ b/deploy.sh @@ -346,8 +346,7 @@ check_backend_health() { check_frontend_health() { local i for ((i = 1; i <= 30; i++)); do - # Allow 200 OK or 302/303/307 Redirect (to login) - if remote "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/ | grep -qE '^(200|302|303|307)$'"; then + if remote "curl -sf http://127.0.0.1:3000/ >/dev/null"; then log "Frontend health check passed" return 0 fi diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index eb14a1d..ea1089c 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -94,82 +94,117 @@ chown -R innercontext:innercontext /opt/innercontext cat > /opt/innercontext/shared/backend/.env <<'EOF' DATABASE_URL=postgresql+psycopg://innercontext:change-me@/innercontext GEMINI_API_KEY=your-key - -# OIDC Configuration -OIDC_ISSUER=https://auth.example.com -OIDC_CLIENT_ID=innercontext-backend -OIDC_DISCOVERY_URL=https://auth.example.com/.well-known/openid-configuration -OIDC_ADMIN_GROUPS=admins -OIDC_MEMBER_GROUPS=members - -# Bootstrap Admin (Optional, used for initial setup) -# BOOTSTRAP_ADMIN_OIDC_ISSUER=https://auth.example.com -# BOOTSTRAP_ADMIN_OIDC_SUB=user-sub-from-authelia -# BOOTSTRAP_ADMIN_EMAIL=admin@example.com -# BOOTSTRAP_ADMIN_NAME="Admin User" -# BOOTSTRAP_HOUSEHOLD_NAME="My Household" EOF cat > /opt/innercontext/shared/frontend/.env.production <<'EOF' PUBLIC_API_BASE=http://127.0.0.1:8000 ORIGIN=http://innercontext.lan - -# Session and OIDC -SESSION_SECRET=generate-a-long-random-string -OIDC_ISSUER=https://auth.example.com -OIDC_CLIENT_ID=innercontext-frontend -OIDC_DISCOVERY_URL=https://auth.example.com/.well-known/openid-configuration EOF + +chmod 600 /opt/innercontext/shared/backend/.env +chmod 600 /opt/innercontext/shared/frontend/.env.production +chown innercontext:innercontext /opt/innercontext/shared/backend/.env +chown innercontext:innercontext /opt/innercontext/shared/frontend/.env.production ``` -## OIDC Setup (Authelia) +### 4) Grant deploy sudo permissions -This project uses OIDC for authentication. You need an OIDC provider like Authelia. +```bash +cat > /etc/sudoers.d/innercontext-deploy << 'EOF' +innercontext ALL=(root) NOPASSWD: \ + /usr/bin/systemctl restart innercontext, \ + /usr/bin/systemctl restart innercontext-node, \ + /usr/bin/systemctl restart innercontext-pricing-worker, \ + /usr/bin/systemctl is-active innercontext, \ + /usr/bin/systemctl is-active innercontext-node, \ + /usr/bin/systemctl is-active innercontext-pricing-worker +EOF -### Authelia Client Configuration +chmod 440 /etc/sudoers.d/innercontext-deploy +visudo -c -f /etc/sudoers.d/innercontext-deploy -Add the following to your Authelia `configuration.yml`: - -```yaml -identity_providers: - oidc: - clients: - - id: innercontext-frontend - description: InnerContext Frontend - secret: '$pbkdf2-sha512$...' # Not used for public client, but Authelia may require it - public: true - authorization_policy: one_factor - redirect_uris: - - http://innercontext.lan/auth/callback - scopes: - - openid - - profile - - email - - groups - userinfo_signed_response_alg: none - - - id: innercontext-backend - description: InnerContext Backend - secret: '$pbkdf2-sha512$...' - public: false - authorization_policy: one_factor - redirect_uris: [] - scopes: - - openid - - profile - - email - - groups - userinfo_signed_response_alg: none +# Must work without password or TTY prompt: +sudo -u innercontext sudo -n -l ``` -### Bootstrap Admin +If `sudo -n -l` fails, deployments will fail during restart/rollback with: +`sudo: a terminal is required` or `sudo: a password is required`. -To create the first user and household, set the `BOOTSTRAP_ADMIN_*` environment variables in the backend `.env` file and restart the backend. The backend will automatically create the user and household on startup if they don't exist. After the first successful login, you can remove these variables. +### 5) Install systemd and nginx configs + +After first deploy (or after copying repo content to `/opt/innercontext/current`), install configs: + +```bash +cp /opt/innercontext/current/systemd/innercontext.service /etc/systemd/system/ +cp /opt/innercontext/current/systemd/innercontext-node.service /etc/systemd/system/ +cp /opt/innercontext/current/systemd/innercontext-pricing-worker.service /etc/systemd/system/ +systemctl daemon-reload +systemctl enable innercontext +systemctl enable innercontext-node +systemctl enable innercontext-pricing-worker + +cp /opt/innercontext/current/nginx/innercontext.conf /etc/nginx/sites-available/innercontext +ln -sf /etc/nginx/sites-available/innercontext /etc/nginx/sites-enabled/innercontext +rm -f /etc/nginx/sites-enabled/default +nginx -t && systemctl reload nginx +``` + +## Local Machine Setup + +`~/.ssh/config`: + +``` +Host innercontext + HostName + User innercontext +``` + +Ensure your public key is in `/home/innercontext/.ssh/authorized_keys`. + +## Deploy Commands + +From repository root on external machine: + +```bash +./deploy.sh # full deploy (default = all) +./deploy.sh all +./deploy.sh backend +./deploy.sh frontend +./deploy.sh list +./deploy.sh rollback +``` + +Optional overrides: + +```bash +DEPLOY_SERVER=innercontext ./deploy.sh all +DEPLOY_ROOT=/opt/innercontext ./deploy.sh backend +DEPLOY_ALLOW_DIRTY=1 ./deploy.sh frontend +``` + +## What `deploy.sh` Does + +For `backend` / `frontend` / `all`: + +1. Local checks (strict, fail-fast) +2. Acquire `/opt/innercontext/.deploy.lock` +3. Create `` release directory +4. Upload selected component(s) +5. Link shared env files in the release directory +6. `uv sync` + `alembic upgrade head` (backend scope) +7. Upload `scripts/`, `systemd/`, `nginx/` +8. Switch `current` to the prepared release +9. Restart affected services +10. Run health checks +11. Remove old releases (keep last 5) +12. Write deploy entry to `/opt/innercontext/deploy.log` + +If anything fails after promotion, script auto-rolls back to previous release. ## Health Checks -- Backend: `http://127.0.0.1:8000/health-check` (returns 200) -- Frontend: `http://127.0.0.1:3000/` (returns 200 or 302 redirect to login) +- Backend: `http://127.0.0.1:8000/health-check` +- Frontend: `http://127.0.0.1:3000/` - Worker: `systemctl is-active innercontext-pricing-worker` Manual checks: diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 8eda3a9..651301a 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -10,11 +10,6 @@ "nav_appName": "innercontext", "nav_appSubtitle": "personal health & skincare", - "auth_signedInAs": "Signed in as", - "auth_roleAdmin": "Admin", - "auth_roleMember": "Member", - "auth_logout": "Log out", - "common_save": "Save", "common_cancel": "Cancel", "common_add": "Add", diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index c086a79..33fbc31 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -10,11 +10,6 @@ "nav_appName": "innercontext", "nav_appSubtitle": "zdrowie & pielęgnacja", - "auth_signedInAs": "Zalogowano jako", - "auth_roleAdmin": "Administrator", - "auth_roleMember": "Użytkownik", - "auth_logout": "Wyloguj", - "common_save": "Zapisz", "common_cancel": "Anuluj", "common_add": "Dodaj", diff --git a/frontend/openapi.json b/frontend/openapi.json index fa51f02..30c509c 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -5,85 +5,6 @@ "version": "0.1.0" }, "paths": { - "/auth/session/sync": { - "post": { - "tags": [ - "auth" - ], - "summary": "Sync Session", - "operationId": "sync_session_auth_session_sync_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "anyOf": [ - { - "$ref": "#/components/schemas/SessionSyncRequest" - }, - { - "type": "null" - } - ], - "title": "Payload" - } - } - } - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AuthSessionResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - }, - "security": [ - { - "HTTPBearer": [] - } - ] - } - }, - "/auth/me": { - "get": { - "tags": [ - "auth" - ], - "summary": "Get Me", - "operationId": "get_me_auth_me_get", - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AuthSessionResponse" - } - } - } - } - }, - "security": [ - { - "HTTPBearer": [] - } - ] - } - }, "/products": { "get": { "tags": [ @@ -91,11 +12,6 @@ ], "summary": "List Products", "operationId": "list_products_products_get", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "category", @@ -214,11 +130,6 @@ ], "summary": "Create Product", "operationId": "create_product_products_post", - "security": [ - { - "HTTPBearer": [] - } - ], "requestBody": { "required": true, "content": { @@ -291,12 +202,7 @@ } } } - }, - "security": [ - { - "HTTPBearer": [] - } - ] + } } }, "/products/summary": { @@ -306,11 +212,6 @@ ], "summary": "List Products Summary", "operationId": "list_products_summary_products_summary_get", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "category", @@ -431,11 +332,6 @@ ], "summary": "Get Product", "operationId": "get_product_products__product_id__get", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "product_id", @@ -477,11 +373,6 @@ ], "summary": "Update Product", "operationId": "update_product_products__product_id__patch", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "product_id", @@ -533,11 +424,6 @@ ], "summary": "Delete Product", "operationId": "delete_product_products__product_id__delete", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "product_id", @@ -574,11 +460,6 @@ ], "summary": "List Product Inventory", "operationId": "list_product_inventory_products__product_id__inventory_get", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "product_id", @@ -624,11 +505,6 @@ ], "summary": "Create Product Inventory", "operationId": "create_product_inventory_products__product_id__inventory_post", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "product_id", @@ -693,12 +569,7 @@ } } } - }, - "security": [ - { - "HTTPBearer": [] - } - ] + } } }, "/inventory/{inventory_id}": { @@ -708,11 +579,6 @@ ], "summary": "Get Inventory", "operationId": "get_inventory_inventory__inventory_id__get", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "inventory_id", @@ -754,11 +620,6 @@ ], "summary": "Update Inventory", "operationId": "update_inventory_inventory__inventory_id__patch", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "inventory_id", @@ -810,11 +671,6 @@ ], "summary": "Delete Inventory", "operationId": "delete_inventory_inventory__inventory_id__delete", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "inventory_id", @@ -870,12 +726,7 @@ } } } - }, - "security": [ - { - "HTTPBearer": [] - } - ] + } }, "patch": { "tags": [ @@ -914,12 +765,7 @@ } } } - }, - "security": [ - { - "HTTPBearer": [] - } - ] + } } }, "/health/medications": { @@ -929,11 +775,6 @@ ], "summary": "List Medications", "operationId": "list_medications_health_medications_get", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "kind", @@ -1001,11 +842,6 @@ ], "summary": "Create Medication", "operationId": "create_medication_health_medications_post", - "security": [ - { - "HTTPBearer": [] - } - ], "requestBody": { "required": true, "content": { @@ -1047,11 +883,6 @@ ], "summary": "Get Medication", "operationId": "get_medication_health_medications__medication_id__get", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "medication_id", @@ -1093,11 +924,6 @@ ], "summary": "Update Medication", "operationId": "update_medication_health_medications__medication_id__patch", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "medication_id", @@ -1149,11 +975,6 @@ ], "summary": "Delete Medication", "operationId": "delete_medication_health_medications__medication_id__delete", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "medication_id", @@ -1190,11 +1011,6 @@ ], "summary": "List Usages", "operationId": "list_usages_health_medications__medication_id__usages_get", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "medication_id", @@ -1240,11 +1056,6 @@ ], "summary": "Create Usage", "operationId": "create_usage_health_medications__medication_id__usages_post", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "medication_id", @@ -1298,11 +1109,6 @@ ], "summary": "Update Usage", "operationId": "update_usage_health_usages__usage_id__patch", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "usage_id", @@ -1354,11 +1160,6 @@ ], "summary": "Delete Usage", "operationId": "delete_usage_health_usages__usage_id__delete", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "usage_id", @@ -1395,11 +1196,6 @@ ], "summary": "List Lab Results", "operationId": "list_lab_results_health_lab_results_get", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "q", @@ -1568,11 +1364,6 @@ ], "summary": "Create Lab Result", "operationId": "create_lab_result_health_lab_results_post", - "security": [ - { - "HTTPBearer": [] - } - ], "requestBody": { "required": true, "content": { @@ -1614,11 +1405,6 @@ ], "summary": "Get Lab Result", "operationId": "get_lab_result_health_lab_results__result_id__get", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "result_id", @@ -1660,11 +1446,6 @@ ], "summary": "Update Lab Result", "operationId": "update_lab_result_health_lab_results__result_id__patch", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "result_id", @@ -1716,11 +1497,6 @@ ], "summary": "Delete Lab Result", "operationId": "delete_lab_result_health_lab_results__result_id__delete", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "result_id", @@ -1757,11 +1533,6 @@ ], "summary": "List Routines", "operationId": "list_routines_routines_get", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "from_date", @@ -1841,11 +1612,6 @@ ], "summary": "Create Routine", "operationId": "create_routine_routines_post", - "security": [ - { - "HTTPBearer": [] - } - ], "requestBody": { "required": true, "content": { @@ -1918,12 +1684,7 @@ } } } - }, - "security": [ - { - "HTTPBearer": [] - } - ] + } } }, "/routines/suggest-batch": { @@ -1964,12 +1725,7 @@ } } } - }, - "security": [ - { - "HTTPBearer": [] - } - ] + } } }, "/routines/grooming-schedule": { @@ -1994,12 +1750,7 @@ } } } - }, - "security": [ - { - "HTTPBearer": [] - } - ] + } }, "post": { "tags": [ @@ -2038,12 +1789,7 @@ } } } - }, - "security": [ - { - "HTTPBearer": [] - } - ] + } } }, "/routines/{routine_id}": { @@ -2053,11 +1799,6 @@ ], "summary": "Get Routine", "operationId": "get_routine_routines__routine_id__get", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "routine_id", @@ -2097,11 +1838,6 @@ ], "summary": "Update Routine", "operationId": "update_routine_routines__routine_id__patch", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "routine_id", @@ -2153,11 +1889,6 @@ ], "summary": "Delete Routine", "operationId": "delete_routine_routines__routine_id__delete", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "routine_id", @@ -2194,11 +1925,6 @@ ], "summary": "Add Step", "operationId": "add_step_routines__routine_id__steps_post", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "routine_id", @@ -2252,11 +1978,6 @@ ], "summary": "Update Step", "operationId": "update_step_routines_steps__step_id__patch", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "step_id", @@ -2308,11 +2029,6 @@ ], "summary": "Delete Step", "operationId": "delete_step_routines_steps__step_id__delete", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "step_id", @@ -2349,11 +2065,6 @@ ], "summary": "Update Grooming Schedule", "operationId": "update_grooming_schedule_routines_grooming_schedule__entry_id__patch", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "entry_id", @@ -2405,11 +2116,6 @@ ], "summary": "Delete Grooming Schedule", "operationId": "delete_grooming_schedule_routines_grooming_schedule__entry_id__delete", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "entry_id", @@ -2477,12 +2183,7 @@ } } } - }, - "security": [ - { - "HTTPBearer": [] - } - ] + } } }, "/skincare": { @@ -2492,11 +2193,6 @@ ], "summary": "List Snapshots", "operationId": "list_snapshots_skincare_get", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "from_date", @@ -2582,11 +2278,6 @@ ], "summary": "Create Snapshot", "operationId": "create_snapshot_skincare_post", - "security": [ - { - "HTTPBearer": [] - } - ], "requestBody": { "required": true, "content": { @@ -2628,11 +2319,6 @@ ], "summary": "Get Snapshot", "operationId": "get_snapshot_skincare__snapshot_id__get", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "snapshot_id", @@ -2674,11 +2360,6 @@ ], "summary": "Update Snapshot", "operationId": "update_snapshot_skincare__snapshot_id__patch", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "snapshot_id", @@ -2730,11 +2411,6 @@ ], "summary": "Delete Snapshot", "operationId": "delete_snapshot_skincare__snapshot_id__delete", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "snapshot_id", @@ -2771,11 +2447,6 @@ ], "summary": "List Ai Logs", "operationId": "list_ai_logs_ai_logs_get", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "endpoint", @@ -2855,11 +2526,6 @@ ], "summary": "Get Ai Log", "operationId": "get_ai_log_ai_logs__log_id__get", - "security": [ - { - "HTTPBearer": [] - } - ], "parameters": [ { "name": "log_id", @@ -2922,18 +2588,6 @@ "format": "uuid", "title": "Id" }, - "user_id": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ], - "title": "User Id" - }, "created_at": { "type": "string", "format": "date-time", @@ -3317,198 +2971,6 @@ ], "title": "ActiveIngredient" }, - "AuthHouseholdMembershipPublic": { - "properties": { - "household_id": { - "type": "string", - "format": "uuid", - "title": "Household Id" - }, - "role": { - "$ref": "#/components/schemas/HouseholdRole" - } - }, - "type": "object", - "required": [ - "household_id", - "role" - ], - "title": "AuthHouseholdMembershipPublic" - }, - "AuthIdentityPublic": { - "properties": { - "issuer": { - "type": "string", - "title": "Issuer" - }, - "subject": { - "type": "string", - "title": "Subject" - }, - "email": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Email" - }, - "name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Name" - }, - "preferred_username": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Preferred Username" - }, - "groups": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Groups" - } - }, - "type": "object", - "required": [ - "issuer", - "subject" - ], - "title": "AuthIdentityPublic" - }, - "AuthProfilePublic": { - "properties": { - "id": { - "type": "string", - "format": "uuid", - "title": "Id" - }, - "user_id": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ], - "title": "User Id" - }, - "birth_date": { - "anyOf": [ - { - "type": "string", - "format": "date" - }, - { - "type": "null" - } - ], - "title": "Birth Date" - }, - "sex_at_birth": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Sex At Birth" - }, - "created_at": { - "type": "string", - "format": "date-time", - "title": "Created At" - }, - "updated_at": { - "type": "string", - "format": "date-time", - "title": "Updated At" - } - }, - "type": "object", - "required": [ - "id", - "user_id", - "created_at", - "updated_at" - ], - "title": "AuthProfilePublic" - }, - "AuthSessionResponse": { - "properties": { - "user": { - "$ref": "#/components/schemas/AuthUserPublic" - }, - "identity": { - "$ref": "#/components/schemas/AuthIdentityPublic" - }, - "profile": { - "anyOf": [ - { - "$ref": "#/components/schemas/AuthProfilePublic" - }, - { - "type": "null" - } - ] - } - }, - "type": "object", - "required": [ - "user", - "identity" - ], - "title": "AuthSessionResponse" - }, - "AuthUserPublic": { - "properties": { - "id": { - "type": "string", - "format": "uuid", - "title": "Id" - }, - "role": { - "$ref": "#/components/schemas/Role" - }, - "household_membership": { - "anyOf": [ - { - "$ref": "#/components/schemas/AuthHouseholdMembershipPublic" - }, - { - "type": "null" - } - ] - } - }, - "type": "object", - "required": [ - "id", - "role" - ], - "title": "AuthUserPublic" - }, "BarrierState": { "type": "string", "enum": [ @@ -3654,18 +3116,6 @@ "format": "uuid", "title": "Id" }, - "user_id": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ], - "title": "User Id" - }, "day_of_week": { "type": "integer", "maximum": 6.0, @@ -3773,14 +3223,6 @@ "type": "object", "title": "HTTPValidationError" }, - "HouseholdRole": { - "type": "string", - "enum": [ - "owner", - "member" - ], - "title": "HouseholdRole" - }, "IngredientFunction": { "type": "string", "enum": [ @@ -3956,18 +3398,6 @@ "format": "uuid", "title": "Record Id" }, - "user_id": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ], - "title": "User Id" - }, "collected_at": { "type": "string", "format": "date-time", @@ -4612,18 +4042,6 @@ "format": "uuid", "title": "Record Id" }, - "user_id": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ], - "title": "User Id" - }, "kind": { "$ref": "#/components/schemas/MedicationKind" }, @@ -4804,18 +4222,6 @@ "format": "uuid", "title": "Record Id" }, - "user_id": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ], - "title": "User Id" - }, "medication_record_id": { "type": "string", "format": "uuid", @@ -5464,28 +4870,11 @@ "format": "uuid", "title": "Id" }, - "user_id": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ], - "title": "User Id" - }, "product_id": { "type": "string", "format": "uuid", "title": "Product Id" }, - "is_household_shared": { - "type": "boolean", - "title": "Is Household Shared", - "default": false - }, "is_opened": { "type": "boolean", "title": "Is Opened", @@ -7299,14 +6688,6 @@ ], "title": "ResultFlag" }, - "Role": { - "type": "string", - "enum": [ - "admin", - "member" - ], - "title": "Role" - }, "Routine": { "properties": { "id": { @@ -7314,18 +6695,6 @@ "format": "uuid", "title": "Id" }, - "user_id": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ], - "title": "User Id" - }, "routine_date": { "type": "string", "format": "date", @@ -7399,18 +6768,6 @@ "format": "uuid", "title": "Id" }, - "user_id": { - "anyOf": [ - { - "type": "string", - "format": "uuid" - }, - { - "type": "null" - } - ], - "title": "User Id" - }, "routine_id": { "type": "string", "format": "uuid", @@ -7755,81 +7112,6 @@ "type": "object", "title": "RoutineUpdate" }, - "SessionSyncRequest": { - "properties": { - "iss": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Iss" - }, - "sub": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Sub" - }, - "email": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Email" - }, - "name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Name" - }, - "preferred_username": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Preferred Username" - }, - "groups": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "title": "Groups" - } - }, - "type": "object", - "title": "SessionSyncRequest" - }, "SexAtBirth": { "type": "string", "enum": [ @@ -9066,12 +8348,6 @@ ], "title": "ValidationError" } - }, - "securitySchemes": { - "HTTPBearer": { - "type": "http", - "scheme": "bearer" - } } } } diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index 33ab713..520c421 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -1,15 +1,9 @@ -import type { AppSession } from "$lib/server/auth"; -import type { AuthUserPublic } from "$lib/api/generated/types.gen"; - // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces declare global { namespace App { // interface Error {} - interface Locals { - session: AppSession | null; - user: AuthUserPublic | null; - } + // interface Locals {} // interface PageData {} // interface PageState {} // interface Platform {} diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 1983535..cb5906d 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -1,38 +1,6 @@ import { paraglideMiddleware } from "$lib/paraglide/server.js"; -import { - buildLoginPath, - getRequestSession, - isBackendRequest, - isProtectedPath, - loadAuthenticatedSession, -} from "$lib/server/auth"; -import type { Handle, HandleFetch } from "@sveltejs/kit"; -import { redirect } from "@sveltejs/kit"; +import type { Handle } from "@sveltejs/kit"; export const handle: Handle = async ({ event, resolve }) => { - return paraglideMiddleware(event.request, async () => { - const session = await loadAuthenticatedSession(event); - event.locals.session = session; - event.locals.user = session?.user ?? null; - - if (!session && isProtectedPath(event.url.pathname)) { - throw redirect(303, buildLoginPath(event.url)); - } - - return resolve(event); - }); -}; - -export const handleFetch: HandleFetch = async ({ event, request, fetch }) => { - const session = getRequestSession(event); - if (!session || !isBackendRequest(new URL(request.url), event)) { - return fetch(request); - } - - const headers = new Headers(request.headers); - if (!headers.has("authorization")) { - headers.set("authorization", `Bearer ${session.accessToken}`); - } - - return fetch(new Request(request, { headers })); + return paraglideMiddleware(event.request, () => resolve(event)); }; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f9bd236..7a0327f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -20,87 +20,43 @@ import type { UserProfile, } from "./types"; -export interface ApiClientOptions { - fetch?: typeof globalThis.fetch; - accessToken?: string; -} +// ─── Core fetch helpers ────────────────────────────────────────────────────── -function resolveBase(options: ApiClientOptions): string { - if (browser && !options.accessToken) { - return "/api"; - } - - return PUBLIC_API_BASE; -} - -function buildHeaders( - initHeaders: HeadersInit | undefined, - body: BodyInit | null | undefined, - accessToken?: string, -): Headers { - const headers = new Headers(initHeaders); - - if (!(body instanceof FormData) && body !== undefined && body !== null) { - headers.set("Content-Type", headers.get("Content-Type") ?? "application/json"); - } - - if (accessToken && !headers.has("Authorization")) { - headers.set("Authorization", `Bearer ${accessToken}`); - } - - return headers; -} - -async function request( - path: string, - init: RequestInit = {}, - options: ApiClientOptions = {}, -): Promise { - const url = `${resolveBase(options)}${path}`; - const requestFn = options.fetch ?? fetch; - const res = await requestFn(url, { - headers: buildHeaders(init.headers, init.body, options.accessToken), +async function request(path: string, init: RequestInit = {}): Promise { + // Server-side uses PUBLIC_API_BASE (e.g. http://localhost:8000). + // Browser-side uses /api so nginx proxies the request on the correct host. + const base = browser ? "/api" : PUBLIC_API_BASE; + const url = `${base}${path}`; + const res = await fetch(url, { + headers: { "Content-Type": "application/json", ...init.headers }, ...init, }); - if (!res.ok) { const detail = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(detail?.detail ?? res.statusText); } - if (res.status === 204) return undefined as T; return res.json(); } -export function createApiClient(options: ApiClientOptions = {}) { - return { - get: (path: string) => request(path, {}, options), - post: (path: string, body: unknown) => - request(path, { method: "POST", body: JSON.stringify(body) }, options), - postForm: (path: string, body: FormData) => - request(path, { method: "POST", body }, options), - patch: (path: string, body: unknown) => - request(path, { method: "PATCH", body: JSON.stringify(body) }, options), - del: (path: string) => request(path, { method: "DELETE" }, options), - }; -} +export const api = { + get: (path: string) => request(path), + post: (path: string, body: unknown) => + request(path, { method: "POST", body: JSON.stringify(body) }), + patch: (path: string, body: unknown) => + request(path, { method: "PATCH", body: JSON.stringify(body) }), + del: (path: string) => request(path, { method: "DELETE" }), +}; -export type ApiClient = ReturnType; +// ─── Profile ───────────────────────────────────────────────────────────────── -export const api = createApiClient(); - -function resolveClient(options?: ApiClientOptions): ApiClient { - return options ? createApiClient(options) : api; -} - -export const getProfile = ( - options?: ApiClientOptions, -): Promise => resolveClient(options).get("/profile"); +export const getProfile = (): Promise => api.get("/profile"); export const updateProfile = ( body: { birth_date?: string; sex_at_birth?: "male" | "female" | "intersex" }, - options?: ApiClientOptions, -): Promise => resolveClient(options).patch("/profile", body); +): Promise => api.patch("/profile", body); + +// ─── Products ──────────────────────────────────────────────────────────────── export interface ProductListParams { category?: string; @@ -112,87 +68,62 @@ export interface ProductListParams { export function getProducts( params: ProductListParams = {}, - options?: ApiClientOptions, ): Promise { const q = new URLSearchParams(); if (params.category) q.set("category", params.category); if (params.brand) q.set("brand", params.brand); if (params.targets) params.targets.forEach((t) => q.append("targets", t)); - if (params.is_medication != null) { + if (params.is_medication != null) q.set("is_medication", String(params.is_medication)); - } if (params.is_tool != null) q.set("is_tool", String(params.is_tool)); const qs = q.toString(); - return resolveClient(options).get(`/products${qs ? `?${qs}` : ""}`); + return api.get(`/products${qs ? `?${qs}` : ""}`); } export function getProductSummaries( params: ProductListParams = {}, - options?: ApiClientOptions, ): Promise { const q = new URLSearchParams(); if (params.category) q.set("category", params.category); if (params.brand) q.set("brand", params.brand); if (params.targets) params.targets.forEach((t) => q.append("targets", t)); - if (params.is_medication != null) { + if (params.is_medication != null) q.set("is_medication", String(params.is_medication)); - } if (params.is_tool != null) q.set("is_tool", String(params.is_tool)); const qs = q.toString(); - return resolveClient(options).get(`/products/summary${qs ? `?${qs}` : ""}`); + return api.get(`/products/summary${qs ? `?${qs}` : ""}`); } -export const getProduct = ( - id: string, - options?: ApiClientOptions, -): Promise => resolveClient(options).get(`/products/${id}`); - +export const getProduct = (id: string): Promise => + api.get(`/products/${id}`); export const createProduct = ( body: Record, - options?: ApiClientOptions, -): Promise => resolveClient(options).post("/products", body); - +): Promise => api.post("/products", body); export const updateProduct = ( id: string, body: Record, - options?: ApiClientOptions, -): Promise => resolveClient(options).patch(`/products/${id}`, body); - -export const deleteProduct = ( - id: string, - options?: ApiClientOptions, -): Promise => resolveClient(options).del(`/products/${id}`); - -export const getInventory = ( - productId: string, - options?: ApiClientOptions, -): Promise => - resolveClient(options).get(`/products/${productId}/inventory`); +): Promise => api.patch(`/products/${id}`, body); +export const deleteProduct = (id: string): Promise => + api.del(`/products/${id}`); +export const getInventory = (productId: string): Promise => + api.get(`/products/${productId}/inventory`); export const createInventory = ( productId: string, body: Record, - options?: ApiClientOptions, ): Promise => - resolveClient(options).post(`/products/${productId}/inventory`, body); - + api.post(`/products/${productId}/inventory`, body); export const updateInventory = ( id: string, body: Record, - options?: ApiClientOptions, -): Promise => - resolveClient(options).patch(`/inventory/${id}`, body); +): Promise => api.patch(`/inventory/${id}`, body); +export const deleteInventory = (id: string): Promise => + api.del(`/inventory/${id}`); -export const deleteInventory = ( - id: string, - options?: ApiClientOptions, -): Promise => resolveClient(options).del(`/inventory/${id}`); +export const parseProductText = (text: string): Promise => + api.post("/products/parse-text", { text }); -export const parseProductText = ( - text: string, - options?: ApiClientOptions, -): Promise => - resolveClient(options).post("/products/parse-text", { text }); +// ─── Routines ──────────────────────────────────────────────────────────────── export interface RoutineListParams { from_date?: string; @@ -202,103 +133,68 @@ export interface RoutineListParams { export function getRoutines( params: RoutineListParams = {}, - options?: ApiClientOptions, ): Promise { const q = new URLSearchParams(); if (params.from_date) q.set("from_date", params.from_date); if (params.to_date) q.set("to_date", params.to_date); if (params.part_of_day) q.set("part_of_day", params.part_of_day); const qs = q.toString(); - return resolveClient(options).get(`/routines${qs ? `?${qs}` : ""}`); + return api.get(`/routines${qs ? `?${qs}` : ""}`); } -export const getRoutine = ( - id: string, - options?: ApiClientOptions, -): Promise => resolveClient(options).get(`/routines/${id}`); - +export const getRoutine = (id: string): Promise => + api.get(`/routines/${id}`); export const createRoutine = ( body: Record, - options?: ApiClientOptions, -): Promise => resolveClient(options).post("/routines", body); - +): Promise => api.post("/routines", body); export const updateRoutine = ( id: string, body: Record, - options?: ApiClientOptions, -): Promise => resolveClient(options).patch(`/routines/${id}`, body); - -export const deleteRoutine = ( - id: string, - options?: ApiClientOptions, -): Promise => resolveClient(options).del(`/routines/${id}`); +): Promise => api.patch(`/routines/${id}`, body); +export const deleteRoutine = (id: string): Promise => + api.del(`/routines/${id}`); export const addRoutineStep = ( routineId: string, body: Record, - options?: ApiClientOptions, -): Promise => - resolveClient(options).post(`/routines/${routineId}/steps`, body); - +): Promise => api.post(`/routines/${routineId}/steps`, body); export const updateRoutineStep = ( stepId: string, body: Record, - options?: ApiClientOptions, -): Promise => - resolveClient(options).patch(`/routines/steps/${stepId}`, body); +): Promise => api.patch(`/routines/steps/${stepId}`, body); +export const deleteRoutineStep = (stepId: string): Promise => + api.del(`/routines/steps/${stepId}`); -export const deleteRoutineStep = ( - stepId: string, - options?: ApiClientOptions, -): Promise => resolveClient(options).del(`/routines/steps/${stepId}`); +export const suggestRoutine = (body: { + routine_date: string; + part_of_day: PartOfDay; + notes?: string; + include_minoxidil_beard?: boolean; + leaving_home?: boolean; +}): Promise => api.post("/routines/suggest", body); -export const suggestRoutine = ( - body: { - routine_date: string; - part_of_day: PartOfDay; - notes?: string; - include_minoxidil_beard?: boolean; - leaving_home?: boolean; - }, - options?: ApiClientOptions, -): Promise => - resolveClient(options).post("/routines/suggest", body); - -export const suggestBatch = ( - body: { - from_date: string; - to_date: string; - notes?: string; - include_minoxidil_beard?: boolean; - minimize_products?: boolean; - }, - options?: ApiClientOptions, -): Promise => - resolveClient(options).post("/routines/suggest-batch", body); - -export const getGroomingSchedule = ( - options?: ApiClientOptions, -): Promise => - resolveClient(options).get("/routines/grooming-schedule"); +export const suggestBatch = (body: { + from_date: string; + to_date: string; + notes?: string; + include_minoxidil_beard?: boolean; + minimize_products?: boolean; +}): Promise => api.post("/routines/suggest-batch", body); +export const getGroomingSchedule = (): Promise => + api.get("/routines/grooming-schedule"); export const createGroomingScheduleEntry = ( body: Record, - options?: ApiClientOptions, -): Promise => - resolveClient(options).post("/routines/grooming-schedule", body); - +): Promise => api.post("/routines/grooming-schedule", body); export const updateGroomingScheduleEntry = ( id: string, body: Record, - options?: ApiClientOptions, ): Promise => - resolveClient(options).patch(`/routines/grooming-schedule/${id}`, body); + api.patch(`/routines/grooming-schedule/${id}`, body); +export const deleteGroomingScheduleEntry = (id: string): Promise => + api.del(`/routines/grooming-schedule/${id}`); -export const deleteGroomingScheduleEntry = ( - id: string, - options?: ApiClientOptions, -): Promise => - resolveClient(options).del(`/routines/grooming-schedule/${id}`); +// ─── Health – Medications ──────────────────────────────────────────────────── export interface MedicationListParams { kind?: string; @@ -307,51 +203,37 @@ export interface MedicationListParams { export function getMedications( params: MedicationListParams = {}, - options?: ApiClientOptions, ): Promise { const q = new URLSearchParams(); if (params.kind) q.set("kind", params.kind); if (params.product_name) q.set("product_name", params.product_name); const qs = q.toString(); - return resolveClient(options).get(`/health/medications${qs ? `?${qs}` : ""}`); + return api.get(`/health/medications${qs ? `?${qs}` : ""}`); } -export const getMedication = ( - id: string, - options?: ApiClientOptions, -): Promise => - resolveClient(options).get(`/health/medications/${id}`); - +export const getMedication = (id: string): Promise => + api.get(`/health/medications/${id}`); export const createMedication = ( body: Record, - options?: ApiClientOptions, -): Promise => - resolveClient(options).post("/health/medications", body); - +): Promise => api.post("/health/medications", body); export const updateMedication = ( id: string, body: Record, - options?: ApiClientOptions, -): Promise => - resolveClient(options).patch(`/health/medications/${id}`, body); - -export const deleteMedication = ( - id: string, - options?: ApiClientOptions, -): Promise => resolveClient(options).del(`/health/medications/${id}`); +): Promise => api.patch(`/health/medications/${id}`, body); +export const deleteMedication = (id: string): Promise => + api.del(`/health/medications/${id}`); export const getMedicationUsages = ( medicationId: string, - options?: ApiClientOptions, ): Promise => - resolveClient(options).get(`/health/medications/${medicationId}/usages`); - + api.get(`/health/medications/${medicationId}/usages`); export const createMedicationUsage = ( medicationId: string, body: Record, - options?: ApiClientOptions, ): Promise => - resolveClient(options).post(`/health/medications/${medicationId}/usages`, body); + api.post(`/health/medications/${medicationId}/usages`, body); + +// ─── Health – Lab results ──────────────────────────────────────────────────── export interface LabResultListParams { q?: string; @@ -368,7 +250,6 @@ export interface LabResultListParams { export function getLabResults( params: LabResultListParams = {}, - options?: ApiClientOptions, ): Promise { const q = new URLSearchParams(); if (params.q) q.set("q", params.q); @@ -377,43 +258,29 @@ export function getLabResults( if (params.flags?.length) { for (const flag of params.flags) q.append("flags", flag); } - if (params.without_flag != null) { - q.set("without_flag", String(params.without_flag)); - } + if (params.without_flag != null) q.set("without_flag", String(params.without_flag)); if (params.from_date) q.set("from_date", params.from_date); if (params.to_date) q.set("to_date", params.to_date); - if (params.latest_only != null) { - q.set("latest_only", String(params.latest_only)); - } + if (params.latest_only != null) q.set("latest_only", String(params.latest_only)); if (params.limit != null) q.set("limit", String(params.limit)); if (params.offset != null) q.set("offset", String(params.offset)); const qs = q.toString(); - return resolveClient(options).get(`/health/lab-results${qs ? `?${qs}` : ""}`); + return api.get(`/health/lab-results${qs ? `?${qs}` : ""}`); } -export const getLabResult = ( - id: string, - options?: ApiClientOptions, -): Promise => - resolveClient(options).get(`/health/lab-results/${id}`); - +export const getLabResult = (id: string): Promise => + api.get(`/health/lab-results/${id}`); export const createLabResult = ( body: Record, - options?: ApiClientOptions, -): Promise => - resolveClient(options).post("/health/lab-results", body); - +): Promise => api.post("/health/lab-results", body); export const updateLabResult = ( id: string, body: Record, - options?: ApiClientOptions, -): Promise => - resolveClient(options).patch(`/health/lab-results/${id}`, body); +): Promise => api.patch(`/health/lab-results/${id}`, body); +export const deleteLabResult = (id: string): Promise => + api.del(`/health/lab-results/${id}`); -export const deleteLabResult = ( - id: string, - options?: ApiClientOptions, -): Promise => resolveClient(options).del(`/health/lab-results/${id}`); +// ─── Skin ──────────────────────────────────────────────────────────────────── export interface SnapshotListParams { from_date?: string; @@ -423,47 +290,40 @@ export interface SnapshotListParams { export function getSkinSnapshots( params: SnapshotListParams = {}, - options?: ApiClientOptions, ): Promise { const q = new URLSearchParams(); if (params.from_date) q.set("from_date", params.from_date); if (params.to_date) q.set("to_date", params.to_date); if (params.overall_state) q.set("overall_state", params.overall_state); const qs = q.toString(); - return resolveClient(options).get(`/skincare${qs ? `?${qs}` : ""}`); + return api.get(`/skincare${qs ? `?${qs}` : ""}`); } -export const getSkinSnapshot = ( - id: string, - options?: ApiClientOptions, -): Promise => resolveClient(options).get(`/skincare/${id}`); - +export const getSkinSnapshot = (id: string): Promise => + api.get(`/skincare/${id}`); export const createSkinSnapshot = ( body: Record, - options?: ApiClientOptions, -): Promise => resolveClient(options).post("/skincare", body); - +): Promise => api.post("/skincare", body); export const updateSkinSnapshot = ( id: string, body: Record, - options?: ApiClientOptions, -): Promise => - resolveClient(options).patch(`/skincare/${id}`, body); - -export const deleteSkinSnapshot = ( - id: string, - options?: ApiClientOptions, -): Promise => resolveClient(options).del(`/skincare/${id}`); +): Promise => api.patch(`/skincare/${id}`, body); +export const deleteSkinSnapshot = (id: string): Promise => + api.del(`/skincare/${id}`); export async function analyzeSkinPhotos( - files: File[] | FormData, - options?: ApiClientOptions, + files: File[], ): Promise { - const body = files instanceof FormData ? files : new FormData(); - - if (!(files instanceof FormData)) { - for (const file of files) body.append("photos", file); + const body = new FormData(); + for (const file of files) body.append("photos", file); + const base = browser ? "/api" : PUBLIC_API_BASE; + const res = await fetch(`${base}/skincare/analyze-photos`, { + method: "POST", + body, + }); + if (!res.ok) { + const detail = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(detail?.detail ?? res.statusText); } - - return resolveClient(options).postForm("/skincare/analyze-photos", body); + return res.json(); } diff --git a/frontend/src/lib/api/generated/index.ts b/frontend/src/lib/api/generated/index.ts index 2bcdc53..5b04f4e 100644 --- a/frontend/src/lib/api/generated/index.ts +++ b/frontend/src/lib/api/generated/index.ts @@ -1,3 +1,3 @@ // This file is auto-generated by @hey-api/openapi-ts -export type { AbsorptionSpeed, ActiveIngredient, AddStepRoutinesRoutineIdStepsPostData, AddStepRoutinesRoutineIdStepsPostError, AddStepRoutinesRoutineIdStepsPostErrors, AddStepRoutinesRoutineIdStepsPostResponse, AddStepRoutinesRoutineIdStepsPostResponses, AiCallLog, AiCallLogPublic, AnalyzeSkinPhotosSkincareAnalyzePhotosPostData, AnalyzeSkinPhotosSkincareAnalyzePhotosPostError, AnalyzeSkinPhotosSkincareAnalyzePhotosPostErrors, AnalyzeSkinPhotosSkincareAnalyzePhotosPostResponse, AnalyzeSkinPhotosSkincareAnalyzePhotosPostResponses, AuthHouseholdMembershipPublic, AuthIdentityPublic, AuthProfilePublic, AuthSessionResponse, AuthUserPublic, BarrierState, BatchSuggestion, BodyAnalyzeSkinPhotosSkincareAnalyzePhotosPost, ClientOptions, CreateGroomingScheduleRoutinesGroomingSchedulePostData, CreateGroomingScheduleRoutinesGroomingSchedulePostError, CreateGroomingScheduleRoutinesGroomingSchedulePostErrors, CreateGroomingScheduleRoutinesGroomingSchedulePostResponse, CreateGroomingScheduleRoutinesGroomingSchedulePostResponses, CreateLabResultHealthLabResultsPostData, CreateLabResultHealthLabResultsPostError, CreateLabResultHealthLabResultsPostErrors, CreateLabResultHealthLabResultsPostResponse, CreateLabResultHealthLabResultsPostResponses, CreateMedicationHealthMedicationsPostData, CreateMedicationHealthMedicationsPostError, CreateMedicationHealthMedicationsPostErrors, CreateMedicationHealthMedicationsPostResponse, CreateMedicationHealthMedicationsPostResponses, CreateProductInventoryProductsProductIdInventoryPostData, CreateProductInventoryProductsProductIdInventoryPostError, CreateProductInventoryProductsProductIdInventoryPostErrors, CreateProductInventoryProductsProductIdInventoryPostResponse, CreateProductInventoryProductsProductIdInventoryPostResponses, CreateProductProductsPostData, CreateProductProductsPostError, CreateProductProductsPostErrors, CreateProductProductsPostResponse, CreateProductProductsPostResponses, CreateRoutineRoutinesPostData, CreateRoutineRoutinesPostError, CreateRoutineRoutinesPostErrors, CreateRoutineRoutinesPostResponse, CreateRoutineRoutinesPostResponses, CreateSnapshotSkincarePostData, CreateSnapshotSkincarePostError, CreateSnapshotSkincarePostErrors, CreateSnapshotSkincarePostResponse, CreateSnapshotSkincarePostResponses, CreateUsageHealthMedicationsMedicationIdUsagesPostData, CreateUsageHealthMedicationsMedicationIdUsagesPostError, CreateUsageHealthMedicationsMedicationIdUsagesPostErrors, CreateUsageHealthMedicationsMedicationIdUsagesPostResponse, CreateUsageHealthMedicationsMedicationIdUsagesPostResponses, DayPlan, DayTime, DeleteGroomingScheduleRoutinesGroomingScheduleEntryIdDeleteData, DeleteGroomingScheduleRoutinesGroomingScheduleEntryIdDeleteError, DeleteGroomingScheduleRoutinesGroomingScheduleEntryIdDeleteErrors, DeleteGroomingScheduleRoutinesGroomingScheduleEntryIdDeleteResponse, DeleteGroomingScheduleRoutinesGroomingScheduleEntryIdDeleteResponses, DeleteInventoryInventoryInventoryIdDeleteData, DeleteInventoryInventoryInventoryIdDeleteError, DeleteInventoryInventoryInventoryIdDeleteErrors, DeleteInventoryInventoryInventoryIdDeleteResponse, DeleteInventoryInventoryInventoryIdDeleteResponses, DeleteLabResultHealthLabResultsResultIdDeleteData, DeleteLabResultHealthLabResultsResultIdDeleteError, DeleteLabResultHealthLabResultsResultIdDeleteErrors, DeleteLabResultHealthLabResultsResultIdDeleteResponse, DeleteLabResultHealthLabResultsResultIdDeleteResponses, DeleteMedicationHealthMedicationsMedicationIdDeleteData, DeleteMedicationHealthMedicationsMedicationIdDeleteError, DeleteMedicationHealthMedicationsMedicationIdDeleteErrors, DeleteMedicationHealthMedicationsMedicationIdDeleteResponse, DeleteMedicationHealthMedicationsMedicationIdDeleteResponses, DeleteProductProductsProductIdDeleteData, DeleteProductProductsProductIdDeleteError, DeleteProductProductsProductIdDeleteErrors, DeleteProductProductsProductIdDeleteResponse, DeleteProductProductsProductIdDeleteResponses, DeleteRoutineRoutinesRoutineIdDeleteData, DeleteRoutineRoutinesRoutineIdDeleteError, DeleteRoutineRoutinesRoutineIdDeleteErrors, DeleteRoutineRoutinesRoutineIdDeleteResponse, DeleteRoutineRoutinesRoutineIdDeleteResponses, DeleteSnapshotSkincareSnapshotIdDeleteData, DeleteSnapshotSkincareSnapshotIdDeleteError, DeleteSnapshotSkincareSnapshotIdDeleteErrors, DeleteSnapshotSkincareSnapshotIdDeleteResponse, DeleteSnapshotSkincareSnapshotIdDeleteResponses, DeleteStepRoutinesStepsStepIdDeleteData, DeleteStepRoutinesStepsStepIdDeleteError, DeleteStepRoutinesStepsStepIdDeleteErrors, DeleteStepRoutinesStepsStepIdDeleteResponse, DeleteStepRoutinesStepsStepIdDeleteResponses, DeleteUsageHealthUsagesUsageIdDeleteData, DeleteUsageHealthUsagesUsageIdDeleteError, DeleteUsageHealthUsagesUsageIdDeleteErrors, DeleteUsageHealthUsagesUsageIdDeleteResponse, DeleteUsageHealthUsagesUsageIdDeleteResponses, GetAiLogAiLogsLogIdGetData, GetAiLogAiLogsLogIdGetError, GetAiLogAiLogsLogIdGetErrors, GetAiLogAiLogsLogIdGetResponse, GetAiLogAiLogsLogIdGetResponses, GetInventoryInventoryInventoryIdGetData, GetInventoryInventoryInventoryIdGetError, GetInventoryInventoryInventoryIdGetErrors, GetInventoryInventoryInventoryIdGetResponse, GetInventoryInventoryInventoryIdGetResponses, GetLabResultHealthLabResultsResultIdGetData, GetLabResultHealthLabResultsResultIdGetError, GetLabResultHealthLabResultsResultIdGetErrors, GetLabResultHealthLabResultsResultIdGetResponse, GetLabResultHealthLabResultsResultIdGetResponses, GetMeAuthMeGetData, GetMeAuthMeGetResponse, GetMeAuthMeGetResponses, GetMedicationHealthMedicationsMedicationIdGetData, GetMedicationHealthMedicationsMedicationIdGetError, GetMedicationHealthMedicationsMedicationIdGetErrors, GetMedicationHealthMedicationsMedicationIdGetResponse, GetMedicationHealthMedicationsMedicationIdGetResponses, GetProductProductsProductIdGetData, GetProductProductsProductIdGetError, GetProductProductsProductIdGetErrors, GetProductProductsProductIdGetResponse, GetProductProductsProductIdGetResponses, GetProfileProfileGetData, GetProfileProfileGetResponse, GetProfileProfileGetResponses, GetRoutineRoutinesRoutineIdGetData, GetRoutineRoutinesRoutineIdGetError, GetRoutineRoutinesRoutineIdGetErrors, GetRoutineRoutinesRoutineIdGetResponses, GetSnapshotSkincareSnapshotIdGetData, GetSnapshotSkincareSnapshotIdGetError, GetSnapshotSkincareSnapshotIdGetErrors, GetSnapshotSkincareSnapshotIdGetResponse, GetSnapshotSkincareSnapshotIdGetResponses, GroomingAction, GroomingSchedule, GroomingScheduleCreate, GroomingScheduleUpdate, HealthCheckHealthCheckGetData, HealthCheckHealthCheckGetResponses, HouseholdRole, HttpValidationError, IngredientFunction, InventoryCreate, InventoryUpdate, LabResult, LabResultCreate, LabResultListResponse, LabResultUpdate, ListAiLogsAiLogsGetData, ListAiLogsAiLogsGetError, ListAiLogsAiLogsGetErrors, ListAiLogsAiLogsGetResponse, ListAiLogsAiLogsGetResponses, ListGroomingScheduleRoutinesGroomingScheduleGetData, ListGroomingScheduleRoutinesGroomingScheduleGetResponse, ListGroomingScheduleRoutinesGroomingScheduleGetResponses, ListLabResultsHealthLabResultsGetData, ListLabResultsHealthLabResultsGetError, ListLabResultsHealthLabResultsGetErrors, ListLabResultsHealthLabResultsGetResponse, ListLabResultsHealthLabResultsGetResponses, ListMedicationsHealthMedicationsGetData, ListMedicationsHealthMedicationsGetError, ListMedicationsHealthMedicationsGetErrors, ListMedicationsHealthMedicationsGetResponse, ListMedicationsHealthMedicationsGetResponses, ListProductInventoryProductsProductIdInventoryGetData, ListProductInventoryProductsProductIdInventoryGetError, ListProductInventoryProductsProductIdInventoryGetErrors, ListProductInventoryProductsProductIdInventoryGetResponse, ListProductInventoryProductsProductIdInventoryGetResponses, ListProductsProductsGetData, ListProductsProductsGetError, ListProductsProductsGetErrors, ListProductsProductsGetResponse, ListProductsProductsGetResponses, ListProductsSummaryProductsSummaryGetData, ListProductsSummaryProductsSummaryGetError, ListProductsSummaryProductsSummaryGetErrors, ListProductsSummaryProductsSummaryGetResponse, ListProductsSummaryProductsSummaryGetResponses, ListRoutinesRoutinesGetData, ListRoutinesRoutinesGetError, ListRoutinesRoutinesGetErrors, ListRoutinesRoutinesGetResponses, ListSnapshotsSkincareGetData, ListSnapshotsSkincareGetError, ListSnapshotsSkincareGetErrors, ListSnapshotsSkincareGetResponse, ListSnapshotsSkincareGetResponses, ListUsagesHealthMedicationsMedicationIdUsagesGetData, ListUsagesHealthMedicationsMedicationIdUsagesGetError, ListUsagesHealthMedicationsMedicationIdUsagesGetErrors, ListUsagesHealthMedicationsMedicationIdUsagesGetResponse, ListUsagesHealthMedicationsMedicationIdUsagesGetResponses, MedicationCreate, MedicationEntry, MedicationKind, MedicationUpdate, MedicationUsage, OverallSkinState, ParseProductTextProductsParseTextPostData, ParseProductTextProductsParseTextPostError, ParseProductTextProductsParseTextPostErrors, ParseProductTextProductsParseTextPostResponse, ParseProductTextProductsParseTextPostResponses, PartOfDay, PriceTier, ProductCategory, ProductContext, ProductCreate, ProductEffectProfile, ProductInventory, ProductListItem, ProductParseRequest, ProductParseResponse, ProductPublic, ProductSuggestion, ProductUpdate, ProductWithInventory, RemainingLevel, ResponseMetadata, ResultFlag, Role, Routine, RoutineCreate, RoutineStep, RoutineStepCreate, RoutineStepUpdate, RoutineSuggestion, RoutineSuggestionSummary, RoutineUpdate, SessionSyncRequest, SexAtBirth, ShoppingSuggestionResponse, SkinConcern, SkinConditionSnapshotPublic, SkinPhotoAnalysisResponse, SkinTexture, SkinType, SnapshotCreate, SnapshotUpdate, StrengthLevel, SuggestBatchRequest, SuggestBatchRoutinesSuggestBatchPostData, SuggestBatchRoutinesSuggestBatchPostError, SuggestBatchRoutinesSuggestBatchPostErrors, SuggestBatchRoutinesSuggestBatchPostResponse, SuggestBatchRoutinesSuggestBatchPostResponses, SuggestedStep, SuggestRoutineRequest, SuggestRoutineRoutinesSuggestPostData, SuggestRoutineRoutinesSuggestPostError, SuggestRoutineRoutinesSuggestPostErrors, SuggestRoutineRoutinesSuggestPostResponse, SuggestRoutineRoutinesSuggestPostResponses, SuggestShoppingProductsSuggestPostData, SuggestShoppingProductsSuggestPostResponse, SuggestShoppingProductsSuggestPostResponses, SyncSessionAuthSessionSyncPostData, SyncSessionAuthSessionSyncPostError, SyncSessionAuthSessionSyncPostErrors, SyncSessionAuthSessionSyncPostResponse, SyncSessionAuthSessionSyncPostResponses, TextureType, TokenMetrics, UpdateGroomingScheduleRoutinesGroomingScheduleEntryIdPatchData, UpdateGroomingScheduleRoutinesGroomingScheduleEntryIdPatchError, UpdateGroomingScheduleRoutinesGroomingScheduleEntryIdPatchErrors, UpdateGroomingScheduleRoutinesGroomingScheduleEntryIdPatchResponse, UpdateGroomingScheduleRoutinesGroomingScheduleEntryIdPatchResponses, UpdateInventoryInventoryInventoryIdPatchData, UpdateInventoryInventoryInventoryIdPatchError, UpdateInventoryInventoryInventoryIdPatchErrors, UpdateInventoryInventoryInventoryIdPatchResponse, UpdateInventoryInventoryInventoryIdPatchResponses, UpdateLabResultHealthLabResultsResultIdPatchData, UpdateLabResultHealthLabResultsResultIdPatchError, UpdateLabResultHealthLabResultsResultIdPatchErrors, UpdateLabResultHealthLabResultsResultIdPatchResponse, UpdateLabResultHealthLabResultsResultIdPatchResponses, UpdateMedicationHealthMedicationsMedicationIdPatchData, UpdateMedicationHealthMedicationsMedicationIdPatchError, UpdateMedicationHealthMedicationsMedicationIdPatchErrors, UpdateMedicationHealthMedicationsMedicationIdPatchResponse, UpdateMedicationHealthMedicationsMedicationIdPatchResponses, UpdateProductProductsProductIdPatchData, UpdateProductProductsProductIdPatchError, UpdateProductProductsProductIdPatchErrors, UpdateProductProductsProductIdPatchResponse, UpdateProductProductsProductIdPatchResponses, UpdateRoutineRoutinesRoutineIdPatchData, UpdateRoutineRoutinesRoutineIdPatchError, UpdateRoutineRoutinesRoutineIdPatchErrors, UpdateRoutineRoutinesRoutineIdPatchResponse, UpdateRoutineRoutinesRoutineIdPatchResponses, UpdateSnapshotSkincareSnapshotIdPatchData, UpdateSnapshotSkincareSnapshotIdPatchError, UpdateSnapshotSkincareSnapshotIdPatchErrors, UpdateSnapshotSkincareSnapshotIdPatchResponse, UpdateSnapshotSkincareSnapshotIdPatchResponses, UpdateStepRoutinesStepsStepIdPatchData, UpdateStepRoutinesStepsStepIdPatchError, UpdateStepRoutinesStepsStepIdPatchErrors, UpdateStepRoutinesStepsStepIdPatchResponse, UpdateStepRoutinesStepsStepIdPatchResponses, UpdateUsageHealthUsagesUsageIdPatchData, UpdateUsageHealthUsagesUsageIdPatchError, UpdateUsageHealthUsagesUsageIdPatchErrors, UpdateUsageHealthUsagesUsageIdPatchResponse, UpdateUsageHealthUsagesUsageIdPatchResponses, UpsertProfileProfilePatchData, UpsertProfileProfilePatchError, UpsertProfileProfilePatchErrors, UpsertProfileProfilePatchResponse, UpsertProfileProfilePatchResponses, UsageCreate, UsageUpdate, UserProfilePublic, UserProfileUpdate, ValidationError } from './types.gen'; +export type { AbsorptionSpeed, ActiveIngredient, AddStepRoutinesRoutineIdStepsPostData, AddStepRoutinesRoutineIdStepsPostError, AddStepRoutinesRoutineIdStepsPostErrors, AddStepRoutinesRoutineIdStepsPostResponse, AddStepRoutinesRoutineIdStepsPostResponses, AiCallLog, AiCallLogPublic, AnalyzeSkinPhotosSkincareAnalyzePhotosPostData, AnalyzeSkinPhotosSkincareAnalyzePhotosPostError, AnalyzeSkinPhotosSkincareAnalyzePhotosPostErrors, AnalyzeSkinPhotosSkincareAnalyzePhotosPostResponse, AnalyzeSkinPhotosSkincareAnalyzePhotosPostResponses, BarrierState, BatchSuggestion, BodyAnalyzeSkinPhotosSkincareAnalyzePhotosPost, ClientOptions, CreateGroomingScheduleRoutinesGroomingSchedulePostData, CreateGroomingScheduleRoutinesGroomingSchedulePostError, CreateGroomingScheduleRoutinesGroomingSchedulePostErrors, CreateGroomingScheduleRoutinesGroomingSchedulePostResponse, CreateGroomingScheduleRoutinesGroomingSchedulePostResponses, CreateLabResultHealthLabResultsPostData, CreateLabResultHealthLabResultsPostError, CreateLabResultHealthLabResultsPostErrors, CreateLabResultHealthLabResultsPostResponse, CreateLabResultHealthLabResultsPostResponses, CreateMedicationHealthMedicationsPostData, CreateMedicationHealthMedicationsPostError, CreateMedicationHealthMedicationsPostErrors, CreateMedicationHealthMedicationsPostResponse, CreateMedicationHealthMedicationsPostResponses, CreateProductInventoryProductsProductIdInventoryPostData, CreateProductInventoryProductsProductIdInventoryPostError, CreateProductInventoryProductsProductIdInventoryPostErrors, CreateProductInventoryProductsProductIdInventoryPostResponse, CreateProductInventoryProductsProductIdInventoryPostResponses, CreateProductProductsPostData, CreateProductProductsPostError, CreateProductProductsPostErrors, CreateProductProductsPostResponse, CreateProductProductsPostResponses, CreateRoutineRoutinesPostData, CreateRoutineRoutinesPostError, CreateRoutineRoutinesPostErrors, CreateRoutineRoutinesPostResponse, CreateRoutineRoutinesPostResponses, CreateSnapshotSkincarePostData, CreateSnapshotSkincarePostError, CreateSnapshotSkincarePostErrors, CreateSnapshotSkincarePostResponse, CreateSnapshotSkincarePostResponses, CreateUsageHealthMedicationsMedicationIdUsagesPostData, CreateUsageHealthMedicationsMedicationIdUsagesPostError, CreateUsageHealthMedicationsMedicationIdUsagesPostErrors, CreateUsageHealthMedicationsMedicationIdUsagesPostResponse, CreateUsageHealthMedicationsMedicationIdUsagesPostResponses, DayPlan, DayTime, DeleteGroomingScheduleRoutinesGroomingScheduleEntryIdDeleteData, DeleteGroomingScheduleRoutinesGroomingScheduleEntryIdDeleteError, DeleteGroomingScheduleRoutinesGroomingScheduleEntryIdDeleteErrors, DeleteGroomingScheduleRoutinesGroomingScheduleEntryIdDeleteResponse, DeleteGroomingScheduleRoutinesGroomingScheduleEntryIdDeleteResponses, DeleteInventoryInventoryInventoryIdDeleteData, DeleteInventoryInventoryInventoryIdDeleteError, DeleteInventoryInventoryInventoryIdDeleteErrors, DeleteInventoryInventoryInventoryIdDeleteResponse, DeleteInventoryInventoryInventoryIdDeleteResponses, DeleteLabResultHealthLabResultsResultIdDeleteData, DeleteLabResultHealthLabResultsResultIdDeleteError, DeleteLabResultHealthLabResultsResultIdDeleteErrors, DeleteLabResultHealthLabResultsResultIdDeleteResponse, DeleteLabResultHealthLabResultsResultIdDeleteResponses, DeleteMedicationHealthMedicationsMedicationIdDeleteData, DeleteMedicationHealthMedicationsMedicationIdDeleteError, DeleteMedicationHealthMedicationsMedicationIdDeleteErrors, DeleteMedicationHealthMedicationsMedicationIdDeleteResponse, DeleteMedicationHealthMedicationsMedicationIdDeleteResponses, DeleteProductProductsProductIdDeleteData, DeleteProductProductsProductIdDeleteError, DeleteProductProductsProductIdDeleteErrors, DeleteProductProductsProductIdDeleteResponse, DeleteProductProductsProductIdDeleteResponses, DeleteRoutineRoutinesRoutineIdDeleteData, DeleteRoutineRoutinesRoutineIdDeleteError, DeleteRoutineRoutinesRoutineIdDeleteErrors, DeleteRoutineRoutinesRoutineIdDeleteResponse, DeleteRoutineRoutinesRoutineIdDeleteResponses, DeleteSnapshotSkincareSnapshotIdDeleteData, DeleteSnapshotSkincareSnapshotIdDeleteError, DeleteSnapshotSkincareSnapshotIdDeleteErrors, DeleteSnapshotSkincareSnapshotIdDeleteResponse, DeleteSnapshotSkincareSnapshotIdDeleteResponses, DeleteStepRoutinesStepsStepIdDeleteData, DeleteStepRoutinesStepsStepIdDeleteError, DeleteStepRoutinesStepsStepIdDeleteErrors, DeleteStepRoutinesStepsStepIdDeleteResponse, DeleteStepRoutinesStepsStepIdDeleteResponses, DeleteUsageHealthUsagesUsageIdDeleteData, DeleteUsageHealthUsagesUsageIdDeleteError, DeleteUsageHealthUsagesUsageIdDeleteErrors, DeleteUsageHealthUsagesUsageIdDeleteResponse, DeleteUsageHealthUsagesUsageIdDeleteResponses, GetAiLogAiLogsLogIdGetData, GetAiLogAiLogsLogIdGetError, GetAiLogAiLogsLogIdGetErrors, GetAiLogAiLogsLogIdGetResponse, GetAiLogAiLogsLogIdGetResponses, GetInventoryInventoryInventoryIdGetData, GetInventoryInventoryInventoryIdGetError, GetInventoryInventoryInventoryIdGetErrors, GetInventoryInventoryInventoryIdGetResponse, GetInventoryInventoryInventoryIdGetResponses, GetLabResultHealthLabResultsResultIdGetData, GetLabResultHealthLabResultsResultIdGetError, GetLabResultHealthLabResultsResultIdGetErrors, GetLabResultHealthLabResultsResultIdGetResponse, GetLabResultHealthLabResultsResultIdGetResponses, GetMedicationHealthMedicationsMedicationIdGetData, GetMedicationHealthMedicationsMedicationIdGetError, GetMedicationHealthMedicationsMedicationIdGetErrors, GetMedicationHealthMedicationsMedicationIdGetResponse, GetMedicationHealthMedicationsMedicationIdGetResponses, GetProductProductsProductIdGetData, GetProductProductsProductIdGetError, GetProductProductsProductIdGetErrors, GetProductProductsProductIdGetResponse, GetProductProductsProductIdGetResponses, GetProfileProfileGetData, GetProfileProfileGetResponse, GetProfileProfileGetResponses, GetRoutineRoutinesRoutineIdGetData, GetRoutineRoutinesRoutineIdGetError, GetRoutineRoutinesRoutineIdGetErrors, GetRoutineRoutinesRoutineIdGetResponses, GetSnapshotSkincareSnapshotIdGetData, GetSnapshotSkincareSnapshotIdGetError, GetSnapshotSkincareSnapshotIdGetErrors, GetSnapshotSkincareSnapshotIdGetResponse, GetSnapshotSkincareSnapshotIdGetResponses, GroomingAction, GroomingSchedule, GroomingScheduleCreate, GroomingScheduleUpdate, HealthCheckHealthCheckGetData, HealthCheckHealthCheckGetResponses, HttpValidationError, IngredientFunction, InventoryCreate, InventoryUpdate, LabResult, LabResultCreate, LabResultListResponse, LabResultUpdate, ListAiLogsAiLogsGetData, ListAiLogsAiLogsGetError, ListAiLogsAiLogsGetErrors, ListAiLogsAiLogsGetResponse, ListAiLogsAiLogsGetResponses, ListGroomingScheduleRoutinesGroomingScheduleGetData, ListGroomingScheduleRoutinesGroomingScheduleGetResponse, ListGroomingScheduleRoutinesGroomingScheduleGetResponses, ListLabResultsHealthLabResultsGetData, ListLabResultsHealthLabResultsGetError, ListLabResultsHealthLabResultsGetErrors, ListLabResultsHealthLabResultsGetResponse, ListLabResultsHealthLabResultsGetResponses, ListMedicationsHealthMedicationsGetData, ListMedicationsHealthMedicationsGetError, ListMedicationsHealthMedicationsGetErrors, ListMedicationsHealthMedicationsGetResponse, ListMedicationsHealthMedicationsGetResponses, ListProductInventoryProductsProductIdInventoryGetData, ListProductInventoryProductsProductIdInventoryGetError, ListProductInventoryProductsProductIdInventoryGetErrors, ListProductInventoryProductsProductIdInventoryGetResponse, ListProductInventoryProductsProductIdInventoryGetResponses, ListProductsProductsGetData, ListProductsProductsGetError, ListProductsProductsGetErrors, ListProductsProductsGetResponse, ListProductsProductsGetResponses, ListProductsSummaryProductsSummaryGetData, ListProductsSummaryProductsSummaryGetError, ListProductsSummaryProductsSummaryGetErrors, ListProductsSummaryProductsSummaryGetResponse, ListProductsSummaryProductsSummaryGetResponses, ListRoutinesRoutinesGetData, ListRoutinesRoutinesGetError, ListRoutinesRoutinesGetErrors, ListRoutinesRoutinesGetResponses, ListSnapshotsSkincareGetData, ListSnapshotsSkincareGetError, ListSnapshotsSkincareGetErrors, ListSnapshotsSkincareGetResponse, ListSnapshotsSkincareGetResponses, ListUsagesHealthMedicationsMedicationIdUsagesGetData, ListUsagesHealthMedicationsMedicationIdUsagesGetError, ListUsagesHealthMedicationsMedicationIdUsagesGetErrors, ListUsagesHealthMedicationsMedicationIdUsagesGetResponse, ListUsagesHealthMedicationsMedicationIdUsagesGetResponses, MedicationCreate, MedicationEntry, MedicationKind, MedicationUpdate, MedicationUsage, OverallSkinState, ParseProductTextProductsParseTextPostData, ParseProductTextProductsParseTextPostError, ParseProductTextProductsParseTextPostErrors, ParseProductTextProductsParseTextPostResponse, ParseProductTextProductsParseTextPostResponses, PartOfDay, PriceTier, ProductCategory, ProductContext, ProductCreate, ProductEffectProfile, ProductInventory, ProductListItem, ProductParseRequest, ProductParseResponse, ProductPublic, ProductSuggestion, ProductUpdate, ProductWithInventory, RemainingLevel, ResponseMetadata, ResultFlag, Routine, RoutineCreate, RoutineStep, RoutineStepCreate, RoutineStepUpdate, RoutineSuggestion, RoutineSuggestionSummary, RoutineUpdate, SexAtBirth, ShoppingSuggestionResponse, SkinConcern, SkinConditionSnapshotPublic, SkinPhotoAnalysisResponse, SkinTexture, SkinType, SnapshotCreate, SnapshotUpdate, StrengthLevel, SuggestBatchRequest, SuggestBatchRoutinesSuggestBatchPostData, SuggestBatchRoutinesSuggestBatchPostError, SuggestBatchRoutinesSuggestBatchPostErrors, SuggestBatchRoutinesSuggestBatchPostResponse, SuggestBatchRoutinesSuggestBatchPostResponses, SuggestedStep, SuggestRoutineRequest, SuggestRoutineRoutinesSuggestPostData, SuggestRoutineRoutinesSuggestPostError, SuggestRoutineRoutinesSuggestPostErrors, SuggestRoutineRoutinesSuggestPostResponse, SuggestRoutineRoutinesSuggestPostResponses, SuggestShoppingProductsSuggestPostData, SuggestShoppingProductsSuggestPostResponse, SuggestShoppingProductsSuggestPostResponses, TextureType, TokenMetrics, UpdateGroomingScheduleRoutinesGroomingScheduleEntryIdPatchData, UpdateGroomingScheduleRoutinesGroomingScheduleEntryIdPatchError, UpdateGroomingScheduleRoutinesGroomingScheduleEntryIdPatchErrors, UpdateGroomingScheduleRoutinesGroomingScheduleEntryIdPatchResponse, UpdateGroomingScheduleRoutinesGroomingScheduleEntryIdPatchResponses, UpdateInventoryInventoryInventoryIdPatchData, UpdateInventoryInventoryInventoryIdPatchError, UpdateInventoryInventoryInventoryIdPatchErrors, UpdateInventoryInventoryInventoryIdPatchResponse, UpdateInventoryInventoryInventoryIdPatchResponses, UpdateLabResultHealthLabResultsResultIdPatchData, UpdateLabResultHealthLabResultsResultIdPatchError, UpdateLabResultHealthLabResultsResultIdPatchErrors, UpdateLabResultHealthLabResultsResultIdPatchResponse, UpdateLabResultHealthLabResultsResultIdPatchResponses, UpdateMedicationHealthMedicationsMedicationIdPatchData, UpdateMedicationHealthMedicationsMedicationIdPatchError, UpdateMedicationHealthMedicationsMedicationIdPatchErrors, UpdateMedicationHealthMedicationsMedicationIdPatchResponse, UpdateMedicationHealthMedicationsMedicationIdPatchResponses, UpdateProductProductsProductIdPatchData, UpdateProductProductsProductIdPatchError, UpdateProductProductsProductIdPatchErrors, UpdateProductProductsProductIdPatchResponse, UpdateProductProductsProductIdPatchResponses, UpdateRoutineRoutinesRoutineIdPatchData, UpdateRoutineRoutinesRoutineIdPatchError, UpdateRoutineRoutinesRoutineIdPatchErrors, UpdateRoutineRoutinesRoutineIdPatchResponse, UpdateRoutineRoutinesRoutineIdPatchResponses, UpdateSnapshotSkincareSnapshotIdPatchData, UpdateSnapshotSkincareSnapshotIdPatchError, UpdateSnapshotSkincareSnapshotIdPatchErrors, UpdateSnapshotSkincareSnapshotIdPatchResponse, UpdateSnapshotSkincareSnapshotIdPatchResponses, UpdateStepRoutinesStepsStepIdPatchData, UpdateStepRoutinesStepsStepIdPatchError, UpdateStepRoutinesStepsStepIdPatchErrors, UpdateStepRoutinesStepsStepIdPatchResponse, UpdateStepRoutinesStepsStepIdPatchResponses, UpdateUsageHealthUsagesUsageIdPatchData, UpdateUsageHealthUsagesUsageIdPatchError, UpdateUsageHealthUsagesUsageIdPatchErrors, UpdateUsageHealthUsagesUsageIdPatchResponse, UpdateUsageHealthUsagesUsageIdPatchResponses, UpsertProfileProfilePatchData, UpsertProfileProfilePatchError, UpsertProfileProfilePatchErrors, UpsertProfileProfilePatchResponse, UpsertProfileProfilePatchResponses, UsageCreate, UsageUpdate, UserProfilePublic, UserProfileUpdate, ValidationError } from './types.gen'; diff --git a/frontend/src/lib/api/generated/types.gen.ts b/frontend/src/lib/api/generated/types.gen.ts index 5db3590..d3d17d2 100644 --- a/frontend/src/lib/api/generated/types.gen.ts +++ b/frontend/src/lib/api/generated/types.gen.ts @@ -12,10 +12,6 @@ export type AiCallLog = { * Id */ id?: string; - /** - * User Id - */ - user_id?: string | null; /** * Created At */ @@ -191,98 +187,6 @@ export type ActiveIngredient = { irritation_potential?: StrengthLevel | null; }; -/** - * AuthHouseholdMembershipPublic - */ -export type AuthHouseholdMembershipPublic = { - /** - * Household Id - */ - household_id: string; - role: HouseholdRole; -}; - -/** - * AuthIdentityPublic - */ -export type AuthIdentityPublic = { - /** - * Issuer - */ - issuer: string; - /** - * Subject - */ - subject: string; - /** - * Email - */ - email?: string | null; - /** - * Name - */ - name?: string | null; - /** - * Preferred Username - */ - preferred_username?: string | null; - /** - * Groups - */ - groups?: Array; -}; - -/** - * AuthProfilePublic - */ -export type AuthProfilePublic = { - /** - * Id - */ - id: string; - /** - * User Id - */ - user_id: string | null; - /** - * Birth Date - */ - birth_date?: string | null; - /** - * Sex At Birth - */ - sex_at_birth?: string | null; - /** - * Created At - */ - created_at: string; - /** - * Updated At - */ - updated_at: string; -}; - -/** - * AuthSessionResponse - */ -export type AuthSessionResponse = { - user: AuthUserPublic; - identity: AuthIdentityPublic; - profile?: AuthProfilePublic | null; -}; - -/** - * AuthUserPublic - */ -export type AuthUserPublic = { - /** - * Id - */ - id: string; - role: Role; - household_membership?: AuthHouseholdMembershipPublic | null; -}; - /** * BarrierState */ @@ -361,10 +265,6 @@ export type GroomingSchedule = { * Id */ id?: string; - /** - * User Id - */ - user_id?: string | null; /** * Day Of Week */ @@ -416,11 +316,6 @@ export type HttpValidationError = { detail?: Array; }; -/** - * HouseholdRole - */ -export type HouseholdRole = 'owner' | 'member'; - /** * IngredientFunction */ @@ -488,10 +383,6 @@ export type LabResult = { * Record Id */ record_id?: string; - /** - * User Id - */ - user_id?: string | null; /** * Collected At */ @@ -758,10 +649,6 @@ export type MedicationEntry = { * Record Id */ record_id?: string; - /** - * User Id - */ - user_id?: string | null; kind: MedicationKind; /** * Product Name @@ -841,10 +728,6 @@ export type MedicationUsage = { * Record Id */ record_id?: string; - /** - * User Id - */ - user_id?: string | null; /** * Medication Record Id */ @@ -1127,18 +1010,10 @@ export type ProductInventory = { * Id */ id?: string; - /** - * User Id - */ - user_id?: string | null; /** * Product Id */ product_id: string; - /** - * Is Household Shared - */ - is_household_shared?: boolean; /** * Is Opened */ @@ -1814,11 +1689,6 @@ export type ResponseMetadata = { */ export type ResultFlag = 'N' | 'ABN' | 'POS' | 'NEG' | 'L' | 'H'; -/** - * Role - */ -export type Role = 'admin' | 'member'; - /** * Routine */ @@ -1827,10 +1697,6 @@ export type Routine = { * Id */ id?: string; - /** - * User Id - */ - user_id?: string | null; /** * Routine Date */ @@ -1873,10 +1739,6 @@ export type RoutineStep = { * Id */ id?: string; - /** - * User Id - */ - user_id?: string | null; /** * Routine Id */ @@ -2015,36 +1877,6 @@ export type RoutineUpdate = { notes?: string | null; }; -/** - * SessionSyncRequest - */ -export type SessionSyncRequest = { - /** - * Iss - */ - iss?: string | null; - /** - * Sub - */ - sub?: string | null; - /** - * Email - */ - email?: string | null; - /** - * Name - */ - name?: string | null; - /** - * Preferred Username - */ - preferred_username?: string | null; - /** - * Groups - */ - groups?: Array | null; -}; - /** * SexAtBirth */ @@ -2532,50 +2364,6 @@ export type ValidationError = { }; }; -export type SyncSessionAuthSessionSyncPostData = { - /** - * Payload - */ - body?: SessionSyncRequest | null; - path?: never; - query?: never; - url: '/auth/session/sync'; -}; - -export type SyncSessionAuthSessionSyncPostErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type SyncSessionAuthSessionSyncPostError = SyncSessionAuthSessionSyncPostErrors[keyof SyncSessionAuthSessionSyncPostErrors]; - -export type SyncSessionAuthSessionSyncPostResponses = { - /** - * Successful Response - */ - 200: AuthSessionResponse; -}; - -export type SyncSessionAuthSessionSyncPostResponse = SyncSessionAuthSessionSyncPostResponses[keyof SyncSessionAuthSessionSyncPostResponses]; - -export type GetMeAuthMeGetData = { - body?: never; - path?: never; - query?: never; - url: '/auth/me'; -}; - -export type GetMeAuthMeGetResponses = { - /** - * Successful Response - */ - 200: AuthSessionResponse; -}; - -export type GetMeAuthMeGetResponse = GetMeAuthMeGetResponses[keyof GetMeAuthMeGetResponses]; - export type ListProductsProductsGetData = { body?: never; path?: never; diff --git a/frontend/src/lib/components/ProductForm.svelte b/frontend/src/lib/components/ProductForm.svelte index fbf5bda..23dc172 100644 --- a/frontend/src/lib/components/ProductForm.svelte +++ b/frontend/src/lib/components/ProductForm.svelte @@ -1,4 +1,4 @@ -