cite-agent 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of cite-agent might be problematic. Click here for more details.

cite_agent/auth.py ADDED
@@ -0,0 +1,281 @@
1
+ """
2
+ Authentication System for Nocturnal Archive
3
+ Handles user login, registration, and session management
4
+ """
5
+
6
+ import hashlib
7
+ import hmac
8
+ import json
9
+ import os
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Optional, Dict
13
+ import requests
14
+ from datetime import datetime, timedelta
15
+
16
+ class AuthenticationError(Exception):
17
+ """Authentication failed"""
18
+ pass
19
+
20
+ class AuthManager:
21
+ """Manages user authentication and sessions"""
22
+
23
+ def __init__(self, config_dir: Optional[Path] = None):
24
+ self.config_dir = config_dir or Path.home() / ".nocturnal_archive"
25
+ self.config_dir.mkdir(exist_ok=True)
26
+
27
+ self.session_file = self.config_dir / "session.json"
28
+ self.api_base = os.getenv("NOCTURNAL_AUTH_API", "https://api.nocturnal.dev")
29
+
30
+ def login(self, email: str, password: str) -> Dict:
31
+ """
32
+ Authenticate user with email and password
33
+ Returns user session data
34
+ """
35
+ # Hash password
36
+ password_hash = hashlib.sha256(password.encode()).hexdigest()
37
+
38
+ try:
39
+ # Call auth API
40
+ response = requests.post(
41
+ f"{self.api_base}/auth/login",
42
+ json={
43
+ "email": email,
44
+ "password_hash": password_hash
45
+ },
46
+ timeout=10
47
+ )
48
+
49
+ if response.status_code == 200:
50
+ session_data = response.json()
51
+ self._save_session(session_data)
52
+ return session_data
53
+ elif response.status_code == 401:
54
+ raise AuthenticationError("Invalid email or password")
55
+ else:
56
+ raise AuthenticationError(f"Login failed: {response.status_code}")
57
+
58
+ except requests.RequestException as e:
59
+ # Fallback: offline mode with local validation
60
+ return self._offline_login(email, password_hash)
61
+
62
+ def register(self, email: str, password: str) -> Dict:
63
+ """
64
+ Register new user with email and password only (no license key for beta)
65
+ Returns user session data
66
+ """
67
+ try:
68
+ response = requests.post(
69
+ f"{self.api_base}/auth/register",
70
+ json={
71
+ "email": email,
72
+ "password": password # Send plain password over HTTPS (backend will hash)
73
+ },
74
+ timeout=10
75
+ )
76
+
77
+ if response.status_code == 201:
78
+ session_data = response.json()
79
+ self._save_session(session_data)
80
+ return session_data
81
+ elif response.status_code == 409:
82
+ raise AuthenticationError("Email already registered")
83
+ else:
84
+ raise AuthenticationError(f"Registration failed: {response.status_code}")
85
+
86
+ except requests.RequestException as e:
87
+ # Fallback: create local session (beta mode)
88
+ return self._offline_register(email, password)
89
+
90
+ def get_session(self) -> Optional[Dict]:
91
+ """Get current session if valid"""
92
+ if not self.session_file.exists():
93
+ return None
94
+
95
+ try:
96
+ with open(self.session_file, 'r') as f:
97
+ session = json.load(f)
98
+
99
+ # Check if session expired
100
+ expires_at = datetime.fromisoformat(session.get('expires_at', '2000-01-01'))
101
+ if datetime.now() > expires_at:
102
+ self.logout()
103
+ return None
104
+
105
+ return session
106
+
107
+ except (json.JSONDecodeError, KeyError):
108
+ return None
109
+
110
+ def logout(self):
111
+ """Clear current session"""
112
+ if self.session_file.exists():
113
+ self.session_file.unlink()
114
+
115
+ def refresh_session(self) -> bool:
116
+ """Refresh session token"""
117
+ session = self.get_session()
118
+ if not session:
119
+ return False
120
+
121
+ try:
122
+ response = requests.post(
123
+ f"{self.api_base}/auth/refresh",
124
+ headers={"Authorization": f"Bearer {session['token']}"},
125
+ timeout=10
126
+ )
127
+
128
+ if response.status_code == 200:
129
+ new_session = response.json()
130
+ self._save_session(new_session)
131
+ return True
132
+
133
+ except requests.RequestException:
134
+ pass
135
+
136
+ return False
137
+
138
+ def _save_session(self, session_data: Dict):
139
+ """Save session to file"""
140
+ # Add expiration (30 days from now)
141
+ if 'expires_at' not in session_data:
142
+ expires_at = datetime.now() + timedelta(days=30)
143
+ session_data['expires_at'] = expires_at.isoformat()
144
+
145
+ with open(self.session_file, 'w') as f:
146
+ json.dump(session_data, f, indent=2)
147
+
148
+ # Secure permissions (owner only)
149
+ os.chmod(self.session_file, 0o600)
150
+
151
+ def _offline_login(self, email: str, password_hash: str) -> Dict:
152
+ """Offline login fallback (beta mode)"""
153
+ # Check if user exists in local cache
154
+ users_file = self.config_dir / "users.json"
155
+
156
+ if not users_file.exists():
157
+ raise AuthenticationError("No internet connection and no local user found")
158
+
159
+ with open(users_file, 'r') as f:
160
+ users = json.load(f)
161
+
162
+ user = users.get(email)
163
+ if not user or user['password_hash'] != password_hash:
164
+ raise AuthenticationError("Invalid credentials")
165
+
166
+ # Create offline session
167
+ session = {
168
+ "email": email,
169
+ "user_id": user['user_id'],
170
+ "access_token": "offline-" + hashlib.sha256(email.encode()).hexdigest()[:16],
171
+ "daily_token_limit": user.get('daily_token_limit', 25000),
172
+ "expires_at": (datetime.now() + timedelta(days=30)).isoformat(),
173
+ "offline_mode": True
174
+ }
175
+
176
+ self._save_session(session)
177
+ return session
178
+
179
+ def _offline_register(self, email: str, password: str) -> Dict:
180
+ """Offline registration fallback (beta mode) - no license key needed"""
181
+ # Hash password
182
+ password_hash = hashlib.sha256(password.encode()).hexdigest()
183
+
184
+ # Save user locally
185
+ users_file = self.config_dir / "users.json"
186
+ users = {}
187
+
188
+ if users_file.exists():
189
+ with open(users_file, 'r') as f:
190
+ users = json.load(f)
191
+
192
+ if email in users:
193
+ raise AuthenticationError("Email already registered")
194
+
195
+ user_id = hashlib.sha256(email.encode()).hexdigest()[:12]
196
+ users[email] = {
197
+ "user_id": user_id,
198
+ "password_hash": password_hash,
199
+ "license_key": license_key,
200
+ "daily_limit": 25,
201
+ "created_at": datetime.now().isoformat()
202
+ }
203
+
204
+ with open(users_file, 'w') as f:
205
+ json.dump(users, f, indent=2)
206
+
207
+ os.chmod(users_file, 0o600)
208
+
209
+ # Create session
210
+ session = {
211
+ "email": email,
212
+ "user_id": user_id,
213
+ "access_token": "offline-" + hashlib.sha256(email.encode()).hexdigest()[:16],
214
+ "daily_token_limit": 25000,
215
+ "expires_at": (datetime.now() + timedelta(days=30)).isoformat(),
216
+ "offline_mode": True
217
+ }
218
+
219
+ self._save_session(session)
220
+ return session
221
+
222
+ def _validate_license_format(self, license_key: str) -> bool:
223
+ """Validate license key format: NA-BETA-{uid}-{expiry}-{checksum}"""
224
+ parts = license_key.split("-")
225
+ return (
226
+ len(parts) == 5 and
227
+ parts[0] == "NA" and
228
+ parts[1] == "BETA" and
229
+ len(parts[2]) == 8 and # user_id
230
+ len(parts[3]) == 8 and # expiry date
231
+ len(parts[4]) == 8 # checksum
232
+ )
233
+
234
+ def generate_license_key(self, email: str, days: int = 30) -> str:
235
+ """
236
+ Generate a license key for a user
237
+ Format: NA-BETA-{user_id}-{expiry}-{checksum}
238
+ """
239
+ # Generate user ID from email
240
+ user_id = hashlib.sha256(email.encode()).hexdigest()[:8]
241
+
242
+ # Generate expiry date
243
+ expiry_date = datetime.now() + timedelta(days=days)
244
+ expiry_str = expiry_date.strftime("%Y%m%d")
245
+
246
+ # Generate checksum
247
+ data = f"{email}{user_id}{expiry_str}"
248
+ checksum = hashlib.sha256(data.encode()).hexdigest()[:8]
249
+
250
+ # Construct license key
251
+ license_key = f"NA-BETA-{user_id}-{expiry_str}-{checksum}"
252
+
253
+ return license_key
254
+
255
+ def _verify_license_offline(self, license_key: str, email: str) -> bool:
256
+ """Verify license key offline"""
257
+ try:
258
+ parts = license_key.split("-")
259
+ if len(parts) != 5:
260
+ return False
261
+
262
+ user_id_provided, expiry_str, checksum_provided = parts[2], parts[3], parts[4]
263
+
264
+ # Verify user ID matches email
265
+ user_id_expected = hashlib.sha256(email.encode()).hexdigest()[:8]
266
+ if user_id_provided != user_id_expected:
267
+ return False
268
+
269
+ # Check expiry
270
+ expiry_date = datetime.strptime(expiry_str, "%Y%m%d")
271
+ if datetime.now() > expiry_date:
272
+ return False
273
+
274
+ # Verify checksum
275
+ data = f"{email}{user_id_provided}{expiry_str}"
276
+ checksum_expected = hashlib.sha256(data.encode()).hexdigest()[:8]
277
+
278
+ return checksum_provided == checksum_expected
279
+
280
+ except Exception:
281
+ return False
@@ -0,0 +1,83 @@
1
+ """
2
+ Backend-only client for distribution.
3
+ All queries are proxied through the centralized backend.
4
+ Local LLM calls are not supported.
5
+ """
6
+
7
+ import os
8
+ import requests
9
+ from typing import Dict, Any, Optional
10
+
11
+ class BackendOnlyClient:
12
+ """
13
+ Minimal client that only talks to backend API.
14
+ Used in distribution to prevent local API key usage.
15
+ """
16
+
17
+ def __init__(self):
18
+ self.backend_url = (
19
+ os.getenv("NOCTURNAL_CONTROL_PLANE_URL")
20
+ or "https://cite-agent-api-720dfadd602c.herokuapp.com"
21
+ )
22
+ self.auth_token = os.getenv("NOCTURNAL_AUTH_TOKEN")
23
+
24
+ def query(self, message: str, style: str = "academic") -> Dict[str, Any]:
25
+ """
26
+ Send query to backend API.
27
+
28
+ Args:
29
+ message: User query
30
+ style: Response style
31
+
32
+ Returns:
33
+ Backend response with citations
34
+
35
+ Raises:
36
+ RuntimeError: If not authenticated or backend unavailable
37
+ """
38
+ if not self.auth_token:
39
+ raise RuntimeError(
40
+ "Not authenticated. Run 'cite-agent --setup' first."
41
+ )
42
+
43
+ try:
44
+ response = requests.post(
45
+ f"{self.backend_url}/api/query",
46
+ headers={"Authorization": f"Bearer {self.auth_token}"},
47
+ json={"query": message, "style": style},
48
+ timeout=30
49
+ )
50
+
51
+ if response.status_code == 401:
52
+ raise RuntimeError(
53
+ "Authentication failed. Your token may have expired. "
54
+ "Run 'cite-agent --setup' to log in again."
55
+ )
56
+
57
+ if response.status_code == 429:
58
+ raise RuntimeError(
59
+ "Daily quota exceeded (25,000 tokens/day). "
60
+ "Your quota will reset tomorrow."
61
+ )
62
+
63
+ response.raise_for_status()
64
+ return response.json()
65
+
66
+ except requests.RequestException as e:
67
+ raise RuntimeError(
68
+ f"Backend unavailable: {e}. "
69
+ f"Please check your internet connection or try again later."
70
+ ) from e
71
+
72
+ def check_quota(self) -> Dict[str, Any]:
73
+ """Check remaining daily quota"""
74
+ if not self.auth_token:
75
+ raise RuntimeError("Not authenticated")
76
+
77
+ response = requests.get(
78
+ f"{self.backend_url}/api/auth/me",
79
+ headers={"Authorization": f"Bearer {self.auth_token}"},
80
+ timeout=10
81
+ )
82
+ response.raise_for_status()
83
+ return response.json()