gitcast 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.
Files changed (61) hide show
  1. ai/__init__.py +0 -0
  2. ai/formatter.py +59 -0
  3. ai/generator.py +604 -0
  4. ai/prompts.py +197 -0
  5. ai/viral_patterns.py +75 -0
  6. api/__init__.py +0 -0
  7. api/analytics.py +48 -0
  8. api/auth.py +49 -0
  9. api/auth_middleware.py +129 -0
  10. api/auth_routes.py +117 -0
  11. api/monitoring.py +56 -0
  12. api/payload.py +253 -0
  13. api/ratelimit.py +9 -0
  14. api/routes.py +1565 -0
  15. api/server.py +162 -0
  16. api/validators.py +101 -0
  17. assets/__init__.py +1 -0
  18. assets/favicon-16x16.png +0 -0
  19. assets/favicon-32x32.png +0 -0
  20. assets/favicon-64x64.png +0 -0
  21. assets/favicon.ico +0 -0
  22. assets/icon.png +0 -0
  23. cli/.env.example +26 -0
  24. cli/__init__.py +1 -0
  25. cli/gitcast.py +79 -0
  26. config/__init__.py +0 -0
  27. config/settings.py +213 -0
  28. core/__init__.py +0 -0
  29. core/capture.py +258 -0
  30. core/codebase_reader.py +90 -0
  31. core/framing.py +86 -0
  32. core/hotkey.py +21 -0
  33. core/log_stream.py +50 -0
  34. core/ocr.py +173 -0
  35. core/screenshot_session.py +274 -0
  36. core/security.py +126 -0
  37. core/tray.py +54 -0
  38. gitcast-1.0.0.dist-info/LICENSE +21 -0
  39. gitcast-1.0.0.dist-info/METADATA +67 -0
  40. gitcast-1.0.0.dist-info/RECORD +61 -0
  41. gitcast-1.0.0.dist-info/WHEEL +5 -0
  42. gitcast-1.0.0.dist-info/entry_points.txt +2 -0
  43. gitcast-1.0.0.dist-info/top_level.txt +10 -0
  44. publisher/__init__.py +0 -0
  45. publisher/clipboard.py +44 -0
  46. publisher/twitter.py +100 -0
  47. storage/__init__.py +0 -0
  48. storage/cleanup.py +60 -0
  49. storage/engagement.py +114 -0
  50. storage/insights.py +203 -0
  51. storage/key_manager.py +45 -0
  52. storage/logger.py +208 -0
  53. storage/metrics.py +119 -0
  54. storage/sprint.py +40 -0
  55. storage/streak.py +0 -0
  56. storage/supabase_client.py +25 -0
  57. storage/tone_memory.py +139 -0
  58. ui/__init__.py +0 -0
  59. web/__init__.py +1 -0
  60. web/index.html +4994 -0
  61. web/landing.html +925 -0
