Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 14 additions & 24 deletions apps/api/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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)
Expand Down
73 changes: 41 additions & 32 deletions apps/api/routers/billing/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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")

Expand Down
15 changes: 8 additions & 7 deletions apps/api/routers/billing/stripe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
26 changes: 14 additions & 12 deletions apps/api/routers/billing/usdc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
38 changes: 20 additions & 18 deletions apps/api/routers/webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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("""
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
Loading