Skip to content
Draft
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ web/app/public/firebase-messaging-sw.js
!app/pubspec.lock
!app/ios/Podfile.lock
!mcp/uv.lock
desktop/codex-proxy/target/
*.lock
!desktop/codex-proxy/Cargo.lock
*.log
*.swo
*.swp
Expand Down
58 changes: 58 additions & 0 deletions backend/database/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,64 @@ def clear_byok_active(uid: str):
)


def get_chatgpt_state(uid: str) -> dict:
user_ref = db.collection('users').document(uid)
data = user_ref.get().to_dict() or {}
return data.get('chatgpt', {})


def is_chatgpt_active(uid: str) -> bool:
"""True if user enrolled ChatGPT/Codex tier (LLM-only; separate from four-key BYOK)."""
state = get_chatgpt_state(uid)
if not state.get('active'):
return False
last_seen = state.get('last_seen_at')
if not last_seen:
return False
if isinstance(last_seen, datetime):
age = (datetime.now(timezone.utc) - last_seen).total_seconds()
else:
return False
return age <= BYOK_HEARTBEAT_TTL_SECONDS
Comment on lines +232 to +236
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh ChatGPT enrollment before TTL-based deactivation

is_chatgpt_active expires enrollment strictly by a 7-day TTL on last_seen_at, but desktop enrollment currently updates that timestamp only during explicit connect flow (not on normal app startup/use). As a result, active users can silently drop out of ChatGPT-unlimited status after seven days unless they manually reconnect, which causes unexpected quota/paywall regressions.

Useful? React with 👍 / 👎.



def set_chatgpt_active(uid: str, fingerprint: str):
Comment on lines +232 to +239
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The ChatGPT TTL reuses BYOK_HEARTBEAT_TTL_SECONDS, a constant whose name and comment are semantically tied to the BYOK feature. If the two TTLs ever diverge, this shared constant would be the wrong one to change. A dedicated constant makes the intent explicit.

Suggested change
if isinstance(last_seen, datetime):
age = (datetime.now(timezone.utc) - last_seen).total_seconds()
else:
return False
return age <= BYOK_HEARTBEAT_TTL_SECONDS
def set_chatgpt_active(uid: str, fingerprint: str):
if isinstance(last_seen, datetime):
age = (datetime.now(timezone.utc) - last_seen).total_seconds()
else:
return False
return age <= CHATGPT_HEARTBEAT_TTL_SECONDS
def set_chatgpt_active(uid: str, fingerprint: str):

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

user_ref = db.collection('users').document(uid)
user_ref.set(
{
'chatgpt': {
'active': True,
'fingerprint': fingerprint,
'last_seen_at': datetime.now(timezone.utc),
}
},
merge=True,
)


def touch_chatgpt_heartbeat(uid: str):
"""Refresh ChatGPT tier heartbeat (called when a valid fingerprint is on the request)."""
user_ref = db.collection('users').document(uid)
user_ref.set(
{'chatgpt': {'last_seen_at': datetime.now(timezone.utc)}},
merge=True,
)


def clear_chatgpt_active(uid: str):
user_ref = db.collection('users').document(uid)
user_ref.set(
{
'chatgpt': {
'active': False,
'fingerprint': '',
'last_seen_at': datetime.now(timezone.utc),
}
},
merge=True,
)