ai/prompts.py ADDED
@@ -0,0 +1,197 @@
1
+ import json
2
+ from config.settings import get_project_narrative, is_tone_memory_enabled, PROMPTS_FILE, get_twitter_plan
3
+ from storage.tone_memory import get_few_shot_examples
4
+ from ai.viral_patterns import get_viral_pattern
5
+
6
+ # ── PROMPT_MAP ───────────────────────────────────────────────────────────────
7
+ PROMPT_MAP = {
8
+ "linkedin": "linkedin_post_prompt",
9
+ "article": "article_prompt"
10
+ }
11
+
12
+
13
+ # ── Narrative injection ───────────────────────────────────────────────────────
14
+
15
+ def _narrative_block() -> str:
16
+ narrative = get_project_narrative()
17
+ if not narrative:
18
+ return ""
19
+ return f"\n\nProject context: The developer is building {narrative}. Where relevant, frame the win or struggle in the context of this larger mission."
20
+
21
+
22
+ def _tone_block() -> str:
23
+ if not is_tone_memory_enabled():
24
+ return ""
25
+ examples = get_few_shot_examples()
26
+ if not examples:
27
+ return ""
28
+ formatted = "\n\n".join(
29
+ f"Example post (highly rated by this user):\n{ex}" for ex in examples
30
+ )
31
+ return f"\n\nHere are examples of posts this developer has written that performed well. Match their voice, tone, and style closely:\n\n{formatted}"
32
+
33
+
34
+ def _plan_block() -> str:
35
+ """
36
+ Returns character limit instructions based on the user's X plan.
37
+ """
38
+ plan = get_twitter_plan()
39
+ if plan == "premium":
40
+ return "\n\nUser Plan: X Premium. You are NOT limited to 280 characters. Feel free to write longer, high-value posts (up to 4000 characters) if the context warrants it."
41
+ else:
42
+ return "\n\nUser Plan: X Free/Basic. You MUST stay under 280 characters for the main post."
43
+
44
+
45
+ # ── Base rules applied to every prompt ───────────────────────────────────────
46
+
47
+ BASE_RULES = """
48
+ Rules:
49
+ - Prioritize the developer's raw thought/prompt if one is provided. If the developer's prompt requests a specific topic, tone, or opening phrase (even if it contradicts these rules, like introducing a project or expressing excitement), follow the developer's instructions over these default rules.
50
+ - Write in first person as the developer. Never use "the developer" or third person.
51
+ - Sound human — like a real developer talking to other developers, not a press release.
52
+ - No hashtags unless they appear naturally. Never more than two.
53
+ - No generic filler phrases: "excited to share", "game changer", "thrilled", "journey" (unless explicitly requested by the developer's prompt).
54
+ - Never start with "I" (unless explicitly requested by the developer's prompt).
55
+ - Keep it grounded and specific. Vague posts get ignored.
56
+ - If there is a code snippet in the context, reference the actual function name, variable, or error — not just "the code".
57
+ """
58
+
59
+
60
+ # ── Prompt Loading ───────────────────────────────────────────────────────────
61
+
62
+ def load_prompt_definitions() -> dict:
63
+ """Loads prompt templates from the JSON store."""
64
+ if not PROMPTS_FILE.exists():
65
+ return {}
66
+ try:
67
+ with open(PROMPTS_FILE, "r", encoding="utf-8") as f:
68
+ return json.load(f)
69
+ except Exception as e:
70
+ print(f"[Prompts] Error loading prompts: {e}")
71
+ return {}
72
+
73
+
74
+ def get_prompt(format_key: str) -> str:
75
+ """Returns the fully assembled system prompt for a given format key."""
76
+ pattern = get_viral_pattern(format_key)
77
+
78
+ # Try to load from prompts.json definitions first for full customization
79
+ definitions = load_prompt_definitions()
80
+ if format_key in definitions:
81
+ template = definitions[format_key]["system_prompt"]
82
+ elif format_key in PROMPT_MAP:
83
+ func_name = PROMPT_MAP[format_key]
84
+ if func_name == "linkedin_post_prompt":
85
+ template = linkedin_post_prompt()
86
+ elif func_name == "article_prompt":
87
+ template = article_prompt()
88
+ else:
89
+ template = ""
90
+ else:
91
+ raise ValueError(f"Unknown prompt format: '{format_key}'")
92
+
93
+ # Assembly with viral pattern injection
94
+ plan_block = _plan_block() if format_key not in ["linkedin", "pr_generator", "article"] else ""
95
+ prompt = f"{template}\n\n{pattern}\n\n{BASE_RULES}{_narrative_block()}{_tone_block()}{plan_block}"
96
+ return prompt
97
+
98
+
99
+ def get_all_prompts() -> dict[str, str]:
100
+ """Returns all prompts as a dict for parallel generation."""
101
+ definitions = load_prompt_definitions()
102
+ prompts = {key: get_prompt(key) for key in definitions.keys() if key != "sprint_summary"}
103
+
104
+ # Ensure linkedin and article are included
105
+ if "linkedin" not in prompts:
106
+ prompts["linkedin"] = get_prompt("linkedin")
107
+ if "article" not in prompts:
108
+ prompts["article"] = get_prompt("article")
109
+
110
+ return prompts
111
+
112
+
113
+ # ── Specialized Prompts ───────────────────────────────────────────────────────
114
+
115
+ def linkedin_post_prompt() -> str:
116
+ """Professional but human LinkedIn post prompt template."""
117
+ return """You are a developer writing a professional but human post for LinkedIn.
118
+
119
+ Your goal is to share a technical win or insight in a way that builds your professional brand without sounding like a corporate robot. LinkedIn posts perform best with white space, a strong hook, and a personal narrative.
120
+
121
+ IMPORTANT: Prioritize the developer's raw thought/prompt if one is provided. Even if the developer's thought/prompt is brief, you MUST elaborate extensively, explaining the project narrative, technical context, challenges, choices, and what it unlocks next. Do NOT write a short 2-3 sentence post. Your output MUST meet the target length of 800–1300 characters.
122
+
123
+ Format guidance:
124
+ - Hook: Line 1 must be a compelling one-sentence hook that stops the scroll.
125
+ - The Story: Explain the technical context, the challenge, and how you solved it. Use line breaks for readability.
126
+ - The Insight: Share one high-level takeaway that other professionals (not just devs) can appreciate.
127
+ - CTA: End with a call to action or a question to your network.
128
+ - No hashtag spam (max 3). No generic 'thrilled to announce' filler.
129
+
130
+ Character target: 800–1300 characters."""
131
+
132
+
133
+ def article_prompt(codebase_summary: str = "") -> str:
134
+ """Generates a full Medium-ready markdown article template."""
135
+ codebase_block = f"\n\nCodebase Summary:\n{codebase_summary}" if codebase_summary else ""
136
+ return f"""You are a developer writing a full Medium-ready technical article in Markdown.
137
+
138
+ Your goal is to turn the current sprint context and raw thoughts into a structured, high-value technical article that documents your journey and teaches a specific lesson.
139
+
140
+ Sections to include:
141
+ 1. Hook: A dramatic opener about the problem or the struggle.
142
+ 2. Context: What you were building and why it matters.
143
+ 3. The Journey: The key moments, decisions, and breakthroughs from the logs.
144
+ 4. Technical Detail: Deep dive into the implementation. Use code snippets from the git diff.
145
+ 5. Resolution: What is now built and working that wasn't before? What does it unlock?
146
+ 6. Takeaway: One specific thing other developers can apply to their own work.
147
+
148
+ Target length: 800–1500 words. Be comprehensive and use Markdown formatting for headers, lists, and code blocks.{codebase_block}"""
149
+
150
+
151
+ def article_refinement_prompt(current_article: str, instruction: str) -> str:
152
+ """Takes existing article draft + user refinement instruction."""
153
+ return f"""You are an editor helping a developer refine their technical article.
154
+
155
+ Current Article Draft:
156
+ ---
157
+ {current_article}
158
+ ---
159
+
160
+ User Instruction: {instruction}
161
+
162
+ Your job is to update the article based on the user's instruction while maintaining the professional yet human developer voice, the Markdown structure, and the technical depth. Output the COMPLETE updated article.
163
+
164
+ No preamble. Just the revised Markdown."""
165
+
166
+
167
+ # ── Sprint Mode batch prompt ──────────────────────────────────────────────────
168
+
169
+ def sprint_summary_prompt(num_captures: int) -> str:
170
+ # Sprint summary is a special case that we'll keep as a function for now
171
+ # or it could also be moved to JSON if needed.
172
+ return f"""You are a developer writing a build thread for X (Twitter) that covers an entire coding sprint.
173
+
174
+ You have been given a log of {num_captures} separate captures made during a focused sprint — each containing a git diff, OCR context, and a short raw thought. Your job is to synthesise them into one compelling narrative thread that tells the full story of the sprint.
175
+
176
+ Format guidance:
177
+ - Tweet 1: The hook. What was the mission of this sprint? What problem were you trying to solve? Make someone stop scrolling.
178
+ - Tweets 2–4: The build story. Pick the 3 most interesting moments from the log — a key decision, a hard bug, a breakthrough. One tweet per moment. Be specific and sequential.
179
+ - Tweet 5: The outcome. What is now built and working that wasn't before? What does it unlock?
180
+ - Final tweet: One honest reflection — what would you do differently, what surprised you, or what is next?
181
+
182
+ Rules:
183
+ - Each tweet must stand alone but flow naturally into the next.
184
+ - Number each tweet: 1/, 2/, 3/ etc.
185
+ - Never use "excited to share" or "amazing journey". This is a war report, not a LinkedIn post.
186
+ - Specific details from the log (actual function names, error messages, time spent) make this format work. Use them.
187
+ - Total thread length: 6–7 tweets.
188
+
189
+ {BASE_RULES}{_narrative_block()}{_tone_block()}"""
190
+
191
+
192
+ if __name__ == "__main__":
193
+ print("=== DYNAMIC PROMPTS TEST ===")
194
+ prompts = get_all_prompts()
195
+ for k, p in prompts.items():
196
+ print(f"\n--- {k.upper()} ---")
197
+ print(p[:200] + "...")
ai/viral_patterns.py ADDED
@@ -0,0 +1,75 @@
1
+ # [ViralPatterns] module for structural guidance in prompt building
2
+
3
+ VIRAL_STRUCTURES = {
4
+ "hot_take": """Optional Structural Guidance (adapt to the developer's prompt if one is provided):
5
+ - Lead with an engaging, opinionated opener related to the topic.
6
+ - Use a supporting observation or detail from the context.
7
+ - Invite discussion or feedback in the closing line.""",
8
+
9
+ "confession": """Optional Structural Guidance (only apply if the developer is sharing a struggle/bug):
10
+ - Open with the symptom or frustration to hook the reader.
11
+ - Briefly explain what didn't work and the key breakthrough/fix.
12
+ - Share a lesson learned or invite others to share similar stories.""",
13
+
14
+ "technical_flex": """Optional Structural Guidance (only apply if the developer is sharing a win/performance improvement):
15
+ - Lead with a concrete metric, improvement, or milestone.
16
+ - Provide a clear, one-line technical explanation of how it was achieved.
17
+ - Share a sharp, confident insight about the engineering approach.""",
18
+
19
+ "thread_hook": """Optional Structural Guidance (for threads):
20
+ - Start with a compelling, open-ended hook/question as the first tweet.
21
+ - Break down the implementation or narrative sequentially across tweets.
22
+ - Number the tweets (1/, 2/, etc.) for flow.""",
23
+
24
+ "opinion_mode": """Optional Structural Guidance (only apply if the developer's prompt is about sharing a general opinion/stance):
25
+ - State a clear stance on a developer ecosystem topic or practice.
26
+ - Back it up with specific developer experience rather than general theory.
27
+ - End with an engaging question to the community.""",
28
+
29
+ "build_confession": """Optional Structural Guidance (only apply if the developer's prompt is about a struggle or complex feature):
30
+ - Set up the challenge and why it was difficult.
31
+ - Highlight the breakthrough or key design choice.
32
+ - Show the current working outcome.""",
33
+
34
+ "linkedin_narrative": """Optional Structural Guidance (for LinkedIn style):
35
+ - Hook: A scroll-stopping opening line highlighting the project, milestone, or challenge.
36
+ - Spacing: Use double line breaks between short paragraphs (1-2 sentences max) to ensure clean readability on mobile devices.
37
+ - Narrative: Tell a relatable story: the challenge/goal, the process, and the breakthrough/launch.
38
+ - Professional Takeaway: Share a high-level lesson or insight that other tech professionals or builders can apply.
39
+ - CTA: End with an engaging question to invite comments (e.g., asking for feedback or experiences).""",
40
+
41
+ "velocity_update": """Optional Structural Guidance (for quick status updates):
42
+ - Lead with the direct outcome (e.g., 'Just shipped...', 'Fixed the...').
43
+ - Provide a single sentence of technical context.
44
+ - End with forward momentum (what this unlocks next).""",
45
+
46
+ "pr_template": """Optional Structural Guidance (for Pull Requests):
47
+ - Markdown layout with clear headings (What changed, Why, How, Testing, Notes).
48
+ - Technical details referencing actual files, functions, or changes.
49
+ - Neutral, documentation-focused tone."""
50
+ }
51
+
52
+ def get_viral_pattern(format_key: str) -> str:
53
+ """Returns a pattern prompt snippet based on the format key."""
54
+ # Mapping certain format keys to specific patterns
55
+ mapping = {
56
+ "deep_tech": "technical_flex",
57
+ "struggle": "confession",
58
+ "quick_win": "velocity_update",
59
+ "linkedin": "linkedin_narrative",
60
+ "pr_generator": "pr_template",
61
+ "thought": "hot_take"
62
+ }
63
+
64
+ pattern_key = mapping.get(format_key, "opinion_mode")
65
+ return VIRAL_STRUCTURES.get(pattern_key, "")
66
+
67
+ def get_all_patterns() -> dict:
68
+ """Returns the full dictionary of viral structures."""
69
+ return VIRAL_STRUCTURES
70
+
71
+ if __name__ == "__main__":
72
+ print("=== VIRAL PATTERNS TEST ===")
73
+ for key in ["deep_tech", "struggle", "unknown"]:
74
+ print(f"\n--- Pattern for {key} ---")
75
+ print(get_viral_pattern(key))
api/__init__.py ADDED
File without changes
api/analytics.py ADDED
@@ -0,0 +1,48 @@
1
+ import uuid
2
+
3
+ from config.settings import CONFIG_DIR, POSTHOG_API_KEY
4
+
5
+
6
+ ANONYMOUS_ID_FILE = CONFIG_DIR / "anonymous_id.txt"
7
+ SENSITIVE_KEYS = {"post_text", "ocr_text", "git_diff", "screenshot_path", "api_key", "key", "raw_thought"}
8
+
9
+
10
+ def _anonymous_id() -> str:
11
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
12
+ if ANONYMOUS_ID_FILE.exists():
13
+ value = ANONYMOUS_ID_FILE.read_text(encoding="utf-8").strip()
14
+ if value:
15
+ return value
16
+
17
+ value = str(uuid.uuid4())
18
+ ANONYMOUS_ID_FILE.write_text(value, encoding="utf-8")
19
+ return value
20
+
21
+
22
+ ANONYMOUS_ID = _anonymous_id()
23
+
24
+
25
+ def _clean_properties(properties: dict) -> dict:
26
+ cleaned = {}
27
+ for key, value in (properties or {}).items():
28
+ if key in SENSITIVE_KEYS:
29
+ continue
30
+ cleaned[key] = value
31
+ return cleaned
32
+
33
+
34
+ def track(event: str, properties: dict = {}) -> None:
35
+ if not POSTHOG_API_KEY:
36
+ return
37
+
38
+ try:
39
+ import posthog
40
+
41
+ posthog.project_api_key = POSTHOG_API_KEY
42
+ posthog.capture(ANONYMOUS_ID, event, _clean_properties(properties))
43
+ except Exception:
44
+ return
45
+
46
+
47
+ if __name__ == "__main__":
48
+ print(f"[Analytics] Anonymous ID: {ANONYMOUS_ID}")
api/auth.py ADDED
@@ -0,0 +1,49 @@
1
+ import uuid
2
+ from fastapi import Header, HTTPException
3
+
4
+ # [Auth] module for session-based API security
5
+
6
+ def generate_session_token():
7
+ """Returns a random UUID string for session authentication."""
8
+ return str(uuid.uuid4())
9
+
10
+ # SESSION_TOKEN global set on module import
11
+ SESSION_TOKEN = generate_session_token()
12
+
13
+ async def verify_token(x_session_token: str = Header(None)):
14
+ """
15
+ FastAPI dependency that raises 401 if the provided token
16
+ doesn't match the active session token.
17
+ """
18
+ if x_session_token != SESSION_TOKEN:
19
+ raise HTTPException(
20
+ status_code=401,
21
+ detail="Unauthorized: Invalid or missing X-Session-Token"
22
+ )
23
+ return x_session_token
24
+
25
+ def get_token():
26
+ """Returns current SESSION_TOKEN for startup display."""
27
+ return SESSION_TOKEN
28
+
29
+ if __name__ == "__main__":
30
+ print("=== API AUTH TEST ===")
31
+ token = get_token()
32
+ print(f"Current Session Token: {token}")
33
+
34
+ # Simple simulated verification
35
+ import asyncio
36
+ async def test_verify():
37
+ try:
38
+ await verify_token(token)
39
+ print("Verification with correct token: PASSED")
40
+ except HTTPException:
41
+ print("Verification with correct token: FAILED")
42
+
43
+ try:
44
+ await verify_token("wrong-token")
45
+ print("Verification with wrong token: FAILED")
46
+ except HTTPException:
47
+ print("Verification with wrong token: PASSED")
48
+
49
+ asyncio.run(test_verify())
api/auth_middleware.py ADDED
@@ -0,0 +1,129 @@
1
+ import hashlib
2
+ import json
3
+ from typing import Optional
4
+ from fastapi import Header, Query, HTTPException
5
+ from jose import JWTError, jwt
6
+
7
+ from config.settings import CONFIG_DIR, SUPABASE_JWT_AUDIENCE, SUPABASE_JWT_SECRET
8
+ from storage.supabase_client import get_client
9
+
10
+ LOCAL_USER_ID = "local_user"
11
+ KNOWN_SESSIONS: dict[str, str] = {}
12
+ SESSION_CACHE_FILE = CONFIG_DIR / "auth_sessions.json"
13
+
14
+
15
+ def _token_hash(token: str) -> str:
16
+ return hashlib.sha256(token.encode("utf-8")).hexdigest()
17
+
18
+
19
+ def _load_session_cache() -> dict[str, str]:
20
+ try:
21
+ if SESSION_CACHE_FILE.exists():
22
+ with open(SESSION_CACHE_FILE, "r", encoding="utf-8") as file:
23
+ data = json.load(file)
24
+ if isinstance(data, dict):
25
+ return {str(key): str(value) for key, value in data.items()}
26
+ except Exception:
27
+ return {}
28
+ return {}
29
+
30
+
31
+ def _save_session_cache(cache: dict[str, str]) -> None:
32
+ try:
33
+ SESSION_CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
34
+ with open(SESSION_CACHE_FILE, "w", encoding="utf-8") as file:
35
+ json.dump(cache, file, indent=2)
36
+ except Exception:
37
+ pass
38
+
39
+
40
+ def _unauthorized(message: str = "Unauthorized") -> HTTPException:
41
+ return HTTPException(status_code=401, detail=message)
42
+
43
+
44
+ def verify_jwt(token: str) -> dict:
45
+ if not token:
46
+ raise _unauthorized("Missing bearer token")
47
+
48
+ known_user_id = KNOWN_SESSIONS.get(token)
49
+ if known_user_id:
50
+ return {"user_id": known_user_id, "sub": known_user_id}
51
+ cached_user_id = _load_session_cache().get(_token_hash(token))
52
+ if cached_user_id:
53
+ KNOWN_SESSIONS[token] = cached_user_id
54
+ return {"user_id": cached_user_id, "sub": cached_user_id}
55
+
56
+ if SUPABASE_JWT_SECRET:
57
+ try:
58
+ payload = jwt.decode(
59
+ token,
60
+ SUPABASE_JWT_SECRET,
61
+ algorithms=["HS256"],
62
+ audience=SUPABASE_JWT_AUDIENCE,
63
+ )
64
+ except JWTError as exc:
65
+ raise _unauthorized("Invalid bearer token") from exc
66
+
67
+ user_id = payload.get("sub")
68
+ if not user_id:
69
+ raise _unauthorized("Invalid bearer token")
70
+ payload["user_id"] = user_id
71
+ return payload
72
+
73
+ try:
74
+ response = get_client().auth.get_user(token)
75
+ user = getattr(response, "user", None)
76
+ user_id = getattr(user, "id", None)
77
+ if not user_id:
78
+ raise _unauthorized("Invalid bearer token")
79
+ return {"user_id": str(user_id), "sub": str(user_id)}
80
+ except HTTPException:
81
+ raise
82
+ except Exception as exc:
83
+ raise _unauthorized("Invalid bearer token") from exc
84
+
85
+
86
+ def register_session(access_token: str, user_id: str) -> None:
87
+ if access_token and user_id:
88
+ user_id = str(user_id)
89
+ KNOWN_SESSIONS[access_token] = user_id
90
+ cache = _load_session_cache()
91
+ cache[_token_hash(access_token)] = user_id
92
+ _save_session_cache(cache)
93
+
94
+
95
+ def unregister_session(access_token: str) -> None:
96
+ if access_token:
97
+ KNOWN_SESSIONS.pop(access_token, None)
98
+ cache = _load_session_cache()
99
+ cache.pop(_token_hash(access_token), None)
100
+ _save_session_cache(cache)
101
+
102
+
103
+ async def get_current_user(
104
+ authorization: str = Header(None),
105
+ x_session_token: str = Header(None),
106
+ token: Optional[str] = Query(None),
107
+ ) -> str:
108
+ from api.auth import get_token
109
+ session_token = x_session_token or token
110
+ if session_token and session_token == get_token():
111
+ return LOCAL_USER_ID
112
+
113
+ if not authorization and token:
114
+ payload = verify_jwt(token.strip())
115
+ return str(payload["user_id"])
116
+
117
+ if not authorization:
118
+ raise _unauthorized("Missing Authorization header")
119
+
120
+ scheme, _, bearer_token = authorization.partition(" ")
121
+ if scheme.lower() != "bearer" or not bearer_token:
122
+ raise _unauthorized("Authorization header must be Bearer token")
123
+
124
+ payload = verify_jwt(bearer_token.strip())
125
+ return str(payload["user_id"])
126
+
127
+
128
+ if __name__ == "__main__":
129
+ print("[AuthMiddleware] Import OK")
api/auth_routes.py ADDED
@@ -0,0 +1,117 @@
1
+ from fastapi import APIRouter, Depends, Header, HTTPException, Request
2
+ from fastapi.responses import RedirectResponse
3
+ from pydantic import BaseModel
4
+
5
+ from api.auth_middleware import get_current_user, register_session, unregister_session
6
+ from config.settings import APP_BASE_URL
7
+ from storage.supabase_client import get_client
8
+
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ class MagicLinkRequest(BaseModel):
14
+ email: str
15
+
16
+
17
+ class VerifyOtpRequest(BaseModel):
18
+ email: str
19
+ token: str
20
+
21
+
22
+ @router.post("/magic-link")
23
+ def magic_link(body: MagicLinkRequest):
24
+ try:
25
+ get_client().auth.sign_in_with_otp({"email": body.email})
26
+ return {"success": True, "message": "check your email"}
27
+ except Exception as e:
28
+ print(f"[AuthRoutes] Magic link failed: {e}")
29
+ return {"success": False, "error": str(e)}
30
+
31
+
32
+ @router.post("/verify-otp")
33
+ def verify_otp(body: VerifyOtpRequest):
34
+ try:
35
+ response = get_client().auth.verify_otp({
36
+ "email": body.email,
37
+ "token": body.token,
38
+ "type": "email",
39
+ })
40
+ session = getattr(response, "session", None)
41
+ user = getattr(response, "user", None)
42
+ access_token = getattr(session, "access_token", "")
43
+ user_id = getattr(user, "id", "")
44
+ if not access_token or not user_id:
45
+ raise HTTPException(status_code=401, detail="Invalid OTP")
46
+ register_session(access_token, str(user_id))
47
+ return {"access_token": access_token, "user_id": str(user_id)}
48
+ except HTTPException:
49
+ raise
50
+ except Exception as e:
51
+ print(f"[AuthRoutes] OTP verification failed: {e}")
52
+ raise HTTPException(status_code=401, detail="Invalid OTP") from e
53
+
54
+
55
+ @router.get("/github")
56
+ def github_login():
57
+ try:
58
+ redirect_to = f"{APP_BASE_URL.rstrip('/')}/auth/callback"
59
+ response = get_client().auth.sign_in_with_oauth({
60
+ "provider": "github",
61
+ "options": {"redirect_to": redirect_to},
62
+ })
63
+ return RedirectResponse(str(response.url))
64
+ except Exception as e:
65
+ print(f"[AuthRoutes] GitHub OAuth failed: {e}")
66
+ raise HTTPException(status_code=500, detail="GitHub OAuth failed") from e
67
+
68
+
69
+ @router.get("/callback")
70
+ def auth_callback(request: Request):
71
+ code = request.query_params.get("code", "")
72
+ code_verifier = request.query_params.get("code_verifier", "")
73
+ if not code:
74
+ raise HTTPException(status_code=400, detail="Missing OAuth code")
75
+
76
+ try:
77
+ response = get_client().auth.exchange_code_for_session({
78
+ "auth_code": code,
79
+ "code_verifier": code_verifier,
80
+ "redirect_to": f"{APP_BASE_URL.rstrip('/')}/auth/callback",
81
+ })
82
+ session = getattr(response, "session", None)
83
+ user = getattr(response, "user", None)
84
+ access_token = getattr(session, "access_token", "")
85
+ refresh_token = getattr(session, "refresh_token", "")
86
+ user_id = getattr(user, "id", "")
87
+ if not access_token:
88
+ raise HTTPException(status_code=401, detail="OAuth exchange failed")
89
+
90
+ register_session(access_token, str(user_id))
91
+ fragment = f"access_token={access_token}&refresh_token={refresh_token}&user_id={user_id}"
92
+ return RedirectResponse(f"/app#{fragment}")
93
+ except HTTPException:
94
+ raise
95
+ except Exception as e:
96
+ print(f"[AuthRoutes] OAuth callback failed: {e}")
97
+ raise HTTPException(status_code=401, detail="OAuth callback failed") from e
98
+
99
+
100
+ @router.post("/logout")
101
+ def logout(
102
+ authorization: str = Header(None),
103
+ user_id: str = Depends(get_current_user),
104
+ ):
105
+ try:
106
+ _, _, token = (authorization or "").partition(" ")
107
+ unregister_session(token)
108
+ if token:
109
+ get_client().auth.admin.sign_out(token, "global")
110
+ return {"success": True}
111
+ except Exception as e:
112
+ print(f"[AuthRoutes] Logout failed for user {user_id}: {e}")
113
+ return {"success": False, "error": str(e)}
114
+
115
+
116
+ if __name__ == "__main__":
117
+ print("[AuthRoutes] Router loaded")
api/monitoring.py ADDED
@@ -0,0 +1,56 @@
1
+ from config.settings import SENTRY_DSN
2
+
3
+
4
+ SENSITIVE_KEYS = {"post_text", "ocr_text", "git_diff", "screenshot_path", "api_key", "key"}
5
+
6
+
7
+ def _strip_sensitive(value):
8
+ if isinstance(value, dict):
9
+ return {
10
+ key: ("[redacted]" if key in SENSITIVE_KEYS else _strip_sensitive(item))
11
+ for key, item in value.items()
12
+ }
13
+ if isinstance(value, list):
14
+ return [_strip_sensitive(item) for item in value]
15
+ return value
16
+
17
+
18
+ def init_sentry() -> None:
19
+ if not SENTRY_DSN:
20
+ print("[Monitoring] Sentry not configured - skipping")
21
+ return
22
+
23
+ try:
24
+ import sentry_sdk
25
+
26
+ def before_send(event, hint):
27
+ return _strip_sensitive(event)
28
+
29
+ sentry_sdk.init(
30
+ dsn=SENTRY_DSN,
31
+ traces_sample_rate=0.2,
32
+ profiles_sample_rate=0.1,
33
+ environment="production",
34
+ before_send=before_send,
35
+ )
36
+ print("[Monitoring] Sentry configured")
37
+ except Exception as e:
38
+ print(f"[Monitoring] Sentry init failed: {e}")
39
+
40
+
41
+ def capture_error(error: Exception, context: dict = {}) -> None:
42
+ safe_context = _strip_sensitive(context or {})
43
+ print(f"[Error] {error}")
44
+ try:
45
+ import sentry_sdk
46
+
47
+ with sentry_sdk.push_scope() as scope:
48
+ for key, value in safe_context.items():
49
+ scope.set_context(key, value if isinstance(value, dict) else {"value": value})
50
+ sentry_sdk.capture_exception(error)
51
+ except Exception:
52
+ return
53
+
54
+
55
+ if __name__ == "__main__":
56
+ init_sentry()