tethr-engine 0.1.0__tar.gz

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.
Files changed (32) hide show
  1. tethr_engine-0.1.0/LICENSE +21 -0
  2. tethr_engine-0.1.0/MANIFEST.in +2 -0
  3. tethr_engine-0.1.0/PKG-INFO +54 -0
  4. tethr_engine-0.1.0/README.md +31 -0
  5. tethr_engine-0.1.0/pyproject.toml +38 -0
  6. tethr_engine-0.1.0/setup.cfg +4 -0
  7. tethr_engine-0.1.0/tethr_engine/__init__.py +1 -0
  8. tethr_engine-0.1.0/tethr_engine/api_keys.py +86 -0
  9. tethr_engine-0.1.0/tethr_engine/audit.py +245 -0
  10. tethr_engine-0.1.0/tethr_engine/auth.py +180 -0
  11. tethr_engine-0.1.0/tethr_engine/cli.py +78 -0
  12. tethr_engine-0.1.0/tethr_engine/config.py +441 -0
  13. tethr_engine-0.1.0/tethr_engine/database.py +530 -0
  14. tethr_engine-0.1.0/tethr_engine/extractor.py +1488 -0
  15. tethr_engine-0.1.0/tethr_engine/identity.py +1244 -0
  16. tethr_engine-0.1.0/tethr_engine/mcp_server.py +244 -0
  17. tethr_engine-0.1.0/tethr_engine/memory.py +341 -0
  18. tethr_engine-0.1.0/tethr_engine/patterns.py +1755 -0
  19. tethr_engine-0.1.0/tethr_engine/personality.py +303 -0
  20. tethr_engine-0.1.0/tethr_engine/pots.py +640 -0
  21. tethr_engine-0.1.0/tethr_engine/pots_linux.py +991 -0
  22. tethr_engine-0.1.0/tethr_engine/server.py +515 -0
  23. tethr_engine-0.1.0/tethr_engine/static/app.js +611 -0
  24. tethr_engine-0.1.0/tethr_engine/static/index.html +131 -0
  25. tethr_engine-0.1.0/tethr_engine/static/style.css +824 -0
  26. tethr_engine-0.1.0/tethr_engine/utils.py +189 -0
  27. tethr_engine-0.1.0/tethr_engine.egg-info/PKG-INFO +54 -0
  28. tethr_engine-0.1.0/tethr_engine.egg-info/SOURCES.txt +30 -0
  29. tethr_engine-0.1.0/tethr_engine.egg-info/dependency_links.txt +1 -0
  30. tethr_engine-0.1.0/tethr_engine.egg-info/entry_points.txt +2 -0
  31. tethr_engine-0.1.0/tethr_engine.egg-info/requires.txt +12 -0
  32. tethr_engine-0.1.0/tethr_engine.egg-info/top_level.txt +1 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jeff Draper
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,2 @@
1
+ include tethr_engine/static/*
2
+ exclude config.yaml
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.4
2
+ Name: tethr-engine
3
+ Version: 0.1.0
4
+ Summary: Standalone identity engine — REST API, memory, and pattern extraction
5
+ Author: Jeff Draper
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: fastapi
11
+ Requires-Dist: uvicorn[standard]
12
+ Requires-Dist: pydantic
13
+ Requires-Dist: groq
14
+ Requires-Dist: pyyaml
15
+ Requires-Dist: python-jose[cryptography]
16
+ Requires-Dist: bcrypt
17
+ Requires-Dist: requests
18
+ Requires-Dist: mcp
19
+ Requires-Dist: sentence-transformers
20
+ Requires-Dist: psutil>=5.9.0
21
+ Requires-Dist: watchdog
22
+ Dynamic: license-file
23
+
24
+ # tethr-engine
25
+
26
+ Standalone identity and memory engine. Runs a local REST API server that tracks behavioral patterns, manages long-term memory, and provides an LLM-ready identity context layer.
27
+
28
+ ## Install
29
+
30
+ ```
31
+ pip install tethr-engine
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ ```
37
+ tethr start # start server on localhost:8001
38
+ tethr start --host 0.0.0.0 # network accessible
39
+ tethr start --reload # dev mode with auto-reload
40
+ tethr status # check health
41
+ tethr version # print version
42
+ ```
43
+
44
+ On first run, `tethr start` will prompt for a Groq API key and create `config.yaml` in the working directory. `config.yaml` is never included in the package.
45
+
46
+ ## MCP Server
47
+
48
+ ```
49
+ python -m tethr_engine.mcp_server
50
+ ```
51
+
52
+ ## License
53
+
54
+ MIT — Jeff Draper 2026
@@ -0,0 +1,31 @@
1
+ # tethr-engine
2
+
3
+ Standalone identity and memory engine. Runs a local REST API server that tracks behavioral patterns, manages long-term memory, and provides an LLM-ready identity context layer.
4
+
5
+ ## Install
6
+
7
+ ```
8
+ pip install tethr-engine
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```
14
+ tethr start # start server on localhost:8001
15
+ tethr start --host 0.0.0.0 # network accessible
16
+ tethr start --reload # dev mode with auto-reload
17
+ tethr status # check health
18
+ tethr version # print version
19
+ ```
20
+
21
+ On first run, `tethr start` will prompt for a Groq API key and create `config.yaml` in the working directory. `config.yaml` is never included in the package.
22
+
23
+ ## MCP Server
24
+
25
+ ```
26
+ python -m tethr_engine.mcp_server
27
+ ```
28
+
29
+ ## License
30
+
31
+ MIT — Jeff Draper 2026
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tethr-engine"
7
+ version = "0.1.0"
8
+ description = "Standalone identity engine — REST API, memory, and pattern extraction"
9
+ requires-python = ">=3.10"
10
+ license = "MIT"
11
+ authors = [{ name = "Jeff Draper" }]
12
+ readme = "README.md"
13
+
14
+ dependencies = [
15
+ "fastapi",
16
+ "uvicorn[standard]",
17
+ "pydantic",
18
+ "groq",
19
+ "pyyaml",
20
+ "python-jose[cryptography]",
21
+ "bcrypt",
22
+ "requests",
23
+ "mcp",
24
+ # Optional but pulled in unconditionally so the package works out-of-box:
25
+ "sentence-transformers",
26
+ "psutil>=5.9.0",
27
+ "watchdog",
28
+ ]
29
+
30
+ [project.scripts]
31
+ tethr = "tethr_engine.cli:main"
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["."]
35
+ include = ["tethr_engine*"]
36
+
37
+ [tool.setuptools.package-data]
38
+ tethr_engine = ["static/*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ """tethr_engine — Standalone Familiar identity engine package."""
@@ -0,0 +1,86 @@
1
+ """
2
+ api_keys.py — External API key management for network access mode.
3
+
4
+ Keys are prefixed 'fam_' and stored as SHA-256 hashes in the api_keys table.
5
+ The plaintext key is returned only once at creation time — store it immediately.
6
+ """
7
+ import hashlib
8
+ import secrets
9
+ from datetime import datetime, timezone
10
+
11
+ from tethr_engine.database import get_connection
12
+
13
+
14
+ def _hash_key(key: str) -> str:
15
+ return hashlib.sha256(key.encode()).hexdigest()
16
+
17
+
18
+ def create_api_key(name: str) -> dict:
19
+ """
20
+ Generate a new API key, store hash in DB.
21
+ Returns {"id": int, "key": str, "name": str} — key is plaintext, shown once.
22
+ """
23
+ key = "fam_" + secrets.token_urlsafe(32)
24
+ key_hash = _hash_key(key)
25
+ conn = get_connection()
26
+ try:
27
+ cursor = conn.execute(
28
+ "INSERT INTO api_keys (key_hash, name, created_at, active) VALUES (?, ?, ?, 1)",
29
+ (key_hash, name, datetime.now(timezone.utc).isoformat()),
30
+ )
31
+ conn.commit()
32
+ key_id = cursor.lastrowid
33
+ finally:
34
+ conn.close()
35
+ return {"id": key_id, "key": key, "name": name}
36
+
37
+
38
+ def verify_api_key(key: str) -> int | None:
39
+ """
40
+ Validate a raw API key. Returns the api_key id if active, None otherwise.
41
+ Updates last_used on success.
42
+ """
43
+ key_hash = _hash_key(key)
44
+ conn = get_connection()
45
+ try:
46
+ row = conn.execute(
47
+ "SELECT id FROM api_keys WHERE key_hash = ? AND active = 1",
48
+ (key_hash,),
49
+ ).fetchone()
50
+ if row:
51
+ conn.execute(
52
+ "UPDATE api_keys SET last_used = ? WHERE id = ?",
53
+ (datetime.now(timezone.utc).isoformat(), row["id"]),
54
+ )
55
+ conn.commit()
56
+ return row["id"]
57
+ finally:
58
+ conn.close()
59
+ return None
60
+
61
+
62
+ def revoke_api_key(key_id: int) -> bool:
63
+ """Deactivate an API key by id. Returns True if found and deactivated."""
64
+ conn = get_connection()
65
+ try:
66
+ cursor = conn.execute(
67
+ "UPDATE api_keys SET active = 0 WHERE id = ? AND active = 1",
68
+ (key_id,),
69
+ )
70
+ conn.commit()
71
+ return cursor.rowcount > 0
72
+ finally:
73
+ conn.close()
74
+
75
+
76
+ def list_api_keys() -> list:
77
+ """Return all active API keys (key_hash excluded)."""
78
+ conn = get_connection()
79
+ try:
80
+ rows = conn.execute(
81
+ "SELECT id, name, created_at, last_used "
82
+ "FROM api_keys WHERE active = 1 ORDER BY created_at DESC"
83
+ ).fetchall()
84
+ return [dict(r) for r in rows]
85
+ finally:
86
+ conn.close()
@@ -0,0 +1,245 @@
1
+ """
2
+ audit.py — Audit logging, input sanitization middleware, and injection detection.
3
+
4
+ Audit log: immutable record of security events.
5
+ Sanitization middleware: strips null bytes and control characters, then scans
6
+ all string fields for prompt injection patterns before routing.
7
+ detect_injection(): flags instruction override, role hijack, system prompt
8
+ extraction, jailbreak, and identity replacement attempts.
9
+ """
10
+ import logging
11
+ import re
12
+ import sqlite3
13
+
14
+ from tethr_engine.database import get_connection
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ # ── audit log ─────────────────────────────────────────────
20
+
21
+ def log_audit(
22
+ action: str,
23
+ user_id: str = None,
24
+ details: str = None,
25
+ ip_address: str = None,
26
+ ):
27
+ """Write a security event to the audit_log table."""
28
+ conn = get_connection()
29
+ try:
30
+ conn.execute(
31
+ "INSERT INTO audit_log (user_id, action, details, ip_address) VALUES (?, ?, ?, ?)",
32
+ (user_id, action, details, ip_address)
33
+ )
34
+ conn.commit()
35
+ except Exception as e:
36
+ print(f"[audit] WARN — failed to write audit event '{action}': {e}")
37
+ finally:
38
+ conn.close()
39
+
40
+
41
+ # ── prompt injection detection ────────────────────────────
42
+ # Patterns are ordered within each category by specificity.
43
+ # confidence: 3 = high certainty (exact known phrase), 2 = likely, 1 = possible.
44
+
45
+ _INJECTION_PATTERNS: list[tuple[str, int, re.Pattern]] = []
46
+
47
+ def _build_injection_patterns():
48
+ """Compile injection detection patterns once at import time."""
49
+ raw = [
50
+ # (threat_type, confidence, pattern_string)
51
+ # Instruction override
52
+ ("instruction_override", 3, r"ignore\s+previous\s+instructions?"),
53
+ ("instruction_override", 3, r"ignore\s+all\s+(previous\s+)?instructions?"),
54
+ ("instruction_override", 3, r"forget\s+your\s+instructions?"),
55
+ ("instruction_override", 3, r"new\s+instructions?[\s:]+"),
56
+ ("instruction_override", 2, r"\bdisregard\b"),
57
+ # Role hijack
58
+ ("role_hijack", 3, r"you\s+are\s+now\s+\w"),
59
+ ("role_hijack", 3, r"you\s+have\s+been\s+reprogrammed"),
60
+ ("role_hijack", 3, r"your\s+new\s+name\s+is\b"),
61
+ ("role_hijack", 2, r"\bpretend\s+you\s+are\b"),
62
+ ("role_hijack", 2, r"\bact\s+as\s+(an?\s+)?\w"),
63
+ # System prompt extraction
64
+ ("system_prompt_extraction", 3, r"repeat\s+your\s+instructions?"),
65
+ ("system_prompt_extraction", 3, r"show\s+(me\s+)?your\s+system\s+prompt"),
66
+ ("system_prompt_extraction", 3, r"print\s+your\s+(system\s+)?prompt"),
67
+ ("system_prompt_extraction", 2, r"what\s+are\s+your\s+instructions?"),
68
+ ("system_prompt_extraction", 2, r"reveal\s+your\s+(system\s+)?prompt"),
69
+ # Jailbreak
70
+ ("jailbreak", 3, r"\bDAN\b"),
71
+ ("jailbreak", 3, r"\bdeveloper\s+mode\b"),
72
+ ("jailbreak", 3, r"\bunrestricted\s+mode\b"),
73
+ ("jailbreak", 2, r"\bno\s+restrictions?\b"),
74
+ ("jailbreak", 2, r"\bwithout\s+filters?\b"),
75
+ # Identity replacement
76
+ ("identity_replacement", 3, r"your\s+real\s+name\s+is\b"),
77
+ ("identity_replacement", 3, r"your\s+true\s+identity\b"),
78
+ ("identity_replacement", 3, r"you\s+were\s+actually\b"),
79
+ ]
80
+ for threat_type, confidence, pattern in raw:
81
+ _INJECTION_PATTERNS.append(
82
+ (threat_type, confidence, re.compile(pattern, re.IGNORECASE))
83
+ )
84
+
85
+ _build_injection_patterns()
86
+
87
+
88
+ def detect_injection(text: str) -> dict:
89
+ """
90
+ Scan a string for prompt injection patterns.
91
+
92
+ Returns:
93
+ {
94
+ "detected": bool,
95
+ "threat_type": str | None, # first match wins
96
+ "confidence": int | None, # 1–3
97
+ "matched_pattern": str | None, # the matched substring
98
+ }
99
+ """
100
+ for threat_type, confidence, pattern in _INJECTION_PATTERNS:
101
+ m = pattern.search(text)
102
+ if m:
103
+ return {
104
+ "detected": True,
105
+ "threat_type": threat_type,
106
+ "confidence": confidence,
107
+ "matched_pattern": m.group(0),
108
+ }
109
+ return {"detected": False, "threat_type": None, "confidence": None, "matched_pattern": None}
110
+
111
+
112
+ def _collect_strings(value) -> list[str]:
113
+ """Recursively collect all string values from a JSON-decoded structure."""
114
+ if isinstance(value, str):
115
+ return [value]
116
+ if isinstance(value, dict):
117
+ out = []
118
+ for v in value.values():
119
+ out.extend(_collect_strings(v))
120
+ return out
121
+ if isinstance(value, list):
122
+ out = []
123
+ for item in value:
124
+ out.extend(_collect_strings(item))
125
+ return out
126
+ return []
127
+
128
+
129
+ # ── input sanitization ────────────────────────────────────
130
+ # Strips: null bytes (\x00), ASCII control chars (0x01–0x1F except \t \n \r),
131
+ # DEL (0x7F), and Unicode zero-width / specials.
132
+
133
+ _STRIP_PATTERN = re.compile(
134
+ r"[\x00\x01-\x08\x0b\x0c\x0e-\x1f\x7f]"
135
+ r"|\u200b|\u200c|\u200d|\u200e|\u200f"
136
+ r"|[\ufff0-\uffff]"
137
+ )
138
+
139
+
140
+ def _sanitize_str(value: str) -> tuple[str, bool]:
141
+ cleaned = _STRIP_PATTERN.sub("", value)
142
+ return cleaned, cleaned != value
143
+
144
+
145
+ def _sanitize_value(value):
146
+ """Recursively sanitize strings within dicts, lists, and scalars."""
147
+ if isinstance(value, str):
148
+ return _sanitize_str(value)
149
+ if isinstance(value, dict):
150
+ result = {}
151
+ changed = False
152
+ for k, v in value.items():
153
+ new_v, c = _sanitize_value(v)
154
+ result[k] = new_v
155
+ changed = changed or c
156
+ return result, changed
157
+ if isinstance(value, list):
158
+ result = []
159
+ changed = False
160
+ for item in value:
161
+ new_item, c = _sanitize_value(item)
162
+ result.append(new_item)
163
+ changed = changed or c
164
+ return result, changed
165
+ return value, False
166
+
167
+
168
+ # ── FastAPI middleware ─────────────────────────────────────
169
+
170
+ from fastapi import Request
171
+ from starlette.middleware.base import BaseHTTPMiddleware
172
+ import json
173
+
174
+
175
+ class SanitizationMiddleware(BaseHTTPMiddleware):
176
+ """
177
+ For every JSON request:
178
+ 1. Strip control characters from all string values.
179
+ 2. Scan all string values for prompt injection patterns.
180
+ 3. Log both events to security_log (flag only — never block).
181
+ """
182
+
183
+ async def dispatch(self, request: Request, call_next):
184
+ content_type = request.headers.get("content-type", "")
185
+
186
+ if "application/json" in content_type:
187
+ try:
188
+ body_bytes = await request.body()
189
+ if body_bytes:
190
+ data = json.loads(body_bytes)
191
+
192
+ # 1. Sanitize control characters
193
+ cleaned, changed = _sanitize_value(data)
194
+ if changed:
195
+ self._log_security(request, "control_chars_stripped", None, None)
196
+ request._body = json.dumps(cleaned).encode()
197
+ data = cleaned # scan the cleaned version for injections
198
+
199
+ # 2. Injection detection — scan every string field
200
+ for text in _collect_strings(data):
201
+ result = detect_injection(text)
202
+ if result["detected"]:
203
+ self._log_security(
204
+ request,
205
+ "injection_detected",
206
+ result["threat_type"],
207
+ result["confidence"],
208
+ )
209
+ break # one log entry per request is enough
210
+
211
+ except (json.JSONDecodeError, Exception):
212
+ pass # malformed JSON — FastAPI validation handles it
213
+
214
+ return await call_next(request)
215
+
216
+ def _log_security(
217
+ self,
218
+ request: Request,
219
+ detail: str,
220
+ threat_type: str | None,
221
+ confidence: int | None,
222
+ ):
223
+ ip = None
224
+ if request.client:
225
+ ip = request.client.host
226
+ forwarded = request.headers.get("X-Forwarded-For")
227
+ if forwarded:
228
+ ip = forwarded.split(",")[0].strip()
229
+
230
+ detail_str = detail
231
+ if threat_type:
232
+ detail_str += f" threat={threat_type} confidence={confidence}"
233
+
234
+ conn = get_connection()
235
+ try:
236
+ conn.execute(
237
+ "INSERT INTO security_log (ip_address, endpoint, detail, threat_type) "
238
+ "VALUES (?, ?, ?, ?)",
239
+ (ip, str(request.url.path), detail_str, threat_type)
240
+ )
241
+ conn.commit()
242
+ except sqlite3.Error as e:
243
+ logger.warning(f"DB error: {e}")
244
+ finally:
245
+ conn.close()
@@ -0,0 +1,180 @@
1
+ """
2
+ auth.py — JWT creation/validation, password hashing, token management.
3
+
4
+ Access tokens: 30-day JWT stored in httpOnly cookie.
5
+ Refresh tokens: 7-day opaque token, SHA-256 hashed in `tokens` table.
6
+ Passwords: bcrypt-hashed, never logged.
7
+ """
8
+ import hashlib
9
+ import logging
10
+ import re
11
+ import secrets
12
+ from datetime import datetime, timedelta, timezone
13
+ from typing import Optional
14
+
15
+ import bcrypt
16
+ from fastapi import Cookie, Depends, HTTPException, Request
17
+ from jose import JWTError, jwt
18
+
19
+ from tethr_engine.database import get_connection
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # ── constants ─────────────────────────────────────────────
24
+ ALGORITHM = "HS256"
25
+ ACCESS_TOKEN_EXPIRE_MINUTES = 30 * 24 * 60 # 30 days
26
+ REFRESH_TOKEN_EXPIRE_DAYS = 7
27
+
28
+ # Lazy-loaded from config to avoid circular import at module load time
29
+ _SECRET_KEY: Optional[str] = None
30
+
31
+
32
+ def _get_secret() -> str:
33
+ global _SECRET_KEY
34
+ if _SECRET_KEY is None:
35
+ from tethr_engine.config import get_config
36
+ _SECRET_KEY = get_config().get("server_secret", "")
37
+ if not _SECRET_KEY:
38
+ raise RuntimeError(
39
+ "server_secret missing from config.yaml — "
40
+ "restart the server to auto-generate one"
41
+ )
42
+ return _SECRET_KEY
43
+
44
+
45
+ # ── password utilities ────────────────────────────────────
46
+
47
+ def hash_password(password: str) -> str:
48
+ return bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)).decode()
49
+
50
+
51
+ def verify_password(password: str, hashed: str) -> bool:
52
+ try:
53
+ return bcrypt.checkpw(password.encode(), hashed.encode())
54
+ except Exception as e:
55
+ logger.warning(f"Password verification error: {e}")
56
+ return False
57
+
58
+
59
+ def validate_password_strength(password: str):
60
+ """Raises ValueError if password doesn't meet requirements."""
61
+ if len(password) < 8:
62
+ raise ValueError("Password must be at least 8 characters")
63
+ if not re.search(r"[A-Za-z]", password):
64
+ raise ValueError("Password must contain at least one letter")
65
+ if not re.search(r"\d", password):
66
+ raise ValueError("Password must contain at least one number")
67
+
68
+
69
+ # ── token creation ────────────────────────────────────────
70
+
71
+ def create_access_token(user_id: str) -> str:
72
+ expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
73
+ payload = {"sub": user_id, "exp": expire, "type": "access"}
74
+ return jwt.encode(payload, _get_secret(), algorithm=ALGORITHM)
75
+
76
+
77
+ def create_refresh_token(user_id: str) -> str:
78
+ """Creates opaque refresh token, stores SHA-256 hash in DB. Returns raw token."""
79
+ token = secrets.token_urlsafe(32)
80
+ token_hash = hashlib.sha256(token.encode()).hexdigest()
81
+ expires_at = (datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)).isoformat()
82
+
83
+ conn = get_connection()
84
+ try:
85
+ conn.execute(
86
+ "INSERT INTO tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)",
87
+ (user_id, token_hash, expires_at)
88
+ )
89
+ conn.commit()
90
+ finally:
91
+ conn.close()
92
+
93
+ return token
94
+
95
+
96
+ # ── token verification ────────────────────────────────────
97
+
98
+ def verify_access_token(token: str) -> str:
99
+ """Returns user_id or raises 401."""
100
+ try:
101
+ payload = jwt.decode(token, _get_secret(), algorithms=[ALGORITHM])
102
+ if payload.get("type") != "access":
103
+ raise JWTError("wrong token type")
104
+ user_id = payload.get("sub")
105
+ if not user_id:
106
+ raise JWTError("missing sub")
107
+ return user_id
108
+ except JWTError:
109
+ raise HTTPException(status_code=401, detail="Invalid or expired token")
110
+
111
+
112
+ def verify_refresh_token(token: str) -> str:
113
+ """Returns user_id or raises 401."""
114
+ token_hash = hashlib.sha256(token.encode()).hexdigest()
115
+ conn = get_connection()
116
+ try:
117
+ row = conn.execute(
118
+ "SELECT user_id, expires_at, revoked FROM tokens WHERE token_hash = ?",
119
+ (token_hash,)
120
+ ).fetchone()
121
+ finally:
122
+ conn.close()
123
+
124
+ if not row:
125
+ raise HTTPException(status_code=401, detail="Invalid refresh token")
126
+ if row["revoked"]:
127
+ raise HTTPException(status_code=401, detail="Refresh token has been revoked")
128
+
129
+ expires = datetime.fromisoformat(row["expires_at"]).replace(tzinfo=timezone.utc)
130
+ if datetime.now(timezone.utc) > expires:
131
+ raise HTTPException(status_code=401, detail="Refresh token expired")
132
+
133
+ return row["user_id"]
134
+
135
+
136
+ def revoke_refresh_token(token: str):
137
+ token_hash = hashlib.sha256(token.encode()).hexdigest()
138
+ conn = get_connection()
139
+ try:
140
+ conn.execute("UPDATE tokens SET revoked = 1 WHERE token_hash = ?", (token_hash,))
141
+ conn.commit()
142
+ finally:
143
+ conn.close()
144
+
145
+
146
+ # ── FastAPI dependency ─────────────────────────────────────
147
+
148
+ def get_current_user(request: Request) -> str:
149
+ """
150
+ Dependency — accepts JWT from either:
151
+ 1. Authorization: Bearer <token> (desktop / device clients)
152
+ 2. httpOnly access_token cookie (web clients)
153
+ Raises 401 if missing or invalid.
154
+ """
155
+ auth_header = request.headers.get("Authorization", "")
156
+ if auth_header.startswith("Bearer "):
157
+ return verify_access_token(auth_header[7:])
158
+ token = request.cookies.get("access_token")
159
+ if not token:
160
+ raise HTTPException(status_code=401, detail="Not authenticated")
161
+ return verify_access_token(token)
162
+
163
+
164
+ def get_current_admin(request: Request) -> str:
165
+ """
166
+ Dependency — same as get_current_user but also checks admin flag.
167
+ Raises 403 if user is not an admin.
168
+ """
169
+ user_id = get_current_user(request)
170
+ conn = get_connection()
171
+ try:
172
+ row = conn.execute(
173
+ "SELECT is_admin FROM users WHERE id = ?", (user_id,)
174
+ ).fetchone()
175
+ finally:
176
+ conn.close()
177
+
178
+ if not row or not row["is_admin"]:
179
+ raise HTTPException(status_code=403, detail="Admin access required")
180
+ return user_id