Compare commits

...

10 commits

Author SHA1 Message Date
cc73c8a7f4 chore: remove unused import 2026-03-12 16:51:13 +01:00
dac787b81b test(auth): add multi-user regression coverage
- Enable backend tests in CI (remove if: false)
- Fix test_products_helpers.py to pass current_user parameter
- Fix test_routines_helpers.py to include short_id in products
- Fix llm_context.py to use product_effect_profile correctly
- All 221 tests passing
2026-03-12 16:42:00 +01:00
b11f64d5a1 refactor(frontend): route protected API access through server session 2026-03-12 16:27:24 +01:00
1d5630ed8c feat(api): add admin household management endpoints 2026-03-12 16:02:11 +01:00
4bfa4ea02d chore(deploy): wire OIDC runtime configuration 2026-03-12 15:55:32 +01:00
ffa3b71309 feat(api): enforce ownership across health routines and profile flows 2026-03-12 15:48:13 +01:00
cd8e39939a feat(frontend): add Authelia OIDC session flow 2026-03-12 15:40:55 +01:00
803bc3b4cd feat(api): scope products and inventory by owner and household 2026-03-12 15:37:39 +01:00
1f47974f48 refactor(api): centralize tenant authorization helpers 2026-03-12 15:26:06 +01:00
4782fad5b9 feat(auth): validate Authelia tokens in FastAPI 2026-03-12 15:13:55 +01:00
115 changed files with 10683 additions and 620 deletions

View file

@ -83,8 +83,6 @@ 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

9
.sisyphus/boulder.json Normal file
View file

@ -0,0 +1,9 @@
{
"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"
}

View file

@ -0,0 +1,9 @@
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

View file

@ -0,0 +1,41 @@
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)

View file

@ -0,0 +1,283 @@
============================= 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 ==============================

View file

@ -0,0 +1,6 @@
backend-test:
name: Backend Tests
runs-on: lxc
steps:
- name: Checkout code
uses: actions/checkout@v4

View file

@ -0,0 +1,37 @@
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 <module>
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 "<frozen importlib._bootstrap_external>", line 999, in exec_module
File "<frozen importlib._bootstrap>", line 488, in _call_with_frames_removed
File "/Users/piotr/dev/innercontext/backend/alembic/env.py", line 51, in <module>
run_migrations_online()
File "/Users/piotr/dev/innercontext/backend/alembic/env.py", line 45, in run_migrations_online
context.run_migrations()
File "<string>", 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

View file

@ -0,0 +1,3 @@
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

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,63 @@
============================= 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 ========================

View file

@ -0,0 +1,68 @@
============================= 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 ===============================

View file

@ -0,0 +1,62 @@
============================= 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 ========================

View file

@ -0,0 +1,63 @@
============================= 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 ========================

View file

@ -0,0 +1,62 @@
============================= 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 ===============================

View file

@ -0,0 +1,61 @@
============================= 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 ===============================

View file

@ -0,0 +1,158 @@
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)

View file

@ -0,0 +1,40 @@
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

View file

@ -0,0 +1,5 @@
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

View file

@ -0,0 +1,44 @@
> 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

View file

@ -0,0 +1,56 @@
> 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

View file

@ -0,0 +1,228 @@
> 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

View file

@ -0,0 +1,85 @@
"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 -

View file

@ -0,0 +1,18 @@
# 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`

Binary file not shown.

View file

@ -0,0 +1,10 @@
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.

View file

@ -0,0 +1,67 @@
============================= 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 ========================

View file

@ -0,0 +1,65 @@
============================= 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 =======================

View file

@ -0,0 +1,13 @@
# 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.

View file

@ -0,0 +1,5 @@
- 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.

View file

@ -0,0 +1,3 @@
- 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.

View file

@ -0,0 +1,15 @@
- 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.

View file

@ -0,0 +1,3 @@
- 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).

View file

@ -0,0 +1,604 @@
# 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.

Binary file not shown.

View file

@ -0,0 +1,206 @@
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)

View file

@ -2,10 +2,13 @@ import json
from typing import Any, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
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()
@ -43,14 +46,33 @@ 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),
):
stmt = select(AICallLog).order_by(col(AICallLog.created_at).desc()).limit(limit)
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)
)
if endpoint is not None:
stmt = stmt.where(AICallLog.endpoint == endpoint)
if success is not None:
@ -75,9 +97,17 @@ def list_ai_logs(
@router.get("/{log_id}", response_model=AICallLog)
def get_ai_log(log_id: UUID, session: Session = Depends(get_session)):
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)
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

View file

@ -0,0 +1,166 @@
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)

View file

@ -0,0 +1,57 @@
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

View file

@ -0,0 +1,177 @@
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

View file

@ -3,15 +3,17 @@ from datetime import datetime
from typing import Optional
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, 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.utils import get_or_404
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.models import LabResult, MedicationEntry, MedicationUsage
from innercontext.models.enums import MedicationKind, ResultFlag
from innercontext.models.enums import MedicationKind, ResultFlag, Role
router = APIRouter()
@ -133,6 +135,34 @@ 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
# ---------------------------------------------------------------------------
@ -142,9 +172,12 @@ class LabResultListResponse(SQLModel):
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),
):
stmt = select(MedicationEntry)
target_user_id = _resolve_target_user_id(current_user, user_id)
stmt = select(MedicationEntry).where(MedicationEntry.user_id == target_user_id)
if kind is not None:
stmt = stmt.where(MedicationEntry.kind == kind)
if product_name is not None:
@ -153,8 +186,18 @@ def list_medications(
@router.post("/medications", response_model=MedicationEntry, status_code=201)
def create_medication(data: MedicationCreate, session: Session = Depends(get_session)):
entry = MedicationEntry(record_id=uuid4(), **data.model_dump())
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(),
)
session.add(entry)
session.commit()
session.refresh(entry)
@ -162,17 +205,36 @@ def create_medication(data: MedicationCreate, session: Session = Depends(get_ses
@router.get("/medications/{medication_id}", response_model=MedicationEntry)
def get_medication(medication_id: UUID, session: Session = Depends(get_session)):
return get_or_404(session, MedicationEntry, medication_id)
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,
)
@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_or_404(session, MedicationEntry, medication_id)
entry = _get_owned_or_admin_override(
session,
MedicationEntry,
medication_id,
current_user,
user_id,
)
for key, value in data.model_dump(exclude_unset=True).items():
setattr(entry, key, value)
session.add(entry)
@ -182,13 +244,25 @@ def update_medication(
@router.delete("/medications/{medication_id}", status_code=204)
def delete_medication(medication_id: UUID, session: Session = Depends(get_session)):
entry = get_or_404(session, MedicationEntry, medication_id)
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,
)
# Delete usages first (no cascade configured at DB level)
usages = session.exec(
select(MedicationUsage).where(
MedicationUsage.medication_record_id == medication_id
)
select(MedicationUsage)
.where(MedicationUsage.medication_record_id == medication_id)
.where(MedicationUsage.user_id == target_user_id)
).all()
for u in usages:
session.delete(u)
@ -202,10 +276,24 @@ def delete_medication(medication_id: UUID, session: Session = Depends(get_sessio
@router.get("/medications/{medication_id}/usages", response_model=list[MedicationUsage])
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
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)
)
return session.exec(stmt).all()
@ -218,11 +306,21 @@ def list_usages(medication_id: UUID, session: Session = Depends(get_session)):
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),
):
get_or_404(session, MedicationEntry, medication_id)
target_user_id = _resolve_target_user_id(current_user, user_id)
_ = _get_owned_or_admin_override(
session,
MedicationEntry,
medication_id,
current_user,
user_id,
)
usage = MedicationUsage(
record_id=uuid4(),
user_id=target_user_id,
medication_record_id=medication_id,
**data.model_dump(),
)
@ -236,9 +334,17 @@ 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_or_404(session, MedicationUsage, usage_id)
usage = _get_owned_or_admin_override(
session,
MedicationUsage,
usage_id,
current_user,
user_id,
)
for key, value in data.model_dump(exclude_unset=True).items():
setattr(usage, key, value)
session.add(usage)
@ -248,8 +354,19 @@ def update_usage(
@router.delete("/usages/{usage_id}", status_code=204)
def delete_usage(usage_id: UUID, session: Session = Depends(get_session)):
usage = get_or_404(session, MedicationUsage, usage_id)
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,
)
session.delete(usage)
session.commit()
@ -271,29 +388,35 @@ 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),
):
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),
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),
)
)
)
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 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 latest_only:
ranked_stmt = select(
@ -309,8 +432,7 @@ def list_lab_results(
)
.label("rank"),
)
if filters:
ranked_stmt = ranked_stmt.where(*filters)
ranked_stmt = _apply_filters(ranked_stmt)
ranked_subquery = ranked_stmt.subquery()
latest_ids = select(ranked_subquery.c.record_id).where(
@ -323,11 +445,8 @@ def list_lab_results(
.subquery()
)
else:
stmt = select(LabResult)
count_stmt = select(func.count()).select_from(LabResult)
if filters:
stmt = stmt.where(*filters)
count_stmt = count_stmt.where(*filters)
stmt = _apply_filters(select(LabResult))
count_stmt = _apply_filters(select(func.count()).select_from(LabResult))
test_code_numeric = cast(
func.replace(col(LabResult.test_code), "-", ""),
@ -345,8 +464,18 @@ def list_lab_results(
@router.post("/lab-results", response_model=LabResult, status_code=201)
def create_lab_result(data: LabResultCreate, session: Session = Depends(get_session)):
result = LabResult(record_id=uuid4(), **data.model_dump())
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(),
)
session.add(result)
session.commit()
session.refresh(result)
@ -354,17 +483,36 @@ def create_lab_result(data: LabResultCreate, session: Session = Depends(get_sess
@router.get("/lab-results/{result_id}", response_model=LabResult)
def get_lab_result(result_id: UUID, session: Session = Depends(get_session)):
return get_or_404(session, LabResult, result_id)
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,
)
@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_or_404(session, LabResult, result_id)
result = _get_owned_or_admin_override(
session,
LabResult,
result_id,
current_user,
user_id,
)
for key, value in data.model_dump(exclude_unset=True).items():
setattr(result, key, value)
session.add(result)
@ -374,7 +522,18 @@ def update_lab_result(
@router.delete("/lab-results/{result_id}", status_code=204)
def delete_lab_result(result_id: UUID, session: Session = Depends(get_session)):
result = get_or_404(session, LabResult, result_id)
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,
)
session.delete(result)
session.commit()

View file

@ -1,19 +1,29 @@
from uuid import UUID
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
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
from innercontext.api.utils import get_or_404, get_owned_or_404_admin_override
from innercontext.auth import CurrentUser
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)):
return get_or_404(session, ProductInventory, inventory_id)
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)
@router.patch("/{inventory_id}", response_model=ProductInventory)
@ -21,7 +31,10 @@ 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)
@ -32,7 +45,16 @@ def update_inventory(
@router.delete("/{inventory_id}", status_code=204)
def delete_inventory(inventory_id: UUID, session: Session = Depends(get_session)):
entry = get_or_404(session, ProductInventory, inventory_id)
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,
)
session.delete(entry)
session.commit()

View file