def set_user_deletion_feedback(uid: str, reason: Optional[str], reason_details: Optional[str] = None):
# Stored in a top-level collection so it survives the user record being deleted.
db.collection('account_deletions').document(uid).set(
Expand Down
2 changes: 2 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,10 @@
app.add_middleware(TimeoutMiddleware, methods_timeout=methods_timeout)

from utils.byok import BYOKMiddleware
from utils.chatgpt import ChatGPTMiddleware

app.add_middleware(BYOKMiddleware)
app.add_middleware(ChatGPTMiddleware)


@app.on_event("startup")
Expand Down
70 changes: 70 additions & 0 deletions backend/routers/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@
from utils.webhooks import webhook_first_time_setup
from database.action_items import get_action_items as get_standalone_action_items
from utils.byok import has_byok_keys, invalidate_byok_state_cache
from utils.chatgpt import chatgpt_request_grants_bypass
import logging

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -813,6 +814,47 @@ def deactivate_byok_endpoint(uid: str = Depends(auth.get_current_user_uid_no_byo
return {"active": False}


class ChatGPTActivateRequest(BaseModel):
fingerprint: str


@router.post('/v1/users/me/chatgpt-active', tags=['v1'])
def activate_chatgpt_endpoint(
data: ChatGPTActivateRequest, uid: str = Depends(auth.get_current_user_uid_no_byok_validation)
):
"""Enroll ChatGPT / Codex subscription tier (LLM workloads only; no provider keys stored)."""
if not _SHA256_HEX_RE.match(data.fingerprint):
raise HTTPException(
status_code=400,
detail='Invalid fingerprint: expected lowercase hex SHA-256 (64 chars)',
)
users_db.set_chatgpt_active(uid, data.fingerprint)
Comment on lines +827 to +831
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 Badge Verify subscription ownership before setting chatgpt.active

This endpoint grants ChatGPT enrollment after only a format check on fingerprint, so any authenticated user can POST an arbitrary 64-hex string and flip chatgpt.active to true. Because downstream gating (is_chatgpt_active) is what unlocks unlimited subscription/quota paths, this creates a direct paid-feature bypass without proving the caller actually has a ChatGPT/Codex subscription.

Useful? React with 👍 / 👎.

clear_trial_paywall_cache(uid)
return {"active": True}
Comment on lines +821 to +833
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security No server-side validation of ChatGPT subscription

The activation endpoint accepts any 64-char hex string as a valid enrollment fingerprint. The fingerprint is stored in Firestore, but is_chatgpt_active (and therefore enforce_chat_quota) never re-verifies it against OpenAI — it only checks that active=True and the Firestore timestamp is fresh. Any authenticated Omi user can POST an arbitrary SHA-256 hex to permanently bypass the 3-day trial paywall and monthly LLM quota without ever having a ChatGPT subscription. Unlike BYOK, there is no per-request proof-of-possession; once enrolled, the bypass is unconditional.



@router.delete('/v1/users/me/chatgpt-active', tags=['v1'])
def deactivate_chatgpt_endpoint(uid: str = Depends(auth.get_current_user_uid_no_byok_validation)):
"""Drop ChatGPT / Codex tier enrollment."""
users_db.clear_chatgpt_active(uid)
clear_trial_paywall_cache(uid)
return {"active": False}


def _chatgpt_unlimited_subscription() -> Subscription:
return Subscription(
plan=PlanType.unlimited,
status=SubscriptionStatus.active,
features=["chatgpt"],
limits=PlanLimits(
transcription_seconds=None,
words_transcribed=None,
insights_gained=None,
memories_created=None,
),
)


def _byok_unlimited_subscription() -> Subscription:
"""BYOK free plan: unlimited limits, marked with the `byok` feature flag."""
return Subscription(
Expand Down Expand Up @@ -844,6 +886,22 @@ def get_user_subscription_endpoint(
# these users aren't surprised by a disabled phone-call feature.
unlimited_phone_quota = PhoneCallQuota(has_access=True, is_paid=True)

if chatgpt_request_grants_bypass(uid):
return UserSubscriptionResponse(
subscription=_chatgpt_unlimited_subscription(),
transcription_seconds_used=0,
transcription_seconds_limit=0,
words_transcribed_used=0,
words_transcribed_limit=0,
insights_gained_used=0,
insights_gained_limit=0,
memories_created_used=0,
memories_created_limit=0,
available_plans=[],
show_subscription_ui=False,
phone_call_quota=unlimited_phone_quota,
)

if users_db.is_byok_active(uid) and has_byok_keys():
return UserSubscriptionResponse(
subscription=_byok_unlimited_subscription(),
Expand Down Expand Up @@ -1053,6 +1111,18 @@ def get_user_chat_usage_quota(
# BYOK free plan: user brings their own keys, so there's no Omi-side cost
# to meter. Only return unlimited when BYOK headers are on the request (desktop).
# Mobile (no headers) should see real quota.
if chatgpt_request_grants_bypass(uid):
return ChatUsageQuota(
plan='Free (ChatGPT)',
plan_type=PlanType.unlimited.value,
unit=ChatQuotaUnit.questions,
used=0.0,
limit=None,
percent=0.0,
allowed=True,
reset_at=None,
)

if users_db.is_byok_active(uid) and has_byok_keys():
return ChatUsageQuota(
plan='Free (BYOK)',
Expand Down
35 changes: 35 additions & 0 deletions backend/tests/unit/test_chatgpt_enrollment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Unit tests for ChatGPT / Codex tier enrollment (standalone, no Firestore imports)."""

import re
from datetime import datetime, timedelta, timezone

_SHA256_HEX_RE = re.compile(r'^[a-f0-9]{64}$')
_SHA256 = 'a' * 64
_CHATGPT_TTL_SECONDS = 7 * 24 * 60 * 60


def _is_chatgpt_active_state(state: dict) -> bool:
if not state.get('active'):
return False
last_seen = state.get('last_seen_at')
if not isinstance(last_seen, datetime):
return False
age = (datetime.now(timezone.utc) - last_seen).total_seconds()
return age <= _CHATGPT_TTL_SECONDS


def test_fingerprint_must_be_sha256_hex():
assert _SHA256_HEX_RE.match(_SHA256)
assert not _SHA256_HEX_RE.match('not-hex')
assert not _SHA256_HEX_RE.match('A' * 64)


def test_chatgpt_active_ttl():
fresh = {'active': True, 'last_seen_at': datetime.now(timezone.utc) - timedelta(days=1)}
assert _is_chatgpt_active_state(fresh) is True

stale = {'active': True, 'last_seen_at': datetime.now(timezone.utc) - timedelta(days=30)}
assert _is_chatgpt_active_state(stale) is False

inactive = {'active': False, 'last_seen_at': datetime.now(timezone.utc)}
assert _is_chatgpt_active_state(inactive) is False
74 changes: 74 additions & 0 deletions backend/utils/chatgpt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Per-request ChatGPT / Codex tier fingerprint plumbing.

Desktop sends ``X-ChatGPT-Fingerprint`` (SHA-256 of Codex account_id) on requests
while Codex is active. Quota and subscription bypass require a matching enrolled
fingerprint on the same request — enrollment alone is not enough (mirrors BYOK).
"""

import logging
import re
from contextvars import ContextVar
from datetime import datetime, timezone
from typing import Optional

from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request

import database.users as users_db

logger = logging.getLogger('chatgpt')

CHATGPT_FINGERPRINT_HEADER = 'x-chatgpt-fingerprint'
_SHA256_HEX_RE = re.compile(r'^[a-f0-9]{64}$')
# Refresh Firestore heartbeat at most once per day when desktop sends a valid fingerprint.
_HEARTBEAT_REFRESH_INTERVAL_SECONDS = 24 * 60 * 60

_chatgpt_fp_ctx: ContextVar[Optional[str]] = ContextVar('chatgpt_fingerprint', default=None)


def get_chatgpt_fingerprint() -> Optional[str]:
return _chatgpt_fp_ctx.get()


def has_chatgpt_fingerprint() -> bool:
"""True if the current request carries a ChatGPT enrollment fingerprint header."""
return bool(_chatgpt_fp_ctx.get())


def chatgpt_request_grants_bypass(uid: str) -> bool:
"""True when enrolled and this request's fingerprint matches Firestore enrollment.

Refreshes ``last_seen_at`` on success so desktop enrollment stays alive without
re-posting to ``/chatgpt-active`` every week.
"""
fp = _chatgpt_fp_ctx.get()
if not fp or not _SHA256_HEX_RE.match(fp):
return False
if not users_db.is_chatgpt_active(uid):
return False
state = users_db.get_chatgpt_state(uid)
if state.get('fingerprint') != fp:
return False
last_seen = state.get('last_seen_at')
if not isinstance(last_seen, datetime):
users_db.touch_chatgpt_heartbeat(uid)
else:
age = (datetime.now(timezone.utc) - last_seen).total_seconds()
if age >= _HEARTBEAT_REFRESH_INTERVAL_SECONDS:
users_db.touch_chatgpt_heartbeat(uid)
return True


class ChatGPTMiddleware(BaseHTTPMiddleware):
"""Extract ChatGPT fingerprint header into a per-request contextvar."""

async def dispatch(self, request: Request, call_next):
raw = request.headers.get(CHATGPT_FINGERPRINT_HEADER)
fp = raw.strip() if raw else None
if fp and not _SHA256_HEX_RE.match(fp):
fp = None
token = _chatgpt_fp_ctx.set(fp)
try:
return await call_next(request)
finally:
_chatgpt_fp_ctx.reset(token)
14 changes: 13 additions & 1 deletion backend/utils/subscription.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from database.announcements import compare_versions
from models.users import PlanType, SubscriptionStatus, Subscription, PlanLimits, TrialMetadata
from utils.byok import get_byok_key, get_byok_keys
from utils.chatgpt import chatgpt_request_grants_bypass
from utils.log_sanitizer import sanitize
import logging

Expand Down Expand Up @@ -73,6 +74,8 @@ def _is_trial_expired_uncached(uid: str) -> bool:
return False
if users_db.is_byok_active(uid):
return False
if users_db.is_chatgpt_active(uid):
return False
user_record = firebase_auth.get_user(uid)
creation_ms = user_record.user_metadata.creation_timestamp
if not creation_ms:
Expand Down Expand Up @@ -140,7 +143,12 @@ def get_trial_metadata(uid: str) -> TrialMetadata:
# Same request-level escape hatch as `_is_trial_expired_cached`: a request
# carrying all 4 BYOK provider headers is treated as BYOK-active even if
# Firestore hasn't caught up yet.
if plan != PlanType.basic or users_db.is_byok_active(uid) or _request_has_all_byok_keys():
if (
plan != PlanType.basic
or users_db.is_byok_active(uid)
or users_db.is_chatgpt_active(uid)
or _request_has_all_byok_keys()
):
return TrialMetadata(
trial_expired=False,
trial_duration_seconds=TRIAL_LENGTH_SECONDS,
Expand Down Expand Up @@ -517,6 +525,10 @@ def enforce_chat_quota(uid: str, platform: Optional[str] = None) -> None:
)

# BYOK users pay their own LLM provider — no Omi-side cost to cap.
# ChatGPT/Codex tier: bypass only when this request proves Codex enrollment (header).
if chatgpt_request_grants_bypass(uid):
return

# Require an LLM provider key on this request (not just any BYOK header)
# so a user can't activate with fake fingerprints or send only x-byok-deepgram
# to bypass chat quota while chat falls back to Omi's OpenAI/Anthropic keys.
Expand Down
19 changes: 19 additions & 0 deletions desktop/Desktop/Sources/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ actor APIClient {
headers[provider.headerName] = entry.key
}

if let chatgptFingerprint = CodexAuthService.enrollmentFingerprintIfActive() {
headers["X-ChatGPT-Fingerprint"] = chatgptFingerprint
}

return headers
}

Expand Down Expand Up @@ -4546,6 +4550,21 @@ extension APIClient {
try await delete("v1/users/me/byok-active")
}

/// Activate ChatGPT / Codex subscription tier (LLM only; fingerprint of account_id).
func activateChatGPT(fingerprint: String) async throws {
struct Request: Encodable {
let fingerprint: String
}
struct Empty: Decodable {}
let _: Empty = try await post(
"v1/users/me/chatgpt-active", body: Request(fingerprint: fingerprint)
)
}

func deactivateChatGPT() async throws {
try await delete("v1/users/me/chatgpt-active")
}

/// Fetches all people for the current user
func getPeople() async throws -> [Person] {
return try await get("v1/users/people")
Expand Down
Loading