From 53d986592f26579aaac431e5da76c654ab113475 Mon Sep 17 00:00:00 2001 From: MytelligentPRV Date: Sat, 20 Jun 2026 13:07:30 -0700 Subject: [PATCH] fix(auth): session-OR-key for the dashboard endpoints still on key-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard authenticates by wf_session cookie and has no raw API key to send. GET /billing/balance still required X-Wayforth-API-Key (never got PR #25's resolve_dashboard_caller treatment), 401'd the UNIFIED BALANCE widget, and the UI fell back to a hardcoded 100 — while the account was actually Growth/240k. Audited every route still on key-only auth and converted the consumer-dashboard set to session-OR-key via resolve_dashboard_caller (same pattern as #25). The conversion is additive — X-Wayforth-API-Key still works for programmatic clients. Converted: GET /billing/balance (the reported bug) GET /dashboard GET /keys/usage POST /auth/regenerate-key GET /billing/invoice/{year}/{month} GET /billing/settings PATCH /billing/settings POST /billing/cancel POST /webhooks/wri-alerts GET /webhooks/wri-alerts DELETE /webhooks/wri-alerts/{alert_id} DELETE /webhooks/{webhook_id} Each resolves the caller's primary active api_key by id (caller["api_key_id"]) for the billing/usage display fields, so behaviour is identical to the key path. Deliberately LEFT key-only (programmatic SDK/agent surface — browsers never call these): POST /execute, POST /run, POST /pay. /auth/me already accepts the session cookie (separate path). Flagged-but-not-converted (ambiguous provider/dev use): /health-report, POST /call/keys/add, POST /submit, /identity/{agent_id}/history, and the remaining webhook plumbing (register, list, deliveries, retry). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/routers/auth.py | 38 ++++++--------- apps/api/routers/billing/account.py | 73 ++++++++++++++++------------- apps/api/routers/billing/stripe.py | 15 +++--- apps/api/routers/billing/usdc.py | 26 +++++----- apps/api/routers/webhooks.py | 38 ++++++++------- 5 files changed, 97 insertions(+), 93 deletions(-) diff --git a/apps/api/routers/auth.py b/apps/api/routers/auth.py index a5163eb..5626424 100644 --- a/apps/api/routers/auth.py +++ b/apps/api/routers/auth.py @@ -242,22 +242,17 @@ async def create_api_key(request: Request, body: ApiKeyRequest, db=Depends(get_d @router.get("/keys/usage") @limiter.limit("10/minute") async def key_usage(request: Request, db=Depends(get_db)): - raw_key = request.headers.get("X-Wayforth-API-Key", "") - if not raw_key: - raise HTTPException(status_code=401, detail="X-Wayforth-API-Key header required") - - key_hash = hashlib.sha256(raw_key.encode()).hexdigest() + # Session-OR-key (PR #25 pattern): the dashboard usage widget authenticates by + # wf_session cookie. Resolve the caller, then read their primary active key row. + caller = await resolve_dashboard_caller(request, db) + if not caller.get("api_key_id"): + raise HTTPException(status_code=404, detail="no_active_api_key") key = await db.fetchrow(""" SELECT key_prefix, tier, rate_limit_per_minute, monthly_quota, usage_this_month, quota_reset_at, created_at, last_used_at - FROM api_keys WHERE key_hash = $1 AND active = TRUE - """, key_hash) - + FROM api_keys WHERE id = $1 AND active = TRUE + """, caller["api_key_id"]) if not key: - logger.info( - "auth_failure reason=invalid_api_key path=/auth/key-usage key_hash_prefix=%s", - key_hash[:12], - ) raise HTTPException(status_code=401, detail="Invalid API key") quota_pct = ( @@ -424,22 +419,17 @@ async def register_user(request: Request, db=Depends(get_db)): @router.post("/auth/regenerate-key") @limiter.limit("3/minute") async def regenerate_api_key(request: Request, db=Depends(get_db)): - raw_key = request.headers.get("X-Wayforth-API-Key", "") - if not raw_key: - logger.info("auth_failure reason=missing_api_key path=/auth/regenerate-key") - raise HTTPException(status_code=401, detail={"error": "invalid_api_key"}) - - old_hash = hashlib.sha256(raw_key.encode()).hexdigest() + # Session-OR-key (PR #25 pattern): the dashboard "regenerate key" button + # authenticates by wf_session cookie and rotates the user's primary active key. + caller = await resolve_dashboard_caller(request, db) + if not caller.get("api_key_id"): + raise HTTPException(status_code=404, detail={"error": "no_active_api_key"}) row = await db.fetchrow(""" SELECT id, tier, user_id, owner_email, rate_limit_per_minute, monthly_quota - FROM api_keys WHERE key_hash = $1 AND active = TRUE - """, old_hash) + FROM api_keys WHERE id = $1 AND active = TRUE + """, caller["api_key_id"]) if not row: - logger.info( - "auth_failure reason=invalid_api_key path=/auth/regenerate-key key_hash_prefix=%s", - old_hash[:12], - ) raise HTTPException(status_code=401, detail={"error": "invalid_api_key"}) new_raw = "wf_live_" + secrets.token_urlsafe(32) diff --git a/apps/api/routers/billing/account.py b/apps/api/routers/billing/account.py index 178effb..29a56c3 100644 --- a/apps/api/routers/billing/account.py +++ b/apps/api/routers/billing/account.py @@ -177,19 +177,18 @@ async def put_billing_permissions(request: Request, db=Depends(get_db)): @router.get("/dashboard") @limiter.limit("30/minute") async def dashboard(request: Request, db=Depends(get_db)): - raw_key = request.headers.get("X-Wayforth-API-Key", "") - if not raw_key: - raise HTTPException(status_code=401, detail="API key required") - - key_hash = hashlib.sha256(raw_key.encode()).hexdigest() - + # Session-OR-key (PR #25 pattern): the dashboard authenticates by wf_session + # cookie. Resolve the caller, then read their primary active key + user row. + caller = await resolve_dashboard_caller(request, db) + if not caller.get("api_key_id"): + raise HTTPException(status_code=404, detail="no_active_api_key") key = await db.fetchrow(""" SELECT k.*, u.email, u.created_at as account_created, u.stripe_customer_id FROM api_keys k LEFT JOIN users u ON u.id = k.user_id - WHERE k.key_hash = $1 - """, key_hash) + WHERE k.id = $1 + """, caller["api_key_id"]) if not key: raise HTTPException(status_code=401, detail="Invalid API key") @@ -255,24 +254,33 @@ async def dashboard(request: Request, db=Depends(get_db)): @router.get("/billing/balance") @limiter.limit("30/minute") async def get_balance(request: Request, db=Depends(get_db)): - api_key = request.headers.get("X-Wayforth-API-Key", "") - if not api_key: - raise HTTPException(status_code=401, detail="API key required") - - key_record = await db.fetchrow(""" - SELECT k.id, k.user_id, k.tier, k.payment_rail, - k.quota_reset_at, k.subscription_expires_at, - k.monthly_calls_count, k.monthly_calls_reset_at - FROM api_keys k - WHERE k.key_hash = $1 AND k.active = true - """, hashlib.sha256(api_key.encode()).hexdigest()) + # PR #25 pattern: accept session-OR-key auth (resolve_dashboard_caller), not + # key-only. The dashboard's balance widget authenticates by wf_session cookie + # and has no raw API key to send; the old "API key required" check 401'd it, + # and the UI fell back to a hardcoded balance (showed 100 while the account + # was actually Growth/240k). Auth source for tier/credits is user_credits. + caller = await resolve_dashboard_caller(request, db) + user_id = caller["user_id"] - if not key_record: - raise HTTPException(status_code=401, detail="Invalid API key") + # Billing-display fields (payment rail, reset dates, monthly usage) live on + # the user's active api_key row. resolve_dashboard_caller already resolved the + # primary key id (None only for the rare keyless account). + key_record = None + if caller.get("api_key_id"): + key_record = await db.fetchrow(""" + SELECT k.payment_rail, k.quota_reset_at, k.subscription_expires_at, + k.monthly_calls_count, k.monthly_calls_reset_at + FROM api_keys k + WHERE k.id = $1 AND k.active = true + """, caller["api_key_id"]) + + def _kr(field, default=None): + v = key_record[field] if key_record is not None else None + return v if v is not None else default credits = await db.fetchrow( "SELECT credits_balance, pioneer_credits_balance, package_tier, payment_method FROM user_credits WHERE user_id = $1", - key_record["user_id"], + user_id, ) balance = credits["credits_balance"] if credits else 0 pioneer_balance = credits["pioneer_credits_balance"] if credits else 0 @@ -281,9 +289,9 @@ async def get_balance(request: Request, db=Depends(get_db)): tier = _credits_to_tier(balance, pkg_tier) plan_def = PLANS.get(tier, PLANS["free"]) - resets_at = key_record.get("subscription_expires_at") or key_record.get("quota_reset_at") - monthly_reset_at = key_record.get("monthly_calls_reset_at") - payment_rail = key_record.get("payment_rail") or "card" + resets_at = _kr("subscription_expires_at") or _kr("quota_reset_at") + monthly_reset_at = _kr("monthly_calls_reset_at") + payment_rail = _kr("payment_rail", "card") base_credits = plan_def["monthly_credits"] multiplier = PAYMENT_MULTIPLIERS.get(payment_method, 1.00) @@ -295,7 +303,7 @@ async def get_balance(request: Request, db=Depends(get_db)): # Forecast — daily average in credits (not calls). Returns null if < 3 days history. forecast = None - monthly_credits_consumed = key_record.get("monthly_calls_count") or 0 + monthly_credits_consumed = _kr("monthly_calls_count", 0) or 0 if monthly_reset_at: now_utc = datetime.now(timezone.utc) period_start = monthly_reset_at.replace(tzinfo=timezone.utc) - timedelta(days=30) @@ -1005,17 +1013,18 @@ async def get_invoice_alias(year_month: str, request: Request, db=Depends(get_db @router.get("/billing/invoice/{year}/{month}") @limiter.limit("10/minute") async def get_invoice(year: int, month: int, request: Request, db=Depends(get_db)): - api_key = request.headers.get("X-Wayforth-API-Key", "") - if not api_key: - raise HTTPException(status_code=401, detail="API key required") if not (1 <= month <= 12): raise HTTPException(status_code=400, detail="invalid_month") - + # Session-OR-key (PR #25 pattern): the dashboard billing/invoice view uses + # the wf_session cookie. Resolve the caller, then read their primary key row. + caller = await resolve_dashboard_caller(request, db) + if not caller.get("api_key_id"): + raise HTTPException(status_code=404, detail="no_active_api_key") key_record = await db.fetchrow(""" SELECT k.id, k.user_id, k.tier, u.email FROM api_keys k JOIN users u ON u.id = k.user_id - WHERE k.key_hash = $1 AND k.active = true - """, hashlib.sha256(api_key.encode()).hexdigest()) + WHERE k.id = $1 AND k.active = true + """, caller["api_key_id"]) if not key_record: raise HTTPException(status_code=401, detail="Invalid API key") diff --git a/apps/api/routers/billing/stripe.py b/apps/api/routers/billing/stripe.py index ab90655..715c164 100644 --- a/apps/api/routers/billing/stripe.py +++ b/apps/api/routers/billing/stripe.py @@ -12,7 +12,7 @@ from fastapi.responses import JSONResponse from pydantic import BaseModel -from core.auth import _resolve_user +from core.auth import _resolve_user, resolve_dashboard_caller from core.tier_gates import require_tier from core.credits import _dispatch_webhooks, _maybe_grant_founding_bonus from core.db import get_db @@ -403,15 +403,16 @@ async def create_checkout(request: Request, db=Depends(get_db)): @limiter.limit("5/minute") async def billing_cancel(request: Request, db=Depends(get_db)): """Cancel the caller's Stripe subscription at period end.""" - api_key = request.headers.get("X-Wayforth-API-Key", "") - if not api_key: - raise HTTPException(status_code=401, detail="API key required") - + # Session-OR-key (PR #25 pattern): the dashboard "cancel subscription" button + # authenticates by wf_session cookie. Resolve the caller, then read their key row. + caller = await resolve_dashboard_caller(request, db) + if not caller.get("api_key_id"): + raise HTTPException(status_code=404, detail="no_active_api_key") key_record = await db.fetchrow(""" SELECT k.user_id, k.stripe_subscription_id, u.email FROM api_keys k JOIN users u ON u.id = k.user_id - WHERE k.key_hash = $1 AND k.active = true - """, hashlib.sha256(api_key.encode()).hexdigest()) + WHERE k.id = $1 AND k.active = true + """, caller["api_key_id"]) if not key_record: raise HTTPException(status_code=401, detail="Invalid API key") diff --git a/apps/api/routers/billing/usdc.py b/apps/api/routers/billing/usdc.py index adc0144..b21e54b 100644 --- a/apps/api/routers/billing/usdc.py +++ b/apps/api/routers/billing/usdc.py @@ -12,6 +12,7 @@ import httpx from fastapi import APIRouter, Depends, HTTPException, Request +from core.auth import resolve_dashboard_caller from core.credits import PLANS, CREDITS_PER_CALL, _dispatch_webhooks, _maybe_grant_founding_bonus from core.db import get_db from core.rate_limit import limiter @@ -633,18 +634,19 @@ async def topup_usdc(request: Request, db=Depends(get_db)): async def get_billing_settings(request: Request, db=Depends(get_db)): """Return current billing permission settings for the authenticated API key.""" from core.credits import compute_calls_remaining - api_key = request.headers.get("X-Wayforth-API-Key", "") - if not api_key: - raise HTTPException(status_code=401, detail="API key required") - + # Session-OR-key (PR #25 pattern): the dashboard billing-settings page uses the + # wf_session cookie. Resolve the caller, then read their primary key row. + caller = await resolve_dashboard_caller(request, db) + if not caller.get("api_key_id"): + raise HTTPException(status_code=404, detail="no_active_api_key") key_record = await db.fetchrow(""" SELECT id, user_id, tier, payment_rail, billing_permission, topup_trigger_calls, topup_amount_usd, monthly_topup_limit_usd, monthly_topup_spent_usd, monthly_topup_reset_at FROM api_keys - WHERE key_hash = $1 AND active = true - """, hashlib.sha256(api_key.encode()).hexdigest()) + WHERE id = $1 AND active = true + """, caller["api_key_id"]) if not key_record: raise HTTPException(status_code=401, detail="Invalid API key") @@ -685,14 +687,14 @@ async def get_billing_settings(request: Request, db=Depends(get_db)): @limiter.limit("10/minute") async def update_billing_settings(request: Request, db=Depends(get_db)): """Update billing permission settings for the authenticated API key.""" - api_key = request.headers.get("X-Wayforth-API-Key", "") - if not api_key: - raise HTTPException(status_code=401, detail="API key required") - + # Session-OR-key (PR #25 pattern): dashboard billing-settings save uses the cookie. + caller = await resolve_dashboard_caller(request, db) + if not caller.get("api_key_id"): + raise HTTPException(status_code=404, detail="no_active_api_key") key_record = await db.fetchrow( "SELECT id, topup_amount_usd, monthly_topup_limit_usd FROM api_keys " - "WHERE key_hash = $1 AND active = true", - hashlib.sha256(api_key.encode()).hexdigest(), + "WHERE id = $1 AND active = true", + caller["api_key_id"], ) if not key_record: raise HTTPException(status_code=401, detail="Invalid API key") diff --git a/apps/api/routers/webhooks.py b/apps/api/routers/webhooks.py index e29c287..19a120c 100644 --- a/apps/api/routers/webhooks.py +++ b/apps/api/routers/webhooks.py @@ -13,7 +13,7 @@ from fastapi.requests import Request from pydantic import BaseModel -from core.auth import _resolve_user +from core.auth import _resolve_user, resolve_dashboard_caller from core.db import get_db from core.rate_limit import limiter from core.tier_gates import require_tier @@ -422,10 +422,11 @@ async def list_webhooks(request: Request, db=Depends(get_db)): @limiter.limit("20/minute") async def create_wri_alert(request: Request, db=Depends(get_db)): """Register a webhook that fires when any service crosses a WRI score threshold.""" - api_key = request.headers.get("X-Wayforth-API-Key", "") - if not api_key: - raise HTTPException(status_code=401, detail={"error": "api_key_required"}) - user_id, api_key_id, _tier = await _resolve_user(db, api_key) + # Session-OR-key (PR #25 pattern): the dashboard alerts UI uses the wf_session + # cookie; API clients still send X-Wayforth-API-Key. resolve_dashboard_caller + # accepts both and yields the same (user_id, api_key_id, tier). + caller = await resolve_dashboard_caller(request, db) + user_id, api_key_id, _tier = caller["user_id"], caller["api_key_id"], caller["tier"] require_tier(_tier, "wri_alerts") body = await request.json() @@ -477,10 +478,11 @@ async def create_wri_alert(request: Request, db=Depends(get_db)): @limiter.limit("30/minute") async def list_wri_alerts(request: Request, db=Depends(get_db)): """List all WRI alert webhooks registered to this API key.""" - api_key = request.headers.get("X-Wayforth-API-Key", "") - if not api_key: - raise HTTPException(status_code=401, detail={"error": "api_key_required"}) - user_id, api_key_id, _tier = await _resolve_user(db, api_key) + # Session-OR-key (PR #25 pattern): the dashboard alerts UI uses the wf_session + # cookie; API clients still send X-Wayforth-API-Key. resolve_dashboard_caller + # accepts both and yields the same (user_id, api_key_id, tier). + caller = await resolve_dashboard_caller(request, db) + user_id, api_key_id, _tier = caller["user_id"], caller["api_key_id"], caller["tier"] require_tier(_tier, "wri_alerts") rows = await db.fetch(""" @@ -514,10 +516,11 @@ async def list_wri_alerts(request: Request, db=Depends(get_db)): @limiter.limit("20/minute") async def delete_wri_alert(request: Request, alert_id: str, db=Depends(get_db)): """Deactivate a WRI alert webhook by ID.""" - api_key = request.headers.get("X-Wayforth-API-Key", "") - if not api_key: - raise HTTPException(status_code=401, detail={"error": "api_key_required"}) - user_id, api_key_id, _tier = await _resolve_user(db, api_key) + # Session-OR-key (PR #25 pattern): the dashboard alerts UI uses the wf_session + # cookie; API clients still send X-Wayforth-API-Key. resolve_dashboard_caller + # accepts both and yields the same (user_id, api_key_id, tier). + caller = await resolve_dashboard_caller(request, db) + user_id, api_key_id, _tier = caller["user_id"], caller["api_key_id"], caller["tier"] require_tier(_tier, "wri_alerts") row = await db.fetchrow( @@ -642,11 +645,10 @@ async def retry_webhook(request: Request, webhook_id: str, db=Depends(get_db)): @router.delete("/webhooks/{webhook_id}") @limiter.limit("10/minute") async def delete_webhook(request: Request, webhook_id: str, db=Depends(get_db)): - """Deactivate a registered webhook. Requires the API key of the registrant.""" - api_key = request.headers.get("X-Wayforth-API-Key", "") - if not api_key: - raise HTTPException(status_code=401, detail={"error": "api_key_required"}) - user_id, _api_key_id, _tier = await _resolve_user(db, api_key) + """Deactivate a registered webhook. Auth: wf_session cookie OR the API key of the registrant.""" + # Session-OR-key (PR #25 pattern): dashboard webhook management uses the cookie. + caller = await resolve_dashboard_caller(request, db) + user_id, _api_key_id, _tier = caller["user_id"], caller["api_key_id"], caller["tier"] owner = await db.fetchrow( "SELECT owner_email FROM api_keys WHERE user_id = $1 AND active = true LIMIT 1", user_id