@ -2,14 +2,41 @@ 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 get_user_profile(session: Session) -> UserProfile | 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
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)
return session.exec(
select(UserProfile).order_by(col(UserProfile.created_at).desc())
select(UserProfile)
.where(UserProfile.user_id == target_user_id)
.order_by(col(UserProfile.created_at).desc())
).first()
@ -20,8 +47,14 @@ def calculate_age(birth_date: date, reference_date: date) -> int:
return years
def build_user_profile_context(session: Session, reference_date: date) -> str:
profile = get_user_profile(session)
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)
if profile is None:
return "USER PROFILE: no data\n"
@ -69,8 +102,9 @@ def build_product_context_summary(product: Product, has_inventory: bool = False)
# Get effect profile scores if available
effects = []
if hasattr(product, "effect_profile") and product.effect_profile:
profile = product.effect_profile
effect_profile = getattr(product, "product_effect_profile", None)
if effect_profile:
profile = effect_profile
# Only include notable effects (score > 0)
# Handle both dict (from DB) and object (from Pydantic)
if isinstance(profile, dict):
@ -165,11 +199,12 @@ def build_product_context_detailed(
# Effect profile
effect_profile = None
if hasattr(product, "effect_profile") and product.effect_profile:
if isinstance(product.effect_profile, dict):
effect_profile = product.effect_profile
product_effect_profile = getattr(product, "effect_profile", None)
if 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

View file

@ -1,3 +1,5 @@
# pyright: reportImportCycles=false, reportIncompatibleVariableOverride=false
import json
import logging
from datetime import date
@ -13,6 +15,8 @@ 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,
@ -24,7 +28,8 @@ 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
from innercontext.api.utils import get_or_404, get_owned_or_404_admin_override
from innercontext.auth import CurrentUser
from innercontext.llm import (
call_gemini,
call_gemini_with_function_tools,
@ -42,6 +47,7 @@ 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 (
@ -67,6 +73,34 @@ 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:
@ -214,15 +248,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 # type: ignore[assignment]
irritation_potential: Optional[int] = None # type: ignore[assignment]
strength_level: Optional[int] = None # pyright: ignore[reportIncompatibleVariableOverride]
irritation_potential: Optional[int] = None # pyright: ignore[reportIncompatibleVariableOverride]
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 # type: ignore[assignment]
actives: Optional[list[AIActiveIngredient]] = None # pyright: ignore[reportIncompatibleVariableOverride]
class InventoryCreate(SQLModel):
@ -610,6 +644,7 @@ 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:
@ -622,6 +657,12 @@ 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:
@ -646,20 +687,28 @@ def list_products(
if product_ids
else []
)
inv_by_product: dict = {}
inv_by_product: dict[UUID, list[ProductInventory]] = {}
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 = inv_by_product.get(p.id, [])
r.inventory = _visible_inventory_for_product(
inv_by_product.get(p.id, []),
session,
current_user,
)
results.append(r)
return results
@router.post("", response_model=ProductPublic, status_code=201)
def create_product(data: ProductCreate, session: Session = Depends(get_session)):
def create_product(
data: ProductCreate,
session: Session = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
):
payload = data.model_dump()
if payload.get("price_currency"):
payload["price_currency"] = str(payload["price_currency"]).upper()
@ -667,6 +716,7 @@ def create_product(data: ProductCreate, session: Session = Depends(get_session))
product_id = uuid4()
product = Product(
id=product_id,
user_id=current_user.user_id,
short_id=str(product_id)[:8],
**payload,
)
@ -849,10 +899,12 @@ 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,
@ -872,6 +924,10 @@ 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}
@ -884,26 +940,11 @@ 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,
@ -921,7 +962,7 @@ def list_products_summary(
category=category_value,
recommended_time=recommended_time,
targets=row_targets or [],
is_owned=product_id in owned_ids,
is_owned=product_user_id == current_user.user_id,
price_tier=price_tier,
price_per_use_pln=price_per_use_pln,
price_tier_source=price_tier_source,
@ -932,22 +973,35 @@ def list_products_summary(
@router.get("/{product_id}", response_model=ProductWithInventory)
def get_product(product_id: UUID, session: Session = Depends(get_session)):
def get_product(
product_id: UUID,
session: Session = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
):
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 = list(inventory)
result.inventory = _visible_inventory_for_product(
list(inventory), session, current_user
)
return result
@router.patch("/{product_id}", response_model=ProductPublic)
def update_product(
product_id: UUID, data: ProductUpdate, session: Session = Depends(get_session)
product_id: UUID,
data: ProductUpdate,
session: Session = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
):
product = get_or_404(session, Product, product_id)
product = get_owned_or_404_admin_override(
session, Product, product_id, current_user
)
patch_data = data.model_dump(exclude_unset=True)
if patch_data.get("price_currency"):
patch_data["price_currency"] = str(patch_data["price_currency"]).upper()
@ -962,8 +1016,14 @@ def update_product(
@router.delete("/{product_id}", status_code=204)
def delete_product(product_id: UUID, session: Session = Depends(get_session)):
product = get_or_404(session, Product, product_id)
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
)
session.delete(product)
enqueue_pricing_recalc(session)
session.commit()
@ -975,10 +1035,17 @@ def delete_product(product_id: UUID, session: Session = Depends(get_session)):
@router.get("/{product_id}/inventory", response_model=list[ProductInventory])
def list_product_inventory(product_id: UUID, session: Session = Depends(get_session)):
def list_product_inventory(
product_id: UUID,
session: Session = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
):
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)
return session.exec(stmt).all()
inventories = list(session.exec(stmt).all())
return _visible_inventory_for_product(inventories, session, current_user)
@router.post(
@ -988,10 +1055,14 @@ def create_product_inventory(
product_id: UUID,
data: InventoryCreate,
session: Session = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
):
get_or_404(session, Product, product_id)
product = get_owned_or_404_admin_override(
session, Product, product_id, current_user
)
entry = ProductInventory(
id=uuid4(),
user_id=product.user_id or current_user.user_id,
product_id=product_id,
**data.model_dump(),
)
@ -1018,11 +1089,16 @@ 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)
profile_ctx = build_user_profile_context(
session,
reference_date=reference_date,
current_user=current_user,
)
snapshot = session.exec(
select(SkinConditionSnapshot).order_by(
col(SkinConditionSnapshot.snapshot_date).desc()
@ -1061,7 +1137,7 @@ def _build_shopping_context(
if product_ids
else []
)
inv_by_product: dict = {}
inv_by_product: dict[UUID, list[ProductInventory]] = {}
for inv in inventory_rows:
inv_by_product.setdefault(inv.product_id, []).append(inv)
@ -1213,7 +1289,10 @@ Format odpowiedzi - zwróć wyłącznie JSON zgodny z podanym schematem."""
@router.post("/suggest", response_model=ShoppingSuggestionResponse)
def suggest_shopping(session: Session = Depends(get_session)):
def suggest_shopping(
session: Session = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
):
reference_date = date.today()
shopping_products = _get_shopping_products(session)
last_used_on_by_product = build_last_used_on_by_product(
@ -1223,6 +1302,7 @@ def suggest_shopping(session: Session = Depends(get_session)):
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,
)

View file

@ -1,11 +1,14 @@
from datetime import date, datetime
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
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()
@ -25,8 +28,12 @@ class UserProfilePublic(SQLModel):
@router.get("", response_model=UserProfilePublic | None)
def get_profile(session: Session = Depends(get_session)):
profile = get_user_profile(session)
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)
if profile is None:
return None
return UserProfilePublic(
@ -39,12 +46,18 @@ def get_profile(session: Session = Depends(get_session)):
@router.patch("", response_model=UserProfilePublic)
def upsert_profile(data: UserProfileUpdate, session: Session = Depends(get_session)):
profile = get_user_profile(session)
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)
payload = data.model_dump(exclude_unset=True)
if profile is None:
profile = UserProfile(**payload)
profile = UserProfile(user_id=target_user_id, **payload)
else:
for key, value in payload.items():
setattr(profile, key, value)

View file

@ -5,12 +5,15 @@ from datetime import date, timedelta
from typing import Any, Optional
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
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,
@ -25,7 +28,8 @@ 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
from innercontext.api.utils import get_owned_or_404
from innercontext.auth import CurrentUser
from innercontext.llm import (
call_gemini,
call_gemini_with_function_tools,
@ -33,6 +37,7 @@ from innercontext.llm import (
)
from innercontext.llm_safety import isolate_user_input, sanitize_user_input
from innercontext.models import (
HouseholdMembership,
GroomingSchedule,
Product,
ProductInventory,
@ -43,6 +48,7 @@ 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
@ -86,6 +92,47 @@ 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
# ---------------------------------------------------------------------------
@ -289,6 +336,7 @@ 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,
@ -298,6 +346,7 @@ 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())
@ -307,6 +356,7 @@ 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())
@ -315,12 +365,14 @@ 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())
@ -329,12 +381,14 @@ 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,
@ -354,10 +408,14 @@ def _build_skin_context(
def _build_grooming_context(
session: Session, weekdays: Optional[list[int]] = None
session: Session,
target_user_id: UUID,
weekdays: Optional[list[int]] = None,
) -> str:
entries = session.exec(
select(GroomingSchedule).order_by(col(GroomingSchedule.day_of_week))
select(GroomingSchedule)
.where(GroomingSchedule.user_id == target_user_id)
.order_by(col(GroomingSchedule.day_of_week))
).all()
if not entries:
return "GROOMING SCHEDULE: none\n"
@ -378,11 +436,14 @@ 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).order_by(col(GroomingSchedule.day_of_week))
select(GroomingSchedule)
.where(GroomingSchedule.user_id == target_user_id)
.order_by(col(GroomingSchedule.day_of_week))
).all()
if not entries:
return f"UPCOMING GROOMING (next {days} days): none\n"
@ -420,12 +481,14 @@ 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())
@ -437,6 +500,7 @@ 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 = []
@ -458,11 +522,37 @@ 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))
products = session.exec(stmt).all()
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()
result: list[Product] = []
for p in products:
if p.is_medication and not _is_minoxidil_product(p):
@ -517,7 +607,9 @@ def _extract_requested_product_ids(
def _get_products_with_inventory(
session: Session, product_ids: list[UUID]
session: Session,
current_user: CurrentUser,
product_ids: list[UUID],
) -> set[UUID]:
"""
Return set of product IDs that have active (non-finished) inventory.
@ -527,17 +619,33 @@ def _get_products_with_inventory(
if not product_ids:
return set()
inventory_rows = session.exec(
stmt = (
select(ProductInventory.product_id)
.where(col(ProductInventory.product_id).in_(product_ids))
.where(col(ProductInventory.finished_at).is_(None))
.distinct()
).all()
)
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()
return set(inventory_rows)
def _expand_product_id(session: Session, short_or_full_id: str) -> UUID | None:
def _expand_product_id(
session: Session,
current_user: CurrentUser,
short_or_full_id: str,
) -> UUID | None:
"""
Expand 8-char short_id to full UUID, or validate full UUID.
@ -558,7 +666,13 @@ def _expand_product_id(session: Session, short_or_full_id: str) -> UUID | None:
uuid_obj = UUID(short_or_full_id)
# Verify it exists
product = session.get(Product, uuid_obj)
return uuid_obj if product else None
if product is None:
return None
return (
uuid_obj
if is_product_visible(session, uuid_obj, current_user)
else None
)
except (ValueError, TypeError):
return None
@ -567,7 +681,13 @@ def _expand_product_id(session: Session, short_or_full_id: str) -> UUID | None:
product = session.exec(
select(Product).where(Product.short_id == short_or_full_id)
).first()
return product.id if product else None
if product is None:
return None
return (
product.id
if is_product_visible(session, product.id, current_user)
else None
)
# Invalid length
return None
@ -590,6 +710,17 @@ 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.
@ -676,9 +807,12 @@ 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),
):
stmt = select(Routine)
target_user_id = _resolve_target_user_id(current_user, user_id)
stmt = select(Routine).where(Routine.user_id == target_user_id)
if from_date is not None:
stmt = stmt.where(Routine.routine_date >= from_date)
if to_date is not None:
@ -688,10 +822,12 @@ def list_routines(
routines = session.exec(stmt).all()
routine_ids = [r.id for r in routines]
steps_by_routine: dict = {}
steps_by_routine: dict[UUID, list[RoutineStep]] = {}
if routine_ids:
all_steps = session.exec(
select(RoutineStep).where(col(RoutineStep.routine_id).in_(routine_ids))
select(RoutineStep)
.where(col(RoutineStep.routine_id).in_(routine_ids))
.where(RoutineStep.user_id == target_user_id)
).all()
for step in all_steps:
steps_by_routine.setdefault(step.routine_id, []).append(step)
@ -707,8 +843,14 @@ def list_routines(
@router.post("", response_model=Routine, status_code=201)
def create_routine(data: RoutineCreate, session: Session = Depends(get_session)):
routine = Routine(id=uuid4(), **data.model_dump())
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())
session.add(routine)
session.commit()
session.refresh(routine)
@ -724,19 +866,35 @@ def create_routine(data: RoutineCreate, session: Session = Depends(get_session))
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, reference_date=data.routine_date)
profile_ctx = build_user_profile_context(session, reference_date=data.routine_date)
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,
)
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, reference_date=data.routine_date)
history_ctx = _build_recent_history(
session,
target_user_id=target_user_id,
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,
)
@ -752,7 +910,9 @@ def suggest_routine(
# Phase 2: Use tiered context (summary mode for initial prompt)
products_with_inventory = _get_products_with_inventory(
session, [p.id for p in available_products]
session,
current_user,
[p.id for p in available_products],
)
products_ctx = build_products_context_summary_list(
available_products, products_with_inventory
@ -865,22 +1025,35 @@ def suggest_routine(
# Translation layer: Expand short_ids (8 chars) to full UUIDs (36 chars)
steps = []
for s in parsed.get("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
product_id_str = s.get("product_id")
product_id_uuid = None
if product_id_str:
if isinstance(product_id_str, str) and product_id_str:
# Expand short_id or validate full UUID
product_id_uuid = _expand_product_id(session, product_id_str)
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")
steps.append(
SuggestedStep(
product_id=product_id_uuid,
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"),
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,
)
)
@ -904,6 +1077,7 @@ 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,
)
@ -964,7 +1138,9 @@ 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(
@ -976,18 +1152,37 @@ 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)
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)
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,
)
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, [p.id for p in batch_products]
session,
current_user,
[p.id for p in batch_products],
)
products_ctx = build_products_context_summary_list(
batch_products, products_with_inventory
@ -1045,25 +1240,39 @@ 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) -> list[SuggestedStep]:
def _parse_steps(raw_steps: list[dict[str, object]]) -> 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 product_id_str:
if isinstance(product_id_str, str) and product_id_str:
# Translation layer: expand short_id to full UUID
product_id_uuid = _expand_product_id(session, product_id_str)
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")
result.append(
SuggestedStep(
product_id=product_id_uuid,
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"),
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,
)
)
return result
@ -1086,6 +1295,7 @@ 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,
)
@ -1140,15 +1350,36 @@ 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(session: Session = Depends(get_session)):
return session.exec(select(GroomingSchedule)).all()
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()
@router.get("/{routine_id}")
def get_routine(routine_id: UUID, session: Session = Depends(get_session)):
routine = get_or_404(session, Routine, 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,
)
steps = session.exec(
select(RoutineStep).where(RoutineStep.routine_id == routine_id)
select(RoutineStep)
.where(RoutineStep.routine_id == routine_id)
.where(RoutineStep.user_id == target_user_id)
).all()
data = routine.model_dump(mode="json")
data["steps"] = [step.model_dump(mode="json") for step in steps]
@ -1159,9 +1390,17 @@ def get_routine(routine_id: UUID, session: Session = Depends(get_session)):
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_or_404(session, Routine, routine_id)
routine = _get_owned_or_admin_override(
session,
Routine,
routine_id,
current_user,
user_id,
)
for key, value in data.model_dump(exclude_unset=True).items():
setattr(routine, key, value)
session.add(routine)
@ -1171,8 +1410,19 @@ def update_routine(
@router.delete("/{routine_id}", status_code=204)
def delete_routine(routine_id: UUID, session: Session = Depends(get_session)):
routine = get_or_404(session, Routine, routine_id)
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,
)
session.delete(routine)
session.commit()
@ -1186,10 +1436,28 @@ def delete_routine(routine_id: UUID, session: Session = Depends(get_session)):
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),
):
get_or_404(session, Routine, routine_id)
step = RoutineStep(id=uuid4(), routine_id=routine_id, **data.model_dump())
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(),
)
session.add(step)
session.commit()
session.refresh(step)
@ -1200,9 +1468,21 @@ 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_or_404(session, RoutineStep, step_id)
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")
for key, value in data.model_dump(exclude_unset=True).items():
setattr(step, key, value)
session.add(step)
@ -1212,8 +1492,19 @@ def update_step(
@router.delete("/steps/{step_id}", status_code=204)
def delete_step(step_id: UUID, session: Session = Depends(get_session)):
step = get_or_404(session, RoutineStep, step_id)
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,
)
session.delete(step)
session.commit()
@ -1225,9 +1516,13 @@ def delete_step(step_id: UUID, session: Session = Depends(get_session)):
@router.post("/grooming-schedule", response_model=GroomingSchedule, status_code=201)
def create_grooming_schedule(
data: GroomingScheduleCreate, session: Session = Depends(get_session)
data: GroomingScheduleCreate,
user_id: UUID | None = Query(default=None),
session: Session = Depends(get_session),
current_user: CurrentUser = Depends(get_current_user),
):
entry = GroomingSchedule(id=uuid4(), **data.model_dump())
target_user_id = _resolve_target_user_id(current_user, user_id)
entry = GroomingSchedule(id=uuid4(), user_id=target_user_id, **data.model_dump())
session.add(entry)
session.commit()
session.refresh(entry)
@ -1238,9 +1533,17 @@ 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_or_404(session, GroomingSchedule, entry_id)
entry = _get_owned_or_admin_override(
session,
GroomingSchedule,
entry_id,
current_user,
user_id,
)
for key, value in data.model_dump(exclude_unset=True).items():
setattr(entry, key, value)
session.add(entry)
@ -1250,7 +1553,18 @@ def update_grooming_schedule(
@router.delete("/grooming-schedule/{entry_id}", status_code=204)
def delete_grooming_schedule(entry_id: UUID, session: Session = Depends(get_session)):
entry = get_or_404(session, GroomingSchedule, entry_id)
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,
)
session.delete(entry)
session.commit()

View file

@ -4,15 +4,17 @@ from datetime import date
from typing import Optional
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi import APIRouter, Depends, File, HTTPException, Query, 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_or_404
from innercontext.api.utils import get_owned_or_404
from innercontext.auth import CurrentUser
from innercontext.llm import call_gemini, get_extraction_config
from innercontext.models import (
SkinConditionSnapshot,
@ -26,6 +28,7 @@ from innercontext.models.enums import (
SkinTexture,
SkinType,
)
from innercontext.models.enums import Role
from innercontext.validators import PhotoValidator
logger = logging.getLogger(__name__)
@ -135,6 +138,34 @@ 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
@ -142,6 +173,7 @@ 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.")
@ -174,7 +206,11 @@ async def analyze_skin_photos(
)
parts.append(
genai_types.Part.from_text(
text=build_user_profile_context(session, reference_date=date.today())
text=build_user_profile_context(
session,
reference_date=date.today(),
current_user=current_user,
)
)
)
@ -224,9 +260,14 @@ 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),
):
stmt = select(SkinConditionSnapshot)
target_user_id = _resolve_target_user_id(current_user, user_id)
stmt = select(SkinConditionSnapshot).where(
SkinConditionSnapshot.user_id == target_user_id
)
if from_date is not None:
stmt = stmt.where(SkinConditionSnapshot.snapshot_date >= from_date)
if to_date is not None:
@ -237,8 +278,18 @@ def list_snapshots(
@router.post("", response_model=SkinConditionSnapshotPublic, status_code=201)
def create_snapshot(data: SnapshotCreate, session: Session = Depends(get_session)):
snapshot = SkinConditionSnapshot(id=uuid4(), **data.model_dump())
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(),
)
session.add(snapshot)
session.commit()
session.refresh(snapshot)
@ -246,17 +297,34 @@ def create_snapshot(data: SnapshotCreate, session: Session = Depends(get_session
@router.get("/{snapshot_id}", response_model=SkinConditionSnapshotPublic)
def get_snapshot(snapshot_id: UUID, session: Session = Depends(get_session)):
return get_or_404(session, SkinConditionSnapshot, snapshot_id)
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,
)
@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_or_404(session, SkinConditionSnapshot, snapshot_id)
snapshot = _get_owned_or_admin_override(
session,
snapshot_id,
current_user,
user_id,
)
for key, value in data.model_dump(exclude_unset=True).items():
setattr(snapshot, key, value)
session.add(snapshot)
@ -266,7 +334,17 @@ def update_snapshot(
@router.delete("/{snapshot_id}", status_code=204)
def delete_snapshot(snapshot_id: UUID, session: Session = Depends(get_session)):
snapshot = get_or_404(session, SkinConditionSnapshot, snapshot_id)
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,
)
session.delete(snapshot)
session.commit()

View file

@ -3,6 +3,18 @@ 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")
@ -11,3 +23,37 @@ 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)

View file

@ -0,0 +1,384 @@
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

View file

@ -1,4 +1,5 @@
from datetime import datetime
from uuid import UUID
from sqlmodel import Session, col, select
@ -66,9 +67,53 @@ 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(session, computed_at=utc_now())
updated_count = _apply_pricing_snapshot_for_scope(
session,
computed_at=utc_now(),
scope=job.scope,
)
job.status = "succeeded"
job.finished_at = utc_now()
job.error = None

View file

@ -1,17 +1,19 @@
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 FastAPI # noqa: E402
from fastapi import Depends, 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,
@ -19,15 +21,16 @@ 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}")
@ -47,13 +50,52 @@ app.add_middleware(
allow_headers=["*"],
)
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"])
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.get("/health-check")

View file

@ -8,6 +8,7 @@ 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",

View file

@ -1,4 +1,6 @@
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://")
@ -10,6 +12,9 @@ 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
@ -32,13 +37,35 @@ def session(monkeypatch):
@pytest.fixture()
def client(session, monkeypatch):
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):
"""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()

View file

@ -0,0 +1,354 @@
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"

View file

@ -4,12 +4,13 @@ from typing import Any, cast
from innercontext.models.ai_log import AICallLog
def test_list_ai_logs_normalizes_tool_trace_string(client, session):
def test_list_ai_logs_normalizes_tool_trace_string(client, session, current_user):
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,
@ -26,12 +27,13 @@ def test_list_ai_logs_normalizes_tool_trace_string(client, session):
assert data[0]["tool_trace"]["events"][0]["function"] == "get_product_inci"
def test_get_ai_log_normalizes_tool_trace_string(client, session):
def test_get_ai_log_normalizes_tool_trace_string(client, session, current_user):
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)

275
backend/tests/test_auth.py Normal file
View file

@ -0,0 +1,275 @@
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

293
backend/tests/test_authz.py Normal file
View file

@ -0,0 +1,293 @@
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

View file

@ -0,0 +1,370 @@
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

View file

@ -28,20 +28,27 @@ from innercontext.validators.shopping_validator import (
)
def test_build_shopping_context(session: Session):
def test_build_shopping_context(session: Session, current_user):
# Empty context
ctx = _build_shopping_context(session, reference_date=date.today())
ctx = _build_shopping_context(
session, reference_date=date.today(), current_user=current_user
)
assert "USER PROFILE: no data" in ctx
assert "(brak danych)" in ctx
assert "POSIADANE PRODUKTY" in ctx
profile = UserProfile(birth_date=date(1990, 1, 10), sex_at_birth=SexAtBirth.MALE)
profile = UserProfile(
user_id=current_user.user_id,
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",
@ -79,7 +86,9 @@ def test_build_shopping_context(session: Session):
session.add(inv)
session.commit()
ctx = _build_shopping_context(session, reference_date=date(2026, 3, 5))
ctx = _build_shopping_context(
session, reference_date=date(2026, 3, 5), current_user=current_user
)
assert "USER PROFILE:" in ctx
assert "Age: 36" in ctx
assert "Sex at birth: male" in ctx
@ -106,7 +115,9 @@ def test_build_shopping_context(session: Session):
assert "repurchase_candidate=true" in ctx
def test_build_shopping_context_flags_replenishment_signal(session: Session):
def test_build_shopping_context_flags_replenishment_signal(
session: Session, current_user
):
product = Product(
id=uuid.uuid4(),
short_id=str(uuid.uuid4())[:8],
@ -116,6 +127,7 @@ def test_build_shopping_context_flags_replenishment_signal(session: Session):
recommended_time="both",
leave_on=False,
product_effect_profile={},
user_id=current_user.user_id,
)
session.add(product)
session.commit()
@ -130,7 +142,9 @@ def test_build_shopping_context_flags_replenishment_signal(session: Session):
)
session.commit()
ctx = _build_shopping_context(session, reference_date=date.today())
ctx = _build_shopping_context(
session, reference_date=date.today(), current_user=current_user
)
assert "lowest_remaining_level=nearly_empty" in ctx
assert "stock_state=urgent" in ctx
assert "replenishment_priority_hint=high" in ctx
@ -287,7 +301,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):
def test_shopping_context_medication_skip(session: Session, current_user):
p = Product(
id=uuid.uuid4(),
short_id=str(uuid.uuid4())[:8],
@ -298,11 +312,14 @@ def test_shopping_context_medication_skip(session: Session):
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())
ctx = _build_shopping_context(
session, reference_date=date.today(), current_user=current_user
)
assert "Epiduo" not in ctx

View file

@ -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
from innercontext.models.enums import BarrierState, OverallSkinState, PartOfDay
# ---------------------------------------------------------------------------
# Routines
@ -223,13 +223,14 @@ def test_delete_grooming_schedule_not_found(client):
assert r.status_code == 404
def test_suggest_routine(client, session):
def test_suggest_routine(client, session, current_user):
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,
@ -272,18 +273,20 @@ def test_suggest_routine(client, session):
assert "get_product_details" in kwargs["function_handlers"]
def test_suggest_batch(client, session):
def test_suggest_batch(client, session, current_user):
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="pm",
part_of_day=PartOfDay.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,

View file

@ -0,0 +1,112 @@
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"

View file

@ -78,17 +78,22 @@ def test_ev():
assert _ev("string") == "string"
def test_build_skin_context(session: Session):
def test_build_skin_context(session: Session, current_user):
# Empty
reference_date = date(2026, 3, 10)
assert (
_build_skin_context(session, reference_date=reference_date)
_build_skin_context(
session,
target_user_id=current_user.user_id,
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,
@ -100,7 +105,11 @@ def test_build_skin_context(session: Session):
session.add(snap)
session.commit()
ctx = _build_skin_context(session, reference_date=reference_date)
ctx = _build_skin_context(
session,
target_user_id=current_user.user_id,
reference_date=reference_date,
)
assert "SKIN CONDITION (snapshot from" in ctx
assert "Overall state: good" in ctx
assert "Hydration: 4/5" in ctx
@ -112,10 +121,12 @@ def test_build_skin_context(session: Session):
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,
@ -126,16 +137,23 @@ def test_build_skin_context_falls_back_to_recent_snapshot_within_14_days(
session.add(snap)
session.commit()
ctx = _build_skin_context(session, reference_date=reference_date)
ctx = _build_skin_context(
session,
target_user_id=current_user.user_id,
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):
def test_build_skin_context_ignores_snapshot_older_than_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=15),
overall_state=OverallSkinState.FAIR,
hydration_level=3,
@ -145,15 +163,20 @@ def test_build_skin_context_ignores_snapshot_older_than_14_days(session: Session
session.commit()
assert (
_build_skin_context(session, reference_date=reference_date)
_build_skin_context(
session,
target_user_id=current_user.user_id,
reference_date=reference_date,
)
== "SKIN CONDITION: no data\n"
)
def test_get_recent_skin_snapshot_prefers_window_match(session: Session):
def test_get_recent_skin_snapshot_prefers_window_match(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,
@ -161,6 +184,7 @@ def test_get_recent_skin_snapshot_prefers_window_match(session: Session):
)
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,
@ -169,7 +193,11 @@ def test_get_recent_skin_snapshot_prefers_window_match(session: Session):
session.add_all([older, newer])
session.commit()
snapshot = _get_recent_skin_snapshot(session, reference_date=reference_date)
snapshot = _get_recent_skin_snapshot(
session,
target_user_id=current_user.user_id,
reference_date=reference_date,
)
assert snapshot is not None
assert snapshot.id == newer.id
@ -177,10 +205,12 @@ def test_get_recent_skin_snapshot_prefers_window_match(session: Session):
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,
@ -188,6 +218,7 @@ 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,
@ -198,6 +229,7 @@ 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,
)
@ -205,39 +237,65 @@ 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):
assert _build_grooming_context(session) == "GROOMING SCHEDULE: none\n"
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"
)
sch = GroomingSchedule(
id=uuid.uuid4(), day_of_week=0, action="shaving_oneblade", notes="Morning"
id=uuid.uuid4(),
user_id=current_user.user_id,
day_of_week=0,
action="shaving_oneblade",
notes="Morning",
)
session.add(sch)
session.commit()
ctx = _build_grooming_context(session)
ctx = _build_grooming_context(session, target_user_id=current_user.user_id)
assert "GROOMING SCHEDULE:" in ctx
assert "poniedziałek: shaving_oneblade (Morning)" in ctx
# Test weekdays filter
ctx2 = _build_grooming_context(session, weekdays=[1]) # not monday
ctx2 = _build_grooming_context(
session,
target_user_id=current_user.user_id,
weekdays=[1],
) # not monday
assert "(no entries for specified days)" in ctx2
def test_build_upcoming_grooming_context(session: Session):
def test_build_upcoming_grooming_context(session: Session, current_user):
assert (
_build_upcoming_grooming_context(session, start_date=date(2026, 3, 2), days=7)
_build_upcoming_grooming_context(
session,
target_user_id=current_user.user_id,
start_date=date(2026, 3, 2),
days=7,
)
== "UPCOMING GROOMING (next 7 days): none\n"
)
monday = GroomingSchedule(
id=uuid.uuid4(), day_of_week=0, action="shaving_oneblade", notes="Morning"
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",
)
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,
)
@ -246,14 +304,23 @@ def test_build_upcoming_grooming_context(session: Session):
assert "za 2 dni (2026-03-04, środa): dermarolling" in ctx
def test_build_recent_history(session: Session):
def test_build_recent_history(session: Session, current_user):
reference_date = date(2026, 3, 10)
assert (
_build_recent_history(session, reference_date=reference_date)
_build_recent_history(
session,
target_user_id=current_user.user_id,
reference_date=reference_date,
)
== "RECENT ROUTINES: none\n"
)
r = Routine(id=uuid.uuid4(), routine_date=reference_date, part_of_day="am")
r = Routine(
id=uuid.uuid4(),
user_id=current_user.user_id,
routine_date=reference_date,
part_of_day="am",
)
session.add(r)
p = Product(
id=uuid.uuid4(),
@ -268,19 +335,37 @@ def test_build_recent_history(session: Session):
session.add(p)
session.commit()
s1 = RoutineStep(id=uuid.uuid4(), routine_id=r.id, order_index=1, product_id=p.id)
s1 = RoutineStep(
id=uuid.uuid4(),
user_id=current_user.user_id,
routine_id=r.id,
order_index=1,
product_id=p.id,
)
s2 = RoutineStep(
id=uuid.uuid4(), routine_id=r.id, order_index=2, action_type="shaving_razor"
id=uuid.uuid4(),
user_id=current_user.user_id,
routine_id=r.id,
order_index=2,
action_type="shaving_razor",
)
# Step with non-existent product
s3 = RoutineStep(
id=uuid.uuid4(), routine_id=r.id, order_index=3, product_id=uuid.uuid4()
id=uuid.uuid4(),
user_id=current_user.user_id,
routine_id=r.id,
order_index=3,
product_id=uuid.uuid4(),
)
session.add_all([s1, s2, s3])
session.commit()
ctx = _build_recent_history(session, reference_date=reference_date)
ctx = _build_recent_history(
session,
target_user_id=current_user.user_id,
reference_date=reference_date,
)
assert "RECENT ROUTINES:" in ctx
assert "AM:" in ctx
assert "cleanser [" in ctx
@ -288,31 +373,38 @@ def test_build_recent_history(session: Session):
assert "unknown [" in ctx
def test_build_recent_history_uses_reference_window(session: Session):
def test_build_recent_history_uses_reference_window(session: Session, current_user):
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, reference_date=reference_date)
ctx = _build_recent_history(
session,
target_user_id=current_user.user_id,
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):
def test_build_recent_history_excludes_future_routines(session: Session, current_user):
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",
)
@ -320,12 +412,16 @@ def test_build_recent_history_excludes_future_routines(session: Session):
session.commit()
assert (
_build_recent_history(session, reference_date=reference_date)
_build_recent_history(
session,
target_user_id=current_user.user_id,
reference_date=reference_date,
)
== "RECENT ROUTINES: none\n"
)
def test_build_products_context_summary_list(session: Session):
def test_build_products_context_summary_list(session: Session, current_user):
p1 = Product(
id=uuid.uuid4(),
short_id=str(uuid.uuid4())[:8],
@ -336,6 +432,7 @@ def test_build_products_context_summary_list(session: Session):
recommended_time="both",
leave_on=True,
product_effect_profile={},
user_id=current_user.user_id,
)
p2 = Product(
id=uuid.uuid4(),
@ -350,11 +447,16 @@ def test_build_products_context_summary_list(session: Session):
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, time_filter="am")
products_am = _get_available_products(
session,
current_user=current_user,
time_filter="am",
)
ctx = build_products_context_summary_list(products_am, {p2.id})
assert "Regaine Minoxidil" in ctx
@ -375,9 +477,10 @@ def test_build_day_context():
assert "Leaving home: no" in _build_day_context(False)
def test_get_available_products_respects_filters(session: Session):
def test_get_available_products_respects_filters(session: Session, current_user):
regular_med = Product(
id=uuid.uuid4(),
short_id=str(uuid.uuid4())[:8],
name="Tretinoin",
category="serum",
is_medication=True,
@ -385,9 +488,11 @@ def test_get_available_products_respects_filters(session: Session):
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,
@ -395,29 +500,38 @@ def test_get_available_products_respects_filters(session: Session):
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, time_filter="am")
am_available = _get_available_products(
session,
current_user=current_user,
time_filter="am",
)
am_names = {p.name for p in am_available}
assert "Tretinoin" not in am_names
assert "Minoxidil 5%" in am_names
@ -430,6 +544,7 @@ 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",
@ -440,6 +555,7 @@ 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",
@ -464,9 +580,8 @@ 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"] == str(available.id)
assert products[0]["id"] == available.short_id
assert products[0]["name"] == "Available"
assert products[0]["inci"] == ["Water", "Niacinamide"]
assert "actives" in products[0]
assert "safety" in products[0]
@ -508,9 +623,13 @@ 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):
def test_get_available_products_excludes_minoxidil_when_flag_false(
session: Session,
current_user,
):
minoxidil = Product(
id=uuid.uuid4(),
short_id=str(uuid.uuid4())[:8],
name="Minoxidil 5%",
category="hair_treatment",
is_medication=True,
@ -518,27 +637,38 @@ def test_get_available_products_excludes_minoxidil_when_flag_false(session: Sess
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, include_minoxidil=True)
products = _get_available_products(
session,
current_user=current_user,
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, include_minoxidil=False)
products = _get_available_products(
session,
current_user=current_user,
include_minoxidil=False,
)
names = {p.name for p in products}
assert "Minoxidil 5%" not in names
assert "Cleanser" in names

View file

@ -140,7 +140,7 @@ def test_analyze_photos_includes_user_profile_context(client, monkeypatch):
def _fake_call_gemini(**kwargs):
captured.update(kwargs)
return _FakeResponse()
return _FakeResponse(), None
monkeypatch.setattr(skincare_api, "call_gemini", _fake_call_gemini)

View file

@ -0,0 +1,100 @@
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

16
backend/uv.lock generated
View file

@ -557,6 +557,7 @@ dependencies = [
{ name = "fastapi" },
{ name = "google-genai" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-dotenv" },
{ name = "python-multipart" },
{ name = "sqlmodel" },
@ -580,6 +581,7 @@ 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" },
@ -909,6 +911,20 @@ 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"

View file

@ -346,7 +346,8 @@ check_backend_health() {
check_frontend_health() {
local i
for ((i = 1; i <= 30; i++)); do
if remote "curl -sf http://127.0.0.1:3000/ >/dev/null"; then
# 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
log "Frontend health check passed"
return 0
fi

View file

@ -94,117 +94,82 @@ chown -R innercontext:innercontext /opt/innercontext
cat > /opt/innercontext/shared/backend/.env <<'EOF'
DATABASE_URL=postgresql+psycopg://innercontext:change-me@<pg-ip>/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
```
### 4) Grant deploy sudo permissions
## OIDC Setup (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
This project uses OIDC for authentication. You need an OIDC provider like Authelia.
chmod 440 /etc/sudoers.d/innercontext-deploy
visudo -c -f /etc/sudoers.d/innercontext-deploy
### Authelia Client Configuration
# Must work without password or TTY prompt:
sudo -u innercontext sudo -n -l
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
```
If `sudo -n -l` fails, deployments will fail during restart/rollback with:
`sudo: a terminal is required` or `sudo: a password is required`.
### Bootstrap Admin
### 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 <lxc-ip>
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 `<timestamp>` 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.
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.
## Health Checks
- Backend: `http://127.0.0.1:8000/health-check`
- Frontend: `http://127.0.0.1:3000/`
- 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)
- Worker: `systemctl is-active innercontext-pricing-worker`
Manual checks:

View file

@ -10,6 +10,11 @@
"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",

View file

@ -10,6 +10,11 @@
"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",

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,15 @@
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 {}
interface Locals {
session: AppSession | null;
user: AuthUserPublic | null;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}

View file

@ -1,6 +1,38 @@
import { paraglideMiddleware } from "$lib/paraglide/server.js";
import type { Handle } from "@sveltejs/kit";
import {
buildLoginPath,
getRequestSession,
isBackendRequest,
isProtectedPath,
loadAuthenticatedSession,
} from "$lib/server/auth";
import type { Handle, HandleFetch } from "@sveltejs/kit";
import { redirect } from "@sveltejs/kit";
export const handle: Handle = async ({ event, resolve }) => {
return paraglideMiddleware(event.request, () => resolve(event));
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 }));
};

View file

@ -20,43 +20,87 @@ import type {
UserProfile,
} from "./types";
// ─── Core fetch helpers ──────────────────────────────────────────────────────
export interface ApiClientOptions {
fetch?: typeof globalThis.fetch;
accessToken?: string;
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
// 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 },
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<T>(
path: string,
init: RequestInit = {},
options: ApiClientOptions = {},
): Promise<T> {
const url = `${resolveBase(options)}${path}`;
const requestFn = options.fetch ?? fetch;
const res = await requestFn(url, {
headers: buildHeaders(init.headers, init.body, options.accessToken),
...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 const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body: unknown) =>
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
patch: <T>(path: string, body: unknown) =>
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
del: (path: string) => request<void>(path, { method: "DELETE" }),
};
export function createApiClient(options: ApiClientOptions = {}) {
return {
get: <T>(path: string) => request<T>(path, {}, options),
post: <T>(path: string, body: unknown) =>
request<T>(path, { method: "POST", body: JSON.stringify(body) }, options),
postForm: <T>(path: string, body: FormData) =>
request<T>(path, { method: "POST", body }, options),
patch: <T>(path: string, body: unknown) =>
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }, options),
del: (path: string) => request<void>(path, { method: "DELETE" }, options),
};
}
// ─── Profile ─────────────────────────────────────────────────────────────────
export type ApiClient = ReturnType<typeof createApiClient>;
export const getProfile = (): Promise<UserProfile | null> => api.get("/profile");
export const api = createApiClient();
function resolveClient(options?: ApiClientOptions): ApiClient {
return options ? createApiClient(options) : api;
}
export const getProfile = (
options?: ApiClientOptions,
): Promise<UserProfile | null> => resolveClient(options).get("/profile");
export const updateProfile = (
body: { birth_date?: string; sex_at_birth?: "male" | "female" | "intersex" },
): Promise<UserProfile> => api.patch("/profile", body);
// ─── Products ────────────────────────────────────────────────────────────────
options?: ApiClientOptions,
): Promise<UserProfile> => resolveClient(options).patch("/profile", body);
export interface ProductListParams {
category?: string;
@ -68,62 +112,87 @@ export interface ProductListParams {
export function getProducts(
params: ProductListParams = {},
options?: ApiClientOptions,
): Promise<Product[]> {
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 api.get(`/products${qs ? `?${qs}` : ""}`);
return resolveClient(options).get(`/products${qs ? `?${qs}` : ""}`);
}
export function getProductSummaries(
params: ProductListParams = {},
options?: ApiClientOptions,
): Promise<ProductSummary[]> {
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 api.get(`/products/summary${qs ? `?${qs}` : ""}`);
return resolveClient(options).get(`/products/summary${qs ? `?${qs}` : ""}`);
}
export const getProduct = (id: string): Promise<Product> =>
api.get(`/products/${id}`);
export const getProduct = (
id: string,
options?: ApiClientOptions,
): Promise<Product> => resolveClient(options).get(`/products/${id}`);
export const createProduct = (
body: Record<string, unknown>,
): Promise<Product> => api.post("/products", body);
options?: ApiClientOptions,
): Promise<Product> => resolveClient(options).post("/products", body);
export const updateProduct = (
id: string,
body: Record<string, unknown>,
): Promise<Product> => api.patch(`/products/${id}`, body);
export const deleteProduct = (id: string): Promise<void> =>
api.del(`/products/${id}`);
options?: ApiClientOptions,
): Promise<Product> => resolveClient(options).patch(`/products/${id}`, body);
export const deleteProduct = (
id: string,
options?: ApiClientOptions,
): Promise<void> => resolveClient(options).del(`/products/${id}`);
export const getInventory = (
productId: string,
options?: ApiClientOptions,
): Promise<ProductInventory[]> =>
resolveClient(options).get(`/products/${productId}/inventory`);
export const getInventory = (productId: string): Promise<ProductInventory[]> =>
api.get(`/products/${productId}/inventory`);
export const createInventory = (
productId: string,
body: Record<string, unknown>,
options?: ApiClientOptions,
): Promise<ProductInventory> =>
api.post(`/products/${productId}/inventory`, body);
resolveClient(options).post(`/products/${productId}/inventory`, body);
export const updateInventory = (
id: string,
body: Record<string, unknown>,
): Promise<ProductInventory> => api.patch(`/inventory/${id}`, body);
export const deleteInventory = (id: string): Promise<void> =>
api.del(`/inventory/${id}`);
options?: ApiClientOptions,
): Promise<ProductInventory> =>
resolveClient(options).patch(`/inventory/${id}`, body);
export const parseProductText = (text: string): Promise<ProductParseResponse> =>
api.post("/products/parse-text", { text });
export const deleteInventory = (
id: string,
options?: ApiClientOptions,
): Promise<void> => resolveClient(options).del(`/inventory/${id}`);
// ─── Routines ────────────────────────────────────────────────────────────────
export const parseProductText = (
text: string,
options?: ApiClientOptions,
): Promise<ProductParseResponse> =>
resolveClient(options).post("/products/parse-text", { text });
export interface RoutineListParams {
from_date?: string;
@ -133,68 +202,103 @@ export interface RoutineListParams {
export function getRoutines(
params: RoutineListParams = {},
options?: ApiClientOptions,
): Promise<Routine[]> {
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 api.get(`/routines${qs ? `?${qs}` : ""}`);
return resolveClient(options).get(`/routines${qs ? `?${qs}` : ""}`);
}
export const getRoutine = (id: string): Promise<Routine> =>
api.get(`/routines/${id}`);
export const getRoutine = (
id: string,
options?: ApiClientOptions,
): Promise<Routine> => resolveClient(options).get(`/routines/${id}`);
export const createRoutine = (
body: Record<string, unknown>,
): Promise<Routine> => api.post("/routines", body);
options?: ApiClientOptions,
): Promise<Routine> => resolveClient(options).post("/routines", body);
export const updateRoutine = (
id: string,
body: Record<string, unknown>,
): Promise<Routine> => api.patch(`/routines/${id}`, body);
export const deleteRoutine = (id: string): Promise<void> =>
api.del(`/routines/${id}`);
options?: ApiClientOptions,
): Promise<Routine> => resolveClient(options).patch(`/routines/${id}`, body);
export const deleteRoutine = (
id: string,
options?: ApiClientOptions,
): Promise<void> => resolveClient(options).del(`/routines/${id}`);
export const addRoutineStep = (
routineId: string,
body: Record<string, unknown>,
): Promise<RoutineStep> => api.post(`/routines/${routineId}/steps`, body);
options?: ApiClientOptions,
): Promise<RoutineStep> =>
resolveClient(options).post(`/routines/${routineId}/steps`, body);
export const updateRoutineStep = (
stepId: string,
body: Record<string, unknown>,
): Promise<RoutineStep> => api.patch(`/routines/steps/${stepId}`, body);
export const deleteRoutineStep = (stepId: string): Promise<void> =>
api.del(`/routines/steps/${stepId}`);
options?: ApiClientOptions,
): Promise<RoutineStep> =>
resolveClient(options).patch(`/routines/steps/${stepId}`, body);
export const suggestRoutine = (body: {
routine_date: string;
part_of_day: PartOfDay;
notes?: string;
include_minoxidil_beard?: boolean;
leaving_home?: boolean;
}): Promise<RoutineSuggestion> => api.post("/routines/suggest", body);
export const deleteRoutineStep = (
stepId: string,
options?: ApiClientOptions,
): Promise<void> => resolveClient(options).del(`/routines/steps/${stepId}`);
export const suggestBatch = (body: {
from_date: string;
to_date: string;
notes?: string;
include_minoxidil_beard?: boolean;
minimize_products?: boolean;
}): Promise<BatchSuggestion> => api.post("/routines/suggest-batch", body);
export const suggestRoutine = (
body: {
routine_date: string;
part_of_day: PartOfDay;
notes?: string;
include_minoxidil_beard?: boolean;
leaving_home?: boolean;
},
options?: ApiClientOptions,
): Promise<RoutineSuggestion> =>
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<BatchSuggestion> =>
resolveClient(options).post("/routines/suggest-batch", body);
export const getGroomingSchedule = (
options?: ApiClientOptions,
): Promise<GroomingSchedule[]> =>
resolveClient(options).get("/routines/grooming-schedule");
export const getGroomingSchedule = (): Promise<GroomingSchedule[]> =>
api.get("/routines/grooming-schedule");
export const createGroomingScheduleEntry = (
body: Record<string, unknown>,
): Promise<GroomingSchedule> => api.post("/routines/grooming-schedule", body);
options?: ApiClientOptions,
): Promise<GroomingSchedule> =>
resolveClient(options).post("/routines/grooming-schedule", body);
export const updateGroomingScheduleEntry = (
id: string,
body: Record<string, unknown>,
options?: ApiClientOptions,
): Promise<GroomingSchedule> =>
api.patch(`/routines/grooming-schedule/${id}`, body);
export const deleteGroomingScheduleEntry = (id: string): Promise<void> =>
api.del(`/routines/grooming-schedule/${id}`);
resolveClient(options).patch(`/routines/grooming-schedule/${id}`, body);
// ─── Health Medications ────────────────────────────────────────────────────
export const deleteGroomingScheduleEntry = (
id: string,
options?: ApiClientOptions,
): Promise<void> =>
resolveClient(options).del(`/routines/grooming-schedule/${id}`);
export interface MedicationListParams {
kind?: string;
@ -203,37 +307,51 @@ export interface MedicationListParams {
export function getMedications(
params: MedicationListParams = {},
options?: ApiClientOptions,
): Promise<MedicationEntry[]> {
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 api.get(`/health/medications${qs ? `?${qs}` : ""}`);
return resolveClient(options).get(`/health/medications${qs ? `?${qs}` : ""}`);
}
export const getMedication = (id: string): Promise<MedicationEntry> =>
api.get(`/health/medications/${id}`);
export const getMedication = (
id: string,
options?: ApiClientOptions,
): Promise<MedicationEntry> =>
resolveClient(options).get(`/health/medications/${id}`);
export const createMedication = (
body: Record<string, unknown>,
): Promise<MedicationEntry> => api.post("/health/medications", body);
options?: ApiClientOptions,
): Promise<MedicationEntry> =>
resolveClient(options).post("/health/medications", body);
export const updateMedication = (
id: string,
body: Record<string, unknown>,
): Promise<MedicationEntry> => api.patch(`/health/medications/${id}`, body);
export const deleteMedication = (id: string): Promise<void> =>
api.del(`/health/medications/${id}`);
options?: ApiClientOptions,
): Promise<MedicationEntry> =>
resolveClient(options).patch(`/health/medications/${id}`, body);
export const deleteMedication = (
id: string,
options?: ApiClientOptions,
): Promise<void> => resolveClient(options).del(`/health/medications/${id}`);
export const getMedicationUsages = (
medicationId: string,
options?: ApiClientOptions,
): Promise<MedicationUsage[]> =>
api.get(`/health/medications/${medicationId}/usages`);
resolveClient(options).get(`/health/medications/${medicationId}/usages`);
export const createMedicationUsage = (
medicationId: string,
body: Record<string, unknown>,
options?: ApiClientOptions,
): Promise<MedicationUsage> =>
api.post(`/health/medications/${medicationId}/usages`, body);
// ─── Health Lab results ────────────────────────────────────────────────────
resolveClient(options).post(`/health/medications/${medicationId}/usages`, body);
export interface LabResultListParams {
q?: string;
@ -250,6 +368,7 @@ export interface LabResultListParams {
export function getLabResults(
params: LabResultListParams = {},
options?: ApiClientOptions,
): Promise<LabResultListResponse> {
const q = new URLSearchParams();
if (params.q) q.set("q", params.q);
@ -258,29 +377,43 @@ 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 api.get(`/health/lab-results${qs ? `?${qs}` : ""}`);
return resolveClient(options).get(`/health/lab-results${qs ? `?${qs}` : ""}`);
}
export const getLabResult = (id: string): Promise<LabResult> =>
api.get(`/health/lab-results/${id}`);
export const getLabResult = (
id: string,
options?: ApiClientOptions,
): Promise<LabResult> =>
resolveClient(options).get(`/health/lab-results/${id}`);
export const createLabResult = (
body: Record<string, unknown>,
): Promise<LabResult> => api.post("/health/lab-results", body);
options?: ApiClientOptions,
): Promise<LabResult> =>
resolveClient(options).post("/health/lab-results", body);
export const updateLabResult = (
id: string,
body: Record<string, unknown>,
): Promise<LabResult> => api.patch(`/health/lab-results/${id}`, body);
export const deleteLabResult = (id: string): Promise<void> =>
api.del(`/health/lab-results/${id}`);
options?: ApiClientOptions,
): Promise<LabResult> =>
resolveClient(options).patch(`/health/lab-results/${id}`, body);
// ─── Skin ────────────────────────────────────────────────────────────────────
export const deleteLabResult = (
id: string,
options?: ApiClientOptions,
): Promise<void> => resolveClient(options).del(`/health/lab-results/${id}`);
export interface SnapshotListParams {
from_date?: string;
@ -290,40 +423,47 @@ export interface SnapshotListParams {
export function getSkinSnapshots(
params: SnapshotListParams = {},
options?: ApiClientOptions,
): Promise<SkinConditionSnapshot[]> {
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 api.get(`/skincare${qs ? `?${qs}` : ""}`);
return resolveClient(options).get(`/skincare${qs ? `?${qs}` : ""}`);
}
export const getSkinSnapshot = (id: string): Promise<SkinConditionSnapshot> =>
api.get(`/skincare/${id}`);
export const getSkinSnapshot = (
id: string,
options?: ApiClientOptions,
): Promise<SkinConditionSnapshot> => resolveClient(options).get(`/skincare/${id}`);
export const createSkinSnapshot = (
body: Record<string, unknown>,
): Promise<SkinConditionSnapshot> => api.post("/skincare", body);
options?: ApiClientOptions,
): Promise<SkinConditionSnapshot> => resolveClient(options).post("/skincare", body);
export const updateSkinSnapshot = (
id: string,
body: Record<string, unknown>,
): Promise<SkinConditionSnapshot> => api.patch(`/skincare/${id}`, body);
export const deleteSkinSnapshot = (id: string): Promise<void> =>
api.del(`/skincare/${id}`);
options?: ApiClientOptions,
): Promise<SkinConditionSnapshot> =>
resolveClient(options).patch(`/skincare/${id}`, body);
export const deleteSkinSnapshot = (
id: string,
options?: ApiClientOptions,
): Promise<void> => resolveClient(options).del(`/skincare/${id}`);
export async function analyzeSkinPhotos(
files: File[],
files: File[] | FormData,
options?: ApiClientOptions,
): Promise<SkinPhotoAnalysisResponse> {
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);
const body = files instanceof FormData ? files : new FormData();
if (!(files instanceof FormData)) {
for (const file of files) body.append("photos", file);
}
return res.json();
return resolveClient(options).postForm("/skincare/analyze-photos", body);
}

File diff suppressed because one or more lines are too long

View file

@ -12,6 +12,10 @@ export type AiCallLog = {
* Id
*/
id?: string;
/**
* User Id
*/
user_id?: string | null;
/**
* Created At
*/
@ -187,6 +191,98 @@ 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<string>;
};
/**
* 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
*/
@ -265,6 +361,10 @@ export type GroomingSchedule = {
* Id
*/
id?: string;
/**
* User Id
*/
user_id?: string | null;
/**
* Day Of Week
*/
@ -316,6 +416,11 @@ export type HttpValidationError = {
detail?: Array<ValidationError>;
};
/**
* HouseholdRole
*/
export type HouseholdRole = 'owner' | 'member';
/**
* IngredientFunction
*/
@ -383,6 +488,10 @@ export type LabResult = {
* Record Id
*/
record_id?: string;
/**
* User Id
*/
user_id?: string | null;
/**
* Collected At
*/
@ -649,6 +758,10 @@ export type MedicationEntry = {
* Record Id
*/
record_id?: string;
/**
* User Id
*/
user_id?: string | null;
kind: MedicationKind;
/**
* Product Name
@ -728,6 +841,10 @@ export type MedicationUsage = {
* Record Id
*/
record_id?: string;
/**
* User Id
*/
user_id?: string | null;
/**
* Medication Record Id
*/
@ -1010,10 +1127,18 @@ 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
*/
@ -1689,6 +1814,11 @@ export type ResponseMetadata = {
*/
export type ResultFlag = 'N' | 'ABN' | 'POS' | 'NEG' | 'L' | 'H';
/**
* Role
*/
export type Role = 'admin' | 'member';
/**
* Routine
*/
@ -1697,6 +1827,10 @@ export type Routine = {
* Id
*/
id?: string;
/**
* User Id
*/
user_id?: string | null;
/**
* Routine Date
*/
@ -1739,6 +1873,10 @@ export type RoutineStep = {
* Id
*/
id?: string;
/**
* User Id
*/
user_id?: string | null;
/**
* Routine Id
*/
@ -1877,6 +2015,36 @@ 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<string> | null;
};
/**
* SexAtBirth
*/
@ -2364,6 +2532,50 @@ 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;

View file

@ -1,4 +1,4 @@
<script lang="ts">
<script lang="ts">
import { untrack } from 'svelte';
import type { Product, IngredientFunction, ProductParseResponse } from '$lib/types';
import { Button } from '$lib/components/ui/button';
@ -186,13 +186,27 @@
let editSection = $state<'basic' | 'ingredients' | 'assessment' | 'details' | 'notes'>('basic');
let activesPanelOpen = $state(true);
async function requestProductParse(text: string): Promise<ProductParseResponse> {
const response = await fetch('/api/products/parse-text', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
});
if (!response.ok) {
const detail = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(detail?.detail ?? response.statusText);
}
return response.json();
}
async function parseWithAi() {
if (!aiText.trim()) return;
aiLoading = true;
aiError = '';
try {
const { parseProductText } = await import('$lib/api');
const r = await parseProductText(aiText);
const r = await requestProductParse(aiText);
applyAiResult(r);
aiModalOpen = false;
} catch (e) {

View file

@ -0,0 +1,18 @@
import type { ApiClientOptions } from "$lib/api";
import { error } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
type SessionEvent = Pick<RequestEvent, "fetch" | "locals">;
export function getSessionApiOptions(event: SessionEvent): ApiClientOptions {
const accessToken = event.locals.session?.accessToken;
if (!accessToken) {
throw error(401, "Authentication required");
}
return {
fetch: event.fetch,
accessToken,
};
}

View file

@ -0,0 +1,695 @@
import { dev } from "$app/environment";
import { env as privateEnv } from "$env/dynamic/private";
import { env as publicEnv } from "$env/dynamic/public";
import type { Cookies, RequestEvent } from "@sveltejs/kit";
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
timingSafeEqual,
} from "node:crypto";
import type {
AuthIdentityPublic,
AuthProfilePublic,
AuthSessionResponse,
AuthUserPublic,
} from "$lib/api/generated/types.gen";
const AUTH_COOKIE_NAME = "innercontext_session";
const LOGIN_COOKIE_NAME = "innercontext_login";
const DISCOVERY_PATH = "/.well-known/openid-configuration";
const AUTH_FLOW_TTL_SECONDS = 600;
const SESSION_REFRESH_WINDOW_SECONDS = 60;
const DEFAULT_SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
const DEFAULT_API_BASE = "http://localhost:8000";
const DEFAULT_SCOPES = "openid profile email groups offline_access";
const CIPHER_ALGORITHM = "aes-256-gcm";
const CURRENT_VERSION = 1;
type SameSite = "lax" | "strict" | "none";
interface AuthConfig {
issuer: string;
clientId: string;
clientSecret: string | null;
discoveryUrl: string;
scopes: string;
sessionSecret: string;
}
interface DiscoveryDocument {
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint: string;
end_session_endpoint?: string;
}
interface TokenResponse {
access_token: string;
expires_in?: number;
refresh_token?: string;
refresh_token_expires_in?: number;
refresh_expires_in?: number;
token_type?: string;
scope?: string;
id_token?: string;
}
interface UserInfoClaims {
iss?: string;
sub: string;
email?: string | null;
name?: string | null;
preferred_username?: string | null;
groups?: string[];
}
interface LoginFlowState {
state: string;
codeVerifier: string;
returnTo: string;
}
export interface AppSession {
accessToken: string;
refreshToken: string | null;
tokenType: string;
scope: string | null;
expiresAt: number;
refreshExpiresAt: number | null;
user: AuthUserPublic;
identity: AuthIdentityPublic;
profile: AuthProfilePublic | null;
}
interface SerializedPayload<T> {
v: number;
data: T;
}
let cachedDiscovery: Promise<DiscoveryDocument> | null = null;
function getAuthConfig(): AuthConfig {
const issuer = requiredEnv("OIDC_ISSUER");
return {
issuer,
clientId: requiredEnv("OIDC_CLIENT_ID"),
clientSecret: optionalEnv("OIDC_CLIENT_SECRET"),
discoveryUrl:
optionalEnv("OIDC_DISCOVERY_URL") ??
`${issuer.replace(/\/+$/, "")}${DISCOVERY_PATH}`,
scopes: optionalEnv("OIDC_SCOPES") ?? DEFAULT_SCOPES,
sessionSecret: requiredEnv("SESSION_SECRET"),
};
}
function requiredEnv(name: string): string {
const value = privateEnv[name]?.trim();
if (!value) {
throw new Error(`Missing required auth environment variable: ${name}`);
}
return value;
}
function optionalEnv(name: string): string | null {
const value = privateEnv[name]?.trim();
return value ? value : null;
}
function getApiBase(): string {
return publicEnv.PUBLIC_API_BASE?.trim() || DEFAULT_API_BASE;
}
function cookieOptions(maxAge: number) {
return {
path: "/",
httpOnly: true,
sameSite: "lax" as SameSite,
secure: !dev,
maxAge,
};
}
function getSecretKey(): Buffer {
const configured = getAuthConfig().sessionSecret;
if (/^[0-9a-fA-F]{64}$/.test(configured)) {
return Buffer.from(configured, "hex");
}
if (/^[A-Za-z0-9_-]{43,44}$/.test(configured)) {
const decoded = Buffer.from(configured, "base64url");
if (decoded.length === 32) {
return decoded;
}
}
const utf8 = Buffer.from(configured, "utf8");
if (utf8.length >= 32) {
return createHash("sha256").update(utf8).digest();
}
throw new Error(
"SESSION_SECRET must contain at least 32 bytes or encode exactly 32 bytes",
);
}
function encryptValue<T>(value: T): string {
const key = getSecretKey();
const iv = randomBytes(12);
const cipher = createCipheriv(CIPHER_ALGORITHM, key, iv);
const plaintext = Buffer.from(
JSON.stringify({
v: CURRENT_VERSION,
data: value,
} satisfies SerializedPayload<T>),
"utf8",
);
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const authTag = cipher.getAuthTag();
return Buffer.concat([
Buffer.from([CURRENT_VERSION]),
iv,
authTag,
ciphertext,
]).toString("base64url");
}
function decryptValue<T>(value: string): T | null {
try {
const payload = Buffer.from(value, "base64url");
if (payload.length < 29 || payload[0] !== CURRENT_VERSION) {
return null;
}
const iv = payload.subarray(1, 13);
const authTag = payload.subarray(13, 29);
const ciphertext = payload.subarray(29);
const decipher = createDecipheriv(CIPHER_ALGORITHM, getSecretKey(), iv);
decipher.setAuthTag(authTag);
const plaintext = Buffer.concat([
decipher.update(ciphertext),
decipher.final(),
]).toString("utf8");
const parsed = JSON.parse(plaintext) as SerializedPayload<T>;
if (parsed.v !== CURRENT_VERSION) {
return null;
}
return parsed.data;
} catch {
return null;
}
}
function nowInSeconds(): number {
return Math.floor(Date.now() / 1000);
}
function getSessionCookieMaxAge(session: AppSession): number {
const expiresAt =
session.refreshExpiresAt ??
(session.refreshToken
? nowInSeconds() + DEFAULT_SESSION_MAX_AGE_SECONDS
: session.expiresAt);
return Math.max(expiresAt - nowInSeconds(), 0);
}
function sanitizeReturnTo(value: string | null | undefined): string {
if (!value) {
return "/";
}
if (!value.startsWith("/") || value.startsWith("//")) {
return "/";
}
if (value.startsWith("/auth")) {
return "/";
}
return value;
}
function normalizeGroups(value: unknown): string[] | undefined {
if (value === undefined || value === null) {
return undefined;
}
if (typeof value === "string") {
return [value];
}
if (Array.isArray(value)) {
return value.map((entry) => String(entry));
}
return [String(value)];
}
function decodeJwtExpiry(token: string): number | null {
try {
const [, payload] = token.split(".");
if (!payload) {
return null;
}
const decoded = JSON.parse(
Buffer.from(payload, "base64url").toString("utf8"),
) as {
exp?: unknown;
};
return typeof decoded.exp === "number" ? decoded.exp : null;
} catch {
return null;
}
}
function sessionNeedsRefresh(session: AppSession): boolean {
return session.expiresAt - nowInSeconds() <= SESSION_REFRESH_WINDOW_SECONDS;
}
function stateMatches(expected: string, actual: string): boolean {
const expectedBuffer = Buffer.from(expected, "utf8");
const actualBuffer = Buffer.from(actual, "utf8");
if (expectedBuffer.length !== actualBuffer.length) {
return false;
}
return timingSafeEqual(expectedBuffer, actualBuffer);
}
function getRedirectUri(url: URL): string {
return new URL("/auth/callback", url.origin).toString();
}
function getLogoutReturnUri(url: URL): string {
return new URL("/", url.origin).toString();
}
async function parseJsonResponse(response: Response): Promise<unknown> {
const text = await response.text();
if (!text) {
return null;
}
return JSON.parse(text) as unknown;
}
async function requestJson<T>(input: string, init?: RequestInit): Promise<T> {
const response = await fetch(input, init);
if (!response.ok) {
const text = await response.text().catch(() => response.statusText);
throw new Error(text || response.statusText);
}
return (await parseJsonResponse(response)) as T;
}
async function getDiscoveryDocument(): Promise<DiscoveryDocument> {
cachedDiscovery ??= requestJson<DiscoveryDocument>(
getAuthConfig().discoveryUrl,
).catch((error) => {
cachedDiscovery = null;
throw error;
});
return cachedDiscovery;
}
function buildAuthorizationUrl(
url: URL,
state: string,
codeChallenge: string,
): Promise<URL> {
return getDiscoveryDocument().then((discovery) => {
const authUrl = new URL(discovery.authorization_endpoint);
const config = getAuthConfig();
authUrl.searchParams.set("client_id", config.clientId);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("redirect_uri", getRedirectUri(url));
authUrl.searchParams.set("scope", config.scopes);
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("code_challenge", codeChallenge);
authUrl.searchParams.set("code_challenge_method", "S256");
return authUrl;
});
}
function createPkceVerifier(): string {
return randomBytes(32).toString("base64url");
}
function createPkceChallenge(verifier: string): string {
return createHash("sha256").update(verifier).digest("base64url");
}
function createState(): string {
return randomBytes(24).toString("base64url");
}
function buildTokenRequestBody(
values: Record<string, string>,
): URLSearchParams {
const body = new URLSearchParams();
for (const [key, value] of Object.entries(values)) {
body.set(key, value);
}
const config = getAuthConfig();
body.set("client_id", config.clientId);
if (config.clientSecret) {
body.set("client_secret", config.clientSecret);
}
return body;
}
async function exchangeCodeForTokens(
code: string,
codeVerifier: string,
redirectUri: string,
): Promise<TokenResponse> {
const discovery = await getDiscoveryDocument();
return requestJson<TokenResponse>(discovery.token_endpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: buildTokenRequestBody({
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
}),
});
}
async function refreshToken(refreshToken: string): Promise<TokenResponse> {
const discovery = await getDiscoveryDocument();
return requestJson<TokenResponse>(discovery.token_endpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: buildTokenRequestBody({
grant_type: "refresh_token",
refresh_token: refreshToken,
}),
});
}
async function fetchUserInfo(accessToken: string): Promise<UserInfoClaims> {
const discovery = await getDiscoveryDocument();
const payload = await requestJson<Record<string, unknown>>(
discovery.userinfo_endpoint,
{
headers: { Authorization: `Bearer ${accessToken}` },
},
);
const subject = payload.sub;
if (typeof subject !== "string" || !subject) {
throw new Error("OIDC userinfo payload is missing sub");
}
return {
iss: typeof payload.iss === "string" ? payload.iss : undefined,
sub: subject,
email: typeof payload.email === "string" ? payload.email : null,
name: typeof payload.name === "string" ? payload.name : null,
preferred_username:
typeof payload.preferred_username === "string"
? payload.preferred_username
: null,
groups: normalizeGroups(payload.groups),
};
}
async function syncBackendSession(
accessToken: string,
claims: UserInfoClaims,
): Promise<AuthSessionResponse> {
return requestJson<AuthSessionResponse>(`${getApiBase()}/auth/session/sync`, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
iss: claims.iss ?? getAuthConfig().issuer,
sub: claims.sub,
email: claims.email,
name: claims.name,
preferred_username: claims.preferred_username,
groups: claims.groups,
}),
});
}
function buildAppSession(
tokenResponse: TokenResponse,
backendSession: AuthSessionResponse,
previousSession?: AppSession,
): AppSession {
const currentTime = nowInSeconds();
const accessToken = tokenResponse.access_token;
const accessTokenExpiry =
typeof tokenResponse.expires_in === "number"
? currentTime + tokenResponse.expires_in
: decodeJwtExpiry(accessToken);
if (!accessTokenExpiry) {
throw new Error(
"OIDC token response is missing access token expiry information",
);
}
const refreshExpiresIn =
typeof tokenResponse.refresh_token_expires_in === "number"
? tokenResponse.refresh_token_expires_in
: typeof tokenResponse.refresh_expires_in === "number"
? tokenResponse.refresh_expires_in
: null;
return {
accessToken,
refreshToken:
tokenResponse.refresh_token ?? previousSession?.refreshToken ?? null,
tokenType:
tokenResponse.token_type ?? previousSession?.tokenType ?? "Bearer",
scope: tokenResponse.scope ?? previousSession?.scope ?? null,
expiresAt: accessTokenExpiry,
refreshExpiresAt:
refreshExpiresIn !== null
? currentTime + refreshExpiresIn
: (previousSession?.refreshExpiresAt ?? null),
user: backendSession.user,
identity: backendSession.identity,
profile: backendSession.profile ?? null,
};
}
export function loadSession(cookies: Cookies): AppSession | null {
const raw = cookies.get(AUTH_COOKIE_NAME);
if (!raw) {
return null;
}
return decryptValue<AppSession>(raw);
}
export function setSessionCookie(cookies: Cookies, session: AppSession): void {
cookies.set(
AUTH_COOKIE_NAME,
encryptValue(session),
cookieOptions(Math.max(getSessionCookieMaxAge(session), 1)),
);
}
export function clearSessionCookie(cookies: Cookies): void {
cookies.delete(AUTH_COOKIE_NAME, { path: "/" });
}
function loadLoginFlow(cookies: Cookies): LoginFlowState | null {
const raw = cookies.get(LOGIN_COOKIE_NAME);
if (!raw) {
return null;
}
return decryptValue<LoginFlowState>(raw);
}
function setLoginFlowCookie(cookies: Cookies, flow: LoginFlowState): void {
cookies.set(
LOGIN_COOKIE_NAME,
encryptValue(flow),
cookieOptions(AUTH_FLOW_TTL_SECONDS),
);
}
export function clearLoginFlowCookie(cookies: Cookies): void {
cookies.delete(LOGIN_COOKIE_NAME, { path: "/" });
}
export function clearAuthCookies(cookies: Cookies): void {
clearSessionCookie(cookies);
clearLoginFlowCookie(cookies);
}
export async function createLoginRedirect(event: RequestEvent): Promise<URL> {
const returnTo = sanitizeReturnTo(event.url.searchParams.get("returnTo"));
const codeVerifier = createPkceVerifier();
const state = createState();
setLoginFlowCookie(event.cookies, {
state,
codeVerifier,
returnTo,
});
return buildAuthorizationUrl(
event.url,
state,
createPkceChallenge(codeVerifier),
);
}
export async function finishLogin(event: RequestEvent): Promise<string> {
const flow = loadLoginFlow(event.cookies);
clearLoginFlowCookie(event.cookies);
if (!flow) {
throw new Error("Missing or invalid login flow state");
}
const state = event.url.searchParams.get("state");
const code = event.url.searchParams.get("code");
if (!state || !stateMatches(flow.state, state)) {
throw new Error("OIDC callback state check failed");
}
if (!code) {
throw new Error("OIDC callback is missing authorization code");
}
const tokenResponse = await exchangeCodeForTokens(
code,
flow.codeVerifier,
getRedirectUri(event.url),
);
const userInfo = await fetchUserInfo(tokenResponse.access_token);
const backendSession = await syncBackendSession(
tokenResponse.access_token,
userInfo,
);
const session = buildAppSession(tokenResponse, backendSession);
setSessionCookie(event.cookies, session);
return flow.returnTo;
}
export async function refreshSession(session: AppSession): Promise<AppSession> {
if (!session.refreshToken) {
throw new Error("App session cannot be refreshed without a refresh token");
}
if (
session.refreshExpiresAt !== null &&
session.refreshExpiresAt <= nowInSeconds()
) {
throw new Error("Refresh token has expired");
}
const tokenResponse = await refreshToken(session.refreshToken);
const userInfo = await fetchUserInfo(tokenResponse.access_token);
const backendSession = await syncBackendSession(
tokenResponse.access_token,
userInfo,
);
return buildAppSession(tokenResponse, backendSession, session);
}
export async function loadAuthenticatedSession(
event: RequestEvent,
): Promise<AppSession | null> {
const session = loadSession(event.cookies);
if (!session && event.cookies.get(AUTH_COOKIE_NAME)) {
clearAuthCookies(event.cookies);
return null;
}
if (!session) {
return null;
}
if (!sessionNeedsRefresh(session)) {
return session;
}
try {
const refreshed = await refreshSession(session);
setSessionCookie(event.cookies, refreshed);
return refreshed;
} catch {
clearAuthCookies(event.cookies);
return null;
}
}
export function isProtectedPath(pathname: string): boolean {
if (pathname.startsWith("/auth")) {
return false;
}
if (pathname.startsWith("/_app") || pathname.startsWith("/api")) {
return false;
}
if (
pathname === "/favicon.ico" ||
pathname === "/robots.txt" ||
pathname === "/manifest.webmanifest"
) {
return false;
}
return !/\.[a-zA-Z0-9]+$/.test(pathname);
}
export function buildLoginPath(url: URL): string {
const loginUrl = new URL("/auth/login", url.origin);
const returnTo = sanitizeReturnTo(`${url.pathname}${url.search}`);
if (returnTo !== "/") {
loginUrl.searchParams.set("returnTo", returnTo);
}
return `${loginUrl.pathname}${loginUrl.search}`;
}
export async function buildLogoutUrl(url: URL): Promise<string> {
let discovery: DiscoveryDocument;
try {
discovery = await getDiscoveryDocument();
} catch {
return "/auth/login";
}
if (!discovery.end_session_endpoint) {
return "/auth/login";
}
const logoutUrl = new URL(discovery.end_session_endpoint);
logoutUrl.searchParams.set("client_id", getAuthConfig().clientId);
logoutUrl.searchParams.set(
"post_logout_redirect_uri",
getLogoutReturnUri(url),
);
return logoutUrl.toString();
}
export function getRequestSession(event: RequestEvent): AppSession | null {
return event.locals.session ?? null;
}
export function isBackendRequest(url: URL, event: RequestEvent): boolean {
if (url.origin === event.url.origin && url.pathname.startsWith("/api")) {
return true;
}
const apiBase = getApiBase();
try {
const backendUrl = new URL(apiBase);
return (
url.origin === backendUrl.origin &&
url.pathname.startsWith(backendUrl.pathname)
);
} catch {
return false;
}
}

View file

@ -0,0 +1,36 @@
import type { LayoutServerLoad } from './$types';
function getDisplayName(session: App.Locals['session']): string | null {
if (!session) return null;
const candidates = [
session.identity.name,
session.identity.preferred_username,
session.identity.email
];
for (const candidate of candidates) {
const value = candidate?.trim();
if (value) return value;
}
return null;
}
export const load: LayoutServerLoad = async ({ locals }) => {
if (!locals.session) {
return {
session: null
};
}
return {
session: {
expiresAt: locals.session.expiresAt,
user: locals.session.user,
identity: locals.session.identity,
profile: locals.session.profile,
displayName: getDisplayName(locals.session)
}
};
};

View file

@ -4,6 +4,7 @@
import { page } from '$app/state';
import { resolve } from '$app/paths';
import * as m from '$lib/paraglide/messages.js';
import { Button } from '$lib/components/ui/button';
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
import {
House,
@ -17,34 +18,81 @@
Menu,
X
} from 'lucide-svelte';
import type { AuthIdentityPublic, AuthProfilePublic, AuthUserPublic } from '$lib/api/generated/types.gen';
import type { Snippet } from 'svelte';
let { children } = $props();
type ShellSession = {
expiresAt: number;
user: AuthUserPublic;
identity: AuthIdentityPublic;
profile: AuthProfilePublic | null;
displayName: string | null;
};
type NavRoute =
| '/'
| '/routines'
| '/routines/grooming-schedule'
| '/products'
| '/skin'
| '/profile'
| '/health/medications'
| '/health/lab-results';
type NavItem = {
route: NavRoute;
label: string;
icon: typeof House;
};
let {
data,
children
}: {
data: { session: ShellSession | null };
children: Snippet;
} = $props();
let mobileMenuOpen = $state(false);
const domainClass = $derived(getDomainClass(page.url.pathname));
const authMessages = m as typeof m &
Record<'auth_roleAdmin' | 'auth_roleMember' | 'auth_signedInAs' | 'auth_logout', () => string>;
const session = $derived(data.session);
const logoutPath = '/auth/logout';
const roleLabel = $derived(
session?.user.role === 'admin'
? authMessages['auth_roleAdmin']()
: session
? authMessages['auth_roleMember']()
: ''
);
const secondaryIdentity = $derived(
session?.identity.email ?? session?.identity.preferred_username ?? null
);
const displayName = $derived(session?.displayName ?? secondaryIdentity ?? m.common_unknown());
afterNavigate(() => {
mobileMenuOpen = false;
});
const navItems = $derived([
{ href: resolve('/'), label: m.nav_dashboard(), icon: House },
{ href: resolve('/routines'), label: m.nav_routines(), icon: ClipboardList },
{ href: resolve('/routines/grooming-schedule'), label: m.nav_grooming(), icon: Scissors },
{ href: resolve('/products'), label: m.nav_products(), icon: Package },
{ href: resolve('/skin'), label: m.nav_skin(), icon: Sparkles },
{ href: resolve('/profile'), label: m.nav_profile(), icon: UserRound },
{ href: resolve('/health/medications'), label: m.nav_medications(), icon: Pill },
{ href: resolve('/health/lab-results'), label: m["nav_labResults"](), icon: FlaskConical }
]);
{ route: '/', label: m.nav_dashboard(), icon: House },
{ route: '/routines', label: m.nav_routines(), icon: ClipboardList },
{ route: '/routines/grooming-schedule', label: m.nav_grooming(), icon: Scissors },
{ route: '/products', label: m.nav_products(), icon: Package },
{ route: '/skin', label: m.nav_skin(), icon: Sparkles },
{ route: '/profile', label: m.nav_profile(), icon: UserRound },
{ route: '/health/medications', label: m.nav_medications(), icon: Pill },
{ route: '/health/lab-results', label: m["nav_labResults"](), icon: FlaskConical }
] satisfies NavItem[]);
function isActive(href: string) {
if (href === '/') return page.url.pathname === '/';
function isActive(route: string) {
if (route === '/') return page.url.pathname === '/';
const pathname = page.url.pathname;
if (!pathname.startsWith(href)) return false;
// Don't mark parent as active if a more-specific nav item also matches
if (!pathname.startsWith(route)) return false;
const moreSpecific = navItems.some(
(item) => item.href !== href && item.href.startsWith(href) && pathname.startsWith(item.href)
(item) =>
item.route !== route && item.route.startsWith(route) && pathname.startsWith(item.route)
);
return !moreSpecific;
}
@ -65,6 +113,12 @@
<div class="app-mobile-titleblock">
<p class="app-mobile-overline">{m["nav_appSubtitle"]()}</p>
<span class="app-mobile-title">{m["nav_appName"]()}</span>
{#if session}
<div class="mt-2 flex items-center gap-2 text-[0.68rem] uppercase tracking-[0.22em] text-muted-foreground">
<span class="truncate text-foreground">{displayName}</span>
<span class="rounded-full border border-border/80 bg-background/80 px-2 py-0.5 text-[0.62rem] text-foreground">{roleLabel}</span>
</div>
{/if}
</div>
<button
type="button"
@ -104,12 +158,12 @@
</button>
</div>
<ul class="app-nav-list">
{#each navItems as item (item.href)}
{#each navItems as item (item.route)}
<li>
<a
href={item.href}
href={resolve(item.route)}
onclick={() => (mobileMenuOpen = false)}
class={`app-nav-link ${isActive(item.href) ? 'app-nav-link--active' : ''}`}
class={`app-nav-link ${isActive(item.route) ? 'app-nav-link--active' : ''}`}
>
<item.icon class="size-4 shrink-0" />
<span>{item.label}</span>
@ -118,6 +172,23 @@
{/each}
</ul>
<div class="app-sidebar-footer">
{#if session}
<div class="mb-3 rounded-[1.35rem] border border-border/70 bg-background/88 px-4 py-3 shadow-sm">
<p class="text-[0.65rem] font-semibold uppercase tracking-[0.24em] text-muted-foreground">
{authMessages['auth_signedInAs']()}
</p>
<p class="mt-2 text-sm font-semibold text-foreground">{displayName}</p>
{#if secondaryIdentity}
<p class="mt-1 break-all text-xs text-muted-foreground">{secondaryIdentity}</p>
{/if}
<div class="mt-3 flex flex-wrap items-center gap-2">
<span class="rounded-full border border-border/80 bg-muted/40 px-2.5 py-1 text-[0.65rem] font-semibold uppercase tracking-[0.16em] text-foreground">
{roleLabel}
</span>
<Button href={logoutPath} data-sveltekit-reload variant="outline" size="sm">{authMessages['auth_logout']()}</Button>
</div>
</div>
{/if}
<LanguageSwitcher />
</div>
</aside>
@ -131,11 +202,11 @@
</div>
</div>
<ul class="app-nav-list">
{#each navItems as item (item.href)}
{#each navItems as item (item.route)}
<li>
<a
href={item.href}
class={`app-nav-link ${isActive(item.href) ? 'app-nav-link--active' : ''}`}
href={resolve(item.route)}
class={`app-nav-link ${isActive(item.route) ? 'app-nav-link--active' : ''}`}
>
<item.icon class="size-4 shrink-0" />
<span>{item.label}</span>
@ -144,6 +215,23 @@
{/each}
</ul>
<div class="app-sidebar-footer">
{#if session}
<div class="mb-3 rounded-[1.35rem] border border-border/70 bg-background/88 px-4 py-3 shadow-sm">
<p class="text-[0.65rem] font-semibold uppercase tracking-[0.24em] text-muted-foreground">
{authMessages['auth_signedInAs']()}
</p>
<p class="mt-2 text-sm font-semibold text-foreground">{displayName}</p>
{#if secondaryIdentity}
<p class="mt-1 break-all text-xs text-muted-foreground">{secondaryIdentity}</p>
{/if}
<div class="mt-3 flex flex-wrap items-center gap-2">
<span class="rounded-full border border-border/80 bg-muted/40 px-2.5 py-1 text-[0.65rem] font-semibold uppercase tracking-[0.16em] text-foreground">
{roleLabel}
</span>
<Button href={logoutPath} data-sveltekit-reload variant="outline" size="sm">{authMessages['auth_logout']()}</Button>
</div>
</div>
{/if}
<LanguageSwitcher />
</div>
</nav>

View file

@ -1,12 +1,15 @@
import { getLabResults, getRoutines, getSkinSnapshots } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import type { Routine, SkinConditionSnapshot } from '$lib/types';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
export const load: PageServerLoad = async (event) => {
const apiOptions = getSessionApiOptions(event);
const [routines, snapshots, labResults] = await Promise.all([
getRoutines({ from_date: recentDate(14) }),
getSkinSnapshots({ from_date: recentDate(60) }),
getLabResults({ latest_only: true, limit: 8 })
getRoutines({ from_date: recentDate(14) }, apiOptions),
getSkinSnapshots({ from_date: recentDate(60) }, apiOptions),
getLabResults({ latest_only: true, limit: 8 }, apiOptions)
]);
return {
recentRoutines: getFreshestRoutines(routines).slice(0, 10),

View file

@ -0,0 +1,23 @@
import { parseProductText } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const payload = await event.request.json().catch(() => null);
const text =
typeof payload === 'object' && payload !== null && 'text' in payload && typeof payload.text === 'string'
? payload.text.trim()
: '';
if (!text) {
throw error(400, 'Missing product text');
}
try {
const result = await parseProductText(text, getSessionApiOptions(event));
return json(result);
} catch (cause) {
throw error(502, cause instanceof Error ? cause.message : 'Failed to parse product text');
}
};

View file

@ -0,0 +1,23 @@
import { updateRoutineStep } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const PATCH: RequestHandler = async (event) => {
const payload = await event.request.json().catch(() => null);
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw error(400, 'Invalid routine step payload');
}
try {
const result = await updateRoutineStep(
event.params.id,
payload as Record<string, unknown>,
getSessionApiOptions(event)
);
return json(result);
} catch (cause) {
throw error(502, cause instanceof Error ? cause.message : 'Failed to update routine step');
}
};

View file

@ -0,0 +1,19 @@
import { analyzeSkinPhotos } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const formData = await event.request.formData();
if (formData.getAll('photos').length === 0) {
throw error(400, 'Missing skin photos');
}
try {
const result = await analyzeSkinPhotos(formData, getSessionApiOptions(event));
return json(result);
} catch (cause) {
throw error(502, cause instanceof Error ? cause.message : 'Failed to analyze skin photos');
}
};

View file

@ -0,0 +1,30 @@
import { clearLoginFlowCookie, finishLogin } from "$lib/server/auth";
import { error, redirect } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async (event) => {
const providerError = event.url.searchParams.get("error");
if (providerError) {
clearLoginFlowCookie(event.cookies);
const providerErrorDescription =
event.url.searchParams.get("error_description");
throw error(
400,
providerErrorDescription
? `${providerError}: ${providerErrorDescription}`
: `OIDC callback failed: ${providerError}`,
);
}
let returnTo: string;
try {
returnTo = await finishLogin(event);
} catch (cause) {
throw error(
400,
cause instanceof Error ? cause.message : "OIDC callback failed",
);
}
throw redirect(303, returnTo);
};

View file

@ -0,0 +1,8 @@
import { createLoginRedirect } from "$lib/server/auth";
import { redirect } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async (event) => {
const loginUrl = await createLoginRedirect(event);
throw redirect(303, loginUrl.toString());
};

View file

@ -0,0 +1,10 @@
import { buildLogoutUrl, clearAuthCookies } from "$lib/server/auth";
import { redirect } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async (event) => {
clearAuthCookies(event.cookies);
const logoutUrl = await buildLogoutUrl(event.url);
throw redirect(303, logoutUrl);
};

View file

@ -1,4 +1,5 @@
import { deleteLabResult, getLabResults, updateLabResult } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
@ -9,7 +10,9 @@ const STATUS_GROUP_FLAGS = {
type StatusGroup = 'all' | 'abnormal' | 'normal' | 'uninterpreted';
export const load: PageServerLoad = async ({ url }) => {
export const load: PageServerLoad = async (event) => {
const { url } = event;
const q = url.searchParams.get('q') ?? undefined;
const test_code = url.searchParams.get('test_code') ?? undefined;
const flag = url.searchParams.get('flag') ?? undefined;
@ -41,7 +44,7 @@ export const load: PageServerLoad = async ({ url }) => {
latest_only: latestOnly,
limit,
offset
});
}, getSessionApiOptions(event));
const totalPages = Math.max(1, Math.ceil(resultPage.total / limit));
return {
@ -64,8 +67,8 @@ function normalizeStatusGroup(value: string | null): StatusGroup {
}
export const actions: Actions = {
update: async ({ request }) => {
const form = await request.formData();
update: async (event) => {
const form = await event.request.formData();
const id = form.get('id') as string;
const collected_at = form.get('collected_at') as string;
const test_code = form.get('test_code') as string;
@ -136,20 +139,20 @@ export const actions: Actions = {
}
try {
await updateLabResult(id, body);
await updateLabResult(id, body, getSessionApiOptions(event));
return { updated: true };
} catch (e) {
return fail(500, { error: (e as Error).message });
}
},
delete: async ({ request }) => {
const form = await request.formData();
delete: async (event) => {
const form = await event.request.formData();
const id = form.get('id') as string;
if (!id) return fail(400, { error: 'Missing id' });
try {
await deleteLabResult(id);
await deleteLabResult(id, getSessionApiOptions(event));
return { deleted: true };
} catch (e) {
return fail(500, { error: (e as Error).message });

View file

@ -1,4 +1,5 @@
import { createLabResult } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
@ -7,8 +8,8 @@ export const load: PageServerLoad = async () => {
};
export const actions: Actions = {
default: async ({ request }) => {
const form = await request.formData();
default: async (event) => {
const form = await event.request.formData();
const collected_at = form.get('collected_at') as string;
const test_code = form.get('test_code') as string;
const test_name_original = form.get('test_name_original') as string;
@ -32,7 +33,7 @@ export const actions: Actions = {
if (lab) body.lab = lab;
try {
await createLabResult(body);
await createLabResult(body, getSessionApiOptions(event));
} catch (error) {
return fail(500, { error: (error as Error).message });
}

View file

@ -1,8 +1,11 @@
import { getMedications } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => {
export const load: PageServerLoad = async (event) => {
const { url } = event;
const kind = url.searchParams.get('kind') ?? undefined;
const medications = await getMedications({ kind });
const medications = await getMedications({ kind }, getSessionApiOptions(event));
return { medications, kind };
};

View file

@ -1,4 +1,5 @@
import { createMedication } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
@ -7,8 +8,8 @@ export const load: PageServerLoad = async () => {
};
export const actions: Actions = {
default: async ({ request }) => {
const form = await request.formData();
default: async (event) => {
const form = await event.request.formData();
const kind = form.get('kind') as string;
const product_name = form.get('product_name') as string;
const active_substance = form.get('active_substance') as string;
@ -24,7 +25,7 @@ export const actions: Actions = {
product_name,
active_substance: active_substance || undefined,
notes: notes || undefined
});
}, getSessionApiOptions(event));
} catch (error) {
return fail(500, { error: (error as Error).message });
}

View file

@ -1,9 +1,11 @@
import { getProductSummaries } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
export const load: PageServerLoad = async (event) => {
try {
const products = await getProductSummaries();
const products = await getProductSummaries({}, getSessionApiOptions(event));
return { products, loadError: null };
} catch (error) {
return {

View file

@ -6,12 +6,14 @@ import {
updateInventory as apiUpdateInventory,
deleteInventory as apiDeleteInventory
} from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
export const load: PageServerLoad = async (event) => {
const { params } = event;
try {
const product = await getProduct(params.id);
const product = await getProduct(params.id, getSessionApiOptions(event));
return { product };
} catch {
error(404, 'Product not found');
@ -78,8 +80,9 @@ function parseContextRules(
}
export const actions: Actions = {
update: async ({ params, request }) => {
const form = await request.formData();
update: async (event) => {
const { params } = event;
const form = await event.request.formData();
const name = form.get('name') as string;
const brand = form.get('brand') as string;
@ -163,24 +166,25 @@ export const actions: Actions = {
body.context_rules = parseContextRules(form) ?? null;
try {
const product = await updateProduct(params.id, body);
const product = await updateProduct(params.id, body, getSessionApiOptions(event));
return { success: true, product };
} catch (e) {
return fail(500, { error: (e as Error).message });
}
},
delete: async ({ params }) => {
delete: async (event) => {
try {
await deleteProduct(params.id);
await deleteProduct(event.params.id, getSessionApiOptions(event));
} catch (e) {
return fail(500, { error: (e as Error).message });
}
redirect(303, '/products');
},
addInventory: async ({ params, request }) => {
const form = await request.formData();
addInventory: async (event) => {
const { params } = event;
const form = await event.request.formData();
const body: Record<string, unknown> = {
is_opened: form.get('is_opened') === 'true'
};
@ -195,15 +199,15 @@ export const actions: Actions = {
const notes = form.get('notes');
if (notes) body.notes = notes;
try {
await createInventory(params.id, body);
await createInventory(params.id, body, getSessionApiOptions(event));
return { inventoryAdded: true };
} catch (e) {
return fail(500, { error: (e as Error).message });
}
},
updateInventory: async ({ request }) => {
const form = await request.formData();
updateInventory: async (event) => {
const form = await event.request.formData();
const inventoryId = form.get('inventory_id') as string;
if (!inventoryId) return fail(400, { error: 'Missing inventory_id' });
const body: Record<string, unknown> = {
@ -220,19 +224,19 @@ export const actions: Actions = {
const notes = form.get('notes');
body.notes = notes || null;
try {
await apiUpdateInventory(inventoryId, body);
await apiUpdateInventory(inventoryId, body, getSessionApiOptions(event));
return { inventoryUpdated: true };
} catch (e) {
return fail(500, { error: (e as Error).message });
}
},
deleteInventory: async ({ request }) => {
const form = await request.formData();
deleteInventory: async (event) => {
const form = await event.request.formData();
const inventoryId = form.get('inventory_id') as string;
if (!inventoryId) return fail(400, { error: 'Missing inventory_id' });
try {
await apiDeleteInventory(inventoryId);
await apiDeleteInventory(inventoryId, getSessionApiOptions(event));
return { inventoryDeleted: true };
} catch (e) {
return fail(500, { error: (e as Error).message });

View file

@ -1,4 +1,5 @@
import { createProduct } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
@ -66,8 +67,8 @@ function parseContextRules(
}
export const actions: Actions = {
default: async ({ request }) => {
const form = await request.formData();
default: async (event) => {
const form = await event.request.formData();
const name = form.get('name') as string;
const brand = form.get('brand') as string;
@ -162,7 +163,7 @@ export const actions: Actions = {
if (contextRules) payload.context_rules = contextRules;
try {
await createProduct(payload);
await createProduct(payload, getSessionApiOptions(event));
} catch (e) {
return fail(500, { error: (e as Error).message });
}

View file

@ -1,28 +1,30 @@
import { getProfile, updateProfile } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const profile = await getProfile();
return { profile };
export const load: PageServerLoad = async (event) => {
const profile = await getProfile(getSessionApiOptions(event));
return { profile };
};
export const actions: Actions = {
save: async ({ request }) => {
const form = await request.formData();
const birth_date_raw = String(form.get('birth_date') ?? '').trim();
const sex_at_birth_raw = String(form.get('sex_at_birth') ?? '').trim();
save: async (event) => {
const form = await event.request.formData();
const birth_date_raw = String(form.get('birth_date') ?? '').trim();
const sex_at_birth_raw = String(form.get('sex_at_birth') ?? '').trim();
const payload: { birth_date?: string; sex_at_birth?: 'male' | 'female' | 'intersex' } = {};
if (birth_date_raw) payload.birth_date = birth_date_raw;
if (sex_at_birth_raw === 'male' || sex_at_birth_raw === 'female' || sex_at_birth_raw === 'intersex') {
payload.sex_at_birth = sex_at_birth_raw;
}
}
try {
const profile = await updateProfile(payload);
return { saved: true, profile };
} catch (e) {
try {
const profile = await updateProfile(payload, getSessionApiOptions(event));
return { saved: true, profile };
} catch (e) {
return fail(502, { error: (e as Error).message });
}
}

View file

@ -1,9 +1,12 @@
import { getRoutines } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ url }) => {
export const load: PageServerLoad = async (event) => {
const { url } = event;
const from_date = url.searchParams.get('from_date') ?? recentDate(30);
const routines = await getRoutines({ from_date });
const routines = await getRoutines({ from_date }, getSessionApiOptions(event));
return { routines };
};

View file

@ -1,10 +1,16 @@
import { addRoutineStep, deleteRoutine, deleteRoutineStep, getProductSummaries, getRoutine } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
export const load: PageServerLoad = async (event) => {
const { params } = event;
const apiOptions = getSessionApiOptions(event);
try {
const [routine, products] = await Promise.all([getRoutine(params.id), getProductSummaries()]);
const [routine, products] = await Promise.all([
getRoutine(params.id, apiOptions),
getProductSummaries({}, apiOptions)
]);
return { routine, products };
} catch {
error(404, 'Routine not found');
@ -12,8 +18,9 @@ export const load: PageServerLoad = async ({ params }) => {
};
export const actions: Actions = {
addStep: async ({ params, request }) => {
const form = await request.formData();
addStep: async (event) => {
const { params } = event;
const form = await event.request.formData();
const product_id = form.get('product_id') as string;
const order_index = Number(form.get('order_index') ?? 0);
const dose = form.get('dose') as string;
@ -25,27 +32,27 @@ export const actions: Actions = {
if (region) body.region = region;
try {
await addRoutineStep(params.id, body);
await addRoutineStep(params.id, body, getSessionApiOptions(event));
return { stepAdded: true };
} catch (e) {
return fail(500, { error: (e as Error).message });
}
},
removeStep: async ({ request }) => {
const form = await request.formData();
removeStep: async (event) => {
const form = await event.request.formData();
const step_id = form.get('step_id') as string;
try {
await deleteRoutineStep(step_id);
await deleteRoutineStep(step_id, getSessionApiOptions(event));
return { stepRemoved: true };
} catch (e) {
return fail(500, { error: (e as Error).message });
}
},
delete: async ({ params }) => {
delete: async (event) => {
try {
await deleteRoutine(params.id);
await deleteRoutine(event.params.id, getSessionApiOptions(event));
} catch (e) {
return fail(500, { error: (e as Error).message });
}

View file

@ -2,7 +2,6 @@
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
import { updateRoutineStep } from '$lib/api';
import type { GroomingAction, RoutineStep } from '$lib/types';
import type { ActionData, PageData } from './$types';
import * as m from '$lib/paraglide/messages.js';
@ -32,6 +31,21 @@
// ── Drag & drop reordering ────────────────────────────────────
let dndSaving = $state(false);
async function updateStep(stepId: string, payload: Record<string, unknown>): Promise<RoutineStep> {
const response = await fetch(`/api/routines/steps/${stepId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const detail = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(detail?.detail ?? response.statusText);
}
return response.json();
}
function handleConsider(e: CustomEvent<DndEvent<RoutineStep>>) {
steps = e.detail.items;
}
@ -45,9 +59,7 @@
if (changed.length) {
dndSaving = true;
try {
await Promise.all(
changed.map((s) => updateRoutineStep(s.id, { order_index: s.order_index }))
);
await Promise.all(changed.map((s) => updateStep(s.id, { order_index: s.order_index })));
} finally {
dndSaving = false;
}
@ -85,7 +97,7 @@
payload.action_type = editDraft.action_type;
payload.action_notes = editDraft.action_notes || null;
}
const updatedStep = await updateRoutineStep(step.id, payload);
const updatedStep = await updateStep(step.id, payload);
steps = steps.map((s) => (s.id === step.id ? { ...s, ...updatedStep } : s));
editingStepId = null;
} catch (err) {

View file

@ -3,17 +3,19 @@ import {
getGroomingSchedule,
updateGroomingScheduleEntry
} from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const schedule = await getGroomingSchedule();
export const load: PageServerLoad = async (event) => {
const schedule = await getGroomingSchedule(getSessionApiOptions(event));
return { schedule };
};
export const actions: Actions = {
update: async ({ request }) => {
const form = await request.formData();
update: async (event) => {
const form = await event.request.formData();
const id = form.get('id') as string;
if (!id) return fail(400, { error: 'Missing id' });
const body: Record<string, unknown> = {};
@ -24,19 +26,19 @@ export const actions: Actions = {
const notes = (form.get('notes') as string)?.trim();
body.notes = notes || null;
try {
await updateGroomingScheduleEntry(id, body);
await updateGroomingScheduleEntry(id, body, getSessionApiOptions(event));
return { updated: true };
} catch (e) {
return fail(500, { error: (e as Error).message });
}
},
delete: async ({ request }) => {
const form = await request.formData();
delete: async (event) => {
const form = await event.request.formData();
const id = form.get('id') as string;
if (!id) return fail(400, { error: 'Missing id' });
try {
await deleteGroomingScheduleEntry(id);
await deleteGroomingScheduleEntry(id, getSessionApiOptions(event));
return { deleted: true };
} catch (e) {
return fail(500, { error: (e as Error).message });

View file

@ -1,4 +1,5 @@
import { createGroomingScheduleEntry } from '$lib/api';
import { getSessionApiOptions } from '$lib/server/api';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
@ -7,8 +8,8 @@ export const load: PageServerLoad = async () => {
};
export const actions: Actions = {
default: async ({ request }) => {
const form = await request.formData();
default: async (event) => {
const form = await event.request.formData();
const day_of_week = form.get('day_of_week');
const action = form.get('action') as string;
@ -24,7 +25,7 @@ export const actions: Actions = {
if (notes) body.notes = notes;
try {
await createGroomingScheduleEntry(body);
await createGroomingScheduleEntry(body, getSessionApiOptions(event));
} catch (error) {
return fail(500, { error: (error as Error).message });
}

Some files were not shown because too many files have changed in this diff Show more