voxa-code 0.1.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.
@@ -0,0 +1,134 @@
1
+ """Screen-independent Claude status detection from its own transcript JSONL.
2
+
3
+ For terminals whose screen we cannot capture (GPU terminals behind the ax
4
+ backend), the transcript under ~/.claude/projects/<encoded-cwd>/ is the source
5
+ of truth: growing file means working; quiet file ending in an assistant text
6
+ message means done; quiet file ending mid tool-call means Claude is likely
7
+ waiting on something (permission prompts never reach the transcript, so this
8
+ is a heuristic).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import inspect
15
+ import json
16
+ import os
17
+ import time
18
+ from typing import Optional
19
+
20
+ from .transcripts import PROJECTS_DIR, latest_transcript, _text_of
21
+ from .tmux_controller import FinalCallback
22
+
23
+
24
+ def _last_entry(path: str) -> Optional[dict]:
25
+ last = None
26
+ try:
27
+ with open(path) as f:
28
+ for line in f:
29
+ try:
30
+ o = json.loads(line)
31
+ except ValueError:
32
+ continue
33
+ if o.get("type") in ("user", "assistant"):
34
+ last = o
35
+ except OSError:
36
+ return None
37
+ return last
38
+
39
+
40
+ def transcript_state(path: str, quiet_secs: float = 5.0,
41
+ now: float | None = None) -> tuple[str, str]:
42
+ """Classify a transcript: ("working"|"done"|"needs_input"|"none", text)."""
43
+ try:
44
+ mtime = os.path.getmtime(path)
45
+ except OSError:
46
+ return "none", ""
47
+ if (now or time.time()) - mtime < quiet_secs:
48
+ return "working", ""
49
+ o = _last_entry(path)
50
+ if o is None:
51
+ return "none", ""
52
+ m = o.get("message") or {}
53
+ if o.get("type") == "assistant":
54
+ content = m.get("content")
55
+ blocks = content if isinstance(content, list) else []
56
+ has_text = isinstance(content, str) or any(
57
+ isinstance(b, dict) and b.get("type") == "text" and b.get("text")
58
+ for b in blocks)
59
+ if has_text:
60
+ return "done", _text_of(content).strip()
61
+ return "needs_input", "Claude stopped mid tool call"
62
+ return "needs_input", _text_of(m.get("content")).strip()[:200]
63
+
64
+
65
+ class TranscriptMonitor:
66
+ """Same emit contract as the screen monitors: after the session has WORKED
67
+ (mtime advanced since attach) and then gone quiet, fire on_final once."""
68
+
69
+ def __init__(self, cwd: str, on_final: Optional[FinalCallback] = None, *,
70
+ poll_interval: float = 2.0, quiet_secs: float = 5.0,
71
+ projects_dir: str = PROJECTS_DIR):
72
+ self._cwd = cwd
73
+ self._final_cb = on_final
74
+ self._poll = poll_interval
75
+ self._quiet = quiet_secs
76
+ self._projects = projects_dir
77
+ self.status = "idle"
78
+ self.working_dir: Optional[str] = cwd
79
+ self._started = False
80
+ self._task: Optional[asyncio.Task] = None
81
+
82
+ def on_final(self, cb: FinalCallback) -> None:
83
+ self._final_cb = cb
84
+
85
+ async def _emit(self, text: str) -> None:
86
+ if text.strip() and self._final_cb is not None:
87
+ result = self._final_cb(text)
88
+ if inspect.isawaitable(result):
89
+ await result
90
+
91
+ async def run(self) -> None:
92
+ self._started = True
93
+ path = latest_transcript(self._cwd, self._projects)
94
+ baseline = 0.0
95
+ if path:
96
+ try:
97
+ baseline = os.path.getmtime(path)
98
+ except OSError:
99
+ baseline = 0.0
100
+ saw_work = False
101
+ while self._started:
102
+ await asyncio.sleep(self._poll)
103
+ path = latest_transcript(self._cwd, self._projects)
104
+ if not path:
105
+ continue
106
+ kind, text = transcript_state(path, self._quiet)
107
+ try:
108
+ mtime = os.path.getmtime(path)
109
+ except OSError:
110
+ continue
111
+ if mtime > baseline:
112
+ baseline = mtime
113
+ saw_work = True
114
+ self.status = "working"
115
+ continue
116
+ if saw_work and kind in ("done", "needs_input"):
117
+ saw_work = False
118
+ self.status = "idle"
119
+ prefix = "needs input: " if kind == "needs_input" else ""
120
+ await self._emit(prefix + text if text else prefix.strip())
121
+
122
+ async def start(self, working_dir: Optional[str] = None) -> None:
123
+ if working_dir:
124
+ self.working_dir = working_dir
125
+ self._cwd = working_dir
126
+ if self._task and not self._task.done():
127
+ self._task.cancel()
128
+ self._task = asyncio.ensure_future(self.run())
129
+
130
+ async def stop(self, *, detach_only: bool = False) -> None:
131
+ self._started = False
132
+ if self._task and not self._task.done():
133
+ self._task.cancel()
134
+ self.status = "idle"
server/transcripts.py ADDED
@@ -0,0 +1,143 @@
1
+ """Recap what a terminal was working on by reading Claude Code's own transcript.
2
+
3
+ Claude Code logs each session to ``~/.claude/projects/<encoded-cwd>/<session>.jsonl``
4
+ where the cwd has ``/`` and ``.`` replaced by ``-``. Discovery already knows each
5
+ open terminal's cwd, so on attach we read the newest transcript for that cwd and
6
+ build a short recap of the recent conversation, instead of scraping the screen.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import glob
12
+ import json
13
+ import os
14
+ import re
15
+
16
+ PROJECTS_DIR = os.path.expanduser("~/.claude/projects")
17
+
18
+
19
+ def _encode(cwd: str) -> str:
20
+ return re.sub(r"[/.]", "-", cwd)
21
+
22
+
23
+ def latest_transcript(cwd: str, projects_dir: str = PROJECTS_DIR) -> str | None:
24
+ if not cwd:
25
+ return None
26
+ d = os.path.join(projects_dir, _encode(cwd))
27
+ files = glob.glob(os.path.join(d, "*.jsonl")) if os.path.isdir(d) else []
28
+ if not files:
29
+ return None
30
+ return max(files, key=os.path.getmtime)
31
+
32
+
33
+ def _text_of(content) -> str:
34
+ if isinstance(content, str):
35
+ return content
36
+ if isinstance(content, list):
37
+ parts = []
38
+ for b in content:
39
+ if isinstance(b, dict):
40
+ if b.get("type") == "text" and b.get("text"):
41
+ parts.append(b["text"])
42
+ elif b.get("type") == "tool_use" and b.get("name"):
43
+ parts.append(f"[used {b['name']}]")
44
+ return " ".join(parts)
45
+ return ""
46
+
47
+
48
+ def recap(cwd: str, max_msgs: int = 25, max_chars: int = 500,
49
+ projects_dir: str = PROJECTS_DIR) -> str:
50
+ """Return a short text recap of the most recent conversation in ``cwd``'s
51
+ Claude session, or "" if there is no transcript."""
52
+ path = latest_transcript(cwd, projects_dir)
53
+ if not path:
54
+ return ""
55
+ msgs: list[tuple[str, str]] = []
56
+ try:
57
+ with open(path) as f:
58
+ for line in f:
59
+ try:
60
+ o = json.loads(line)
61
+ except ValueError:
62
+ continue
63
+ if o.get("type") not in ("user", "assistant"):
64
+ continue
65
+ m = o.get("message") or {}
66
+ role = m.get("role") or o.get("type")
67
+ text = _text_of(m.get("content")).strip()
68
+ if text:
69
+ msgs.append((role, text))
70
+ except OSError:
71
+ return ""
72
+ if not msgs:
73
+ return ""
74
+ lines = []
75
+ for role, text in msgs[-max_msgs:]:
76
+ if len(text) > max_chars:
77
+ text = text[:max_chars] + "…"
78
+ who = "You" if role == "user" else "Claude"
79
+ lines.append(f"{who}: {text}")
80
+ opener = next((t for r, t in msgs if r == "user"), "")
81
+ if opener:
82
+ if len(opener) > max_chars:
83
+ opener = opener[:max_chars] + "…"
84
+ lines.insert(0, f"This session started with: {opener}")
85
+ return "\n".join(lines)
86
+
87
+
88
+ def _collect_messages(path: str) -> list[dict]:
89
+ msgs: list[dict] = []
90
+ try:
91
+ with open(path) as f:
92
+ for line in f:
93
+ try:
94
+ o = json.loads(line)
95
+ except ValueError:
96
+ continue
97
+ if o.get("type") not in ("user", "assistant"):
98
+ continue
99
+ m = o.get("message") or {}
100
+ text = _text_of(m.get("content")).strip()
101
+ if text:
102
+ msgs.append({"role": m.get("role") or o.get("type"),
103
+ "text": text})
104
+ except OSError:
105
+ return []
106
+ return msgs
107
+
108
+
109
+ def read_session(cwd: str, last: int | None = None, search: str | None = None,
110
+ projects_dir: str = PROJECTS_DIR,
111
+ max_bytes: int = 6000) -> dict:
112
+ """On-demand deep read of the newest transcript for ``cwd``: the voice
113
+ agent's read_session tool. Returns {"messages": [{"role","text"}...]}."""
114
+ path = latest_transcript(cwd, projects_dir)
115
+ if not path:
116
+ return {"error": f"no Claude transcript found for {cwd or '(no cwd)'}"}
117
+ msgs = _collect_messages(path)
118
+ if search:
119
+ picked: list[int] = []
120
+ hits = 0
121
+ for i, m in enumerate(msgs):
122
+ if search.lower() in m["text"].lower():
123
+ hits += 1
124
+ for j in (i - 1, i, i + 1): # hit plus one neighbour each side
125
+ if 0 <= j < len(msgs) and j not in picked:
126
+ picked.append(j)
127
+ if hits >= 10:
128
+ break
129
+ out = [msgs[i] for i in sorted(picked)]
130
+ else:
131
+ n = min(int(last or 10), 40)
132
+ out = msgs[-n:]
133
+ # Cap the payload: trim message texts evenly until the JSON fits, accounting
134
+ # for the per-entry JSON overhead (keys, quotes, commas), not just the text.
135
+ if not out:
136
+ return {"messages": []}
137
+ overhead = len(json.dumps({"role": "assistant", "text": ""}).encode()) + 2
138
+ per = max(50, (max_bytes - overhead * len(out)) // len(out))
139
+ result = [{"role": m["role"], "text": m["text"][:per]} for m in out]
140
+ # Safety net for any remaining overshoot (e.g. multi-byte unicode text).
141
+ while result and len(json.dumps({"messages": result}).encode()) > max_bytes:
142
+ result.pop()
143
+ return {"messages": result}
server/users.py ADDED
@@ -0,0 +1,90 @@
1
+ """Voxa user accounts + session tokens (Sign in with Apple).
2
+
3
+ JSON-backed (like Billing/DeviceRegistry). A user is keyed by Apple's stable
4
+ per-user id (`apple_sub`) and assigned a stable Voxa `user_id` (uuid4 hex) that
5
+ becomes the billing account. Session tokens are signed JWTs the app stores and
6
+ sends as a Bearer header.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import threading
14
+ import time
15
+ import uuid
16
+
17
+ import jwt # PyJWT
18
+
19
+ TOKEN_TTL_SECONDS = 365 * 24 * 3600 # 1 year; no refresh tokens in v1.
20
+
21
+
22
+ class UserStore:
23
+ def __init__(self, path: str = "users.json"):
24
+ self._path = path
25
+ self._lock = threading.Lock()
26
+ self._data: dict = self._load()
27
+
28
+ def _load(self) -> dict:
29
+ try:
30
+ with open(self._path) as f:
31
+ return json.load(f)
32
+ except (OSError, ValueError):
33
+ return {}
34
+
35
+ def _save(self) -> None:
36
+ tmp = f"{self._path}.tmp"
37
+ with open(tmp, "w") as f:
38
+ json.dump(self._data, f)
39
+ os.replace(tmp, self._path)
40
+
41
+ def find_or_create_apple_user(self, apple_sub: str, email: str | None = None) -> str:
42
+ """Return the stable user_id for an Apple sub, creating it on first sign-in.
43
+ Stores email only when provided (Apple returns it on the first authorization
44
+ only) and never overwrites a stored email with None."""
45
+ with self._lock:
46
+ users = self._data.setdefault("users", {})
47
+ by_apple = self._data.setdefault("by_apple", {})
48
+ uid = by_apple.get(apple_sub)
49
+ if uid is None:
50
+ uid = uuid.uuid4().hex
51
+ by_apple[apple_sub] = uid
52
+ users[uid] = {"apple_sub": apple_sub, "email": email,
53
+ "created_at": int(time.time())}
54
+ elif email and not users[uid].get("email"):
55
+ users[uid]["email"] = email
56
+ self._save()
57
+ return uid
58
+
59
+ def get_user(self, user_id: str) -> dict | None:
60
+ with self._lock:
61
+ u = self._data.get("users", {}).get(user_id)
62
+ return dict(u) if u else None
63
+
64
+ def delete_user(self, user_id: str) -> bool:
65
+ """Permanently remove a user and its Apple-sub mapping (account deletion,
66
+ App Review Guideline 5.1.1(v)). Returns True if a user was removed."""
67
+ with self._lock:
68
+ users = self._data.setdefault("users", {})
69
+ u = users.pop(user_id, None)
70
+ if u is None:
71
+ return False
72
+ by_apple = self._data.setdefault("by_apple", {})
73
+ for sub, uid in list(by_apple.items()):
74
+ if uid == user_id:
75
+ del by_apple[sub]
76
+ self._save()
77
+ return True
78
+
79
+
80
+ def issue_token(user_id: str, secret: str, now: int | None = None) -> str:
81
+ now = int(time.time()) if now is None else now
82
+ return jwt.encode({"sub": user_id, "iat": now, "exp": now + TOKEN_TTL_SECONDS},
83
+ secret, algorithm="HS256")
84
+
85
+
86
+ def verify_token(token: str, secret: str) -> str | None:
87
+ try:
88
+ return jwt.decode(token, secret, algorithms=["HS256"]).get("sub")
89
+ except Exception:
90
+ return None
server/voxa_cloud.py ADDED
@@ -0,0 +1,132 @@
1
+ """Voxa Cloud — the single service YOU host.
2
+
3
+ Combines everything central into one deployable app:
4
+ - relay (/agent, /ws) : pairs phone <-> laptop, no Tailscale
5
+ - billing (/billing/balance, /purchase) + metered V2V proxy (/live)
6
+ - push/CallKit (/register, /unregister, /call/decline)
7
+
8
+ Run it:
9
+ uvicorn server.voxa_cloud:create_app --factory --host 0.0.0.0 --port 8080
10
+
11
+ Your keys (GEMINI_API_KEY, APNS_*) live here, never on customers' laptops.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+
18
+ from fastapi import FastAPI, Request
19
+
20
+ from server.appstore import verify_transaction
21
+ from server.cloud_app import add_billing_routes
22
+ from server.device_registry import DeviceRegistry
23
+ from server.call_manager import CallManager
24
+ from server.push_routes import add_push_routes
25
+ from server.relay import add_relay_routes
26
+
27
+
28
+ def create_app(billing=None, verifier=verify_transaction, operator_factory=None,
29
+ registry=None, call_manager=None, users=None,
30
+ apple_verifier=None) -> FastAPI:
31
+ import os
32
+
33
+ from dotenv import load_dotenv
34
+ from server.config import load_config
35
+
36
+ load_dotenv()
37
+ config = load_config() # needs GEMINI_API_KEY (yours) + VOXA_AUTH_TOKEN
38
+
39
+ app = FastAPI(title="Voxa Cloud")
40
+
41
+ # Reject oversized request bodies early (memory-DoS guard). Generous cap: every
42
+ # real request here (auth, purchase JWS, waitlist, hook) is a few KB; the
43
+ # metered /live is a WebSocket and is unaffected.
44
+ from starlette.middleware.base import BaseHTTPMiddleware
45
+ from starlette.responses import JSONResponse as _JSONResponse
46
+ _MAX_BODY = 1_000_000 # 1 MB
47
+
48
+ class _BodyLimit(BaseHTTPMiddleware):
49
+ async def dispatch(self, request, call_next):
50
+ cl = request.headers.get("content-length")
51
+ if cl and cl.isdigit() and int(cl) > _MAX_BODY:
52
+ return _JSONResponse({"error": "payload too large"}, status_code=413)
53
+ return await call_next(request)
54
+
55
+ app.add_middleware(_BodyLimit)
56
+
57
+ # Browsers on the marketing site call /waitlist cross-origin; allow only
58
+ # voxa.space. Other routes are hit by the iOS app (no Origin), so this is a
59
+ # no-op for them.
60
+ from fastapi.middleware.cors import CORSMiddleware
61
+ app.add_middleware(
62
+ CORSMiddleware,
63
+ allow_origins=["https://voxa.space", "https://www.voxa.space"],
64
+ allow_methods=["GET", "POST", "OPTIONS"],
65
+ allow_headers=["Content-Type"],
66
+ )
67
+
68
+ @app.get("/healthz")
69
+ async def healthz():
70
+ return {"ok": True}
71
+
72
+ auth_secret = os.environ.get("VOXA_AUTH_SECRET", "").strip()
73
+ if not auth_secret:
74
+ raise ValueError("VOXA_AUTH_SECRET is required (HS256 session-token secret)")
75
+ # A weak session-signing secret lets anyone forge account tokens. Refuse to
76
+ # start on an obviously-weak secret; warn on a merely-short one. Use >= 32 random
77
+ # chars (e.g. `python -c "import secrets;print(secrets.token_hex(32))"`).
78
+ if len(auth_secret) < 16:
79
+ raise ValueError("VOXA_AUTH_SECRET is too weak; use at least 32 random characters")
80
+ if len(auth_secret) < 32:
81
+ logging.warning("VOXA_AUTH_SECRET is short (<32 chars); use a longer random secret")
82
+ apple_bundle = (os.environ.get("VOXA_APPLE_BUNDLE_ID")
83
+ or os.environ.get("APNS_BUNDLE_ID") or "space.voxa.app")
84
+
85
+ # Billing + metered V2V proxy (uses your GEMINI_API_KEY).
86
+ add_billing_routes(app, billing=billing, verifier=verifier,
87
+ operator_factory=operator_factory, auth_secret=auth_secret)
88
+
89
+ # Sign in with Apple: user accounts + session tokens.
90
+ from server.auth import add_auth_routes, verify_apple_identity_token
91
+ from server.users import UserStore
92
+ users = users or UserStore(os.environ.get("VOXA_USERS_FILE", "users.json"))
93
+ add_auth_routes(app, users, secret=auth_secret, bundle_id=apple_bundle,
94
+ apple_verifier=apple_verifier or verify_apple_identity_token,
95
+ billing=billing)
96
+ app.state.users = users
97
+
98
+ # Relay: phone <-> laptop by pairing code.
99
+ add_relay_routes(app)
100
+
101
+ # Push / CallKit (your APNs key stays here).
102
+ registry = registry or DeviceRegistry(os.environ.get("VOXA_DEVICES_FILE", "devices.json"))
103
+ if call_manager is None:
104
+ if config.push_enabled:
105
+ from server.apns import ApnsClient
106
+ pusher = ApnsClient(config)
107
+ else:
108
+ class _NoPush:
109
+ async def send_voip(self, *a, **k):
110
+ logging.warning("push disabled; dropping call %r", a)
111
+ return False
112
+ pusher = _NoPush()
113
+ call_manager = CallManager(pusher, registry)
114
+ app.state.registry = registry
115
+ app.state.call_manager = call_manager
116
+
117
+ def auth_check(request: Request):
118
+ return request.query_params.get("token") == config.auth_token
119
+
120
+ add_push_routes(app, registry, call_manager, auth_check)
121
+
122
+ # Marketing-site waitlist: append signups to a durable JSONL; GET is gated
123
+ # by the same VOXA_AUTH_TOKEN admin secret. If RESEND_API_KEY +
124
+ # WAITLIST_NOTIFY_EMAIL are set, each new signup also emails a heads-up.
125
+ from server.waitlist import WaitlistStore, ResendNotifier, add_waitlist_routes
126
+ waitlist = WaitlistStore(os.environ.get("VOXA_WAITLIST_FILE", "waitlist.jsonl"))
127
+ notifier = ResendNotifier(
128
+ api_key=os.environ.get("RESEND_API_KEY", ""),
129
+ to_email=os.environ.get("WAITLIST_NOTIFY_EMAIL", ""),
130
+ )
131
+ add_waitlist_routes(app, waitlist, admin_token=config.auth_token, notifier=notifier)
132
+ return app
server/waitlist.py ADDED
@@ -0,0 +1,130 @@
1
+ """Waitlist signups for the marketing site (voxa.space).
2
+
3
+ The static site POSTs an email here; we append it to a JSONL file in the
4
+ service's working dir (durable on the Oracle box, alongside users.json /
5
+ devices.json). A token-gated GET dumps the list so you can read who joined.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import re
13
+ import time
14
+ from pathlib import Path
15
+
16
+ import httpx
17
+ from fastapi import Request
18
+ from fastapi.responses import JSONResponse
19
+
20
+ _EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
21
+
22
+
23
+ def valid_email(email: str) -> bool:
24
+ email = (email or "").strip()
25
+ return 0 < len(email) <= 320 and bool(_EMAIL_RE.match(email))
26
+
27
+
28
+ class WaitlistStore:
29
+ """Append-only JSONL of signups, deduped by lowercased email."""
30
+
31
+ def __init__(self, path: str):
32
+ self._path = Path(path)
33
+
34
+ def emails(self) -> list[str]:
35
+ if not self._path.exists():
36
+ return []
37
+ out: list[str] = []
38
+ for line in self._path.read_text().splitlines():
39
+ line = line.strip()
40
+ if not line:
41
+ continue
42
+ try:
43
+ out.append(json.loads(line)["email"])
44
+ except (json.JSONDecodeError, KeyError):
45
+ continue
46
+ return out
47
+
48
+ def add(self, email: str, *, source: str = "", now: float | None = None) -> bool:
49
+ """Record an email. Returns True if newly added, False if a duplicate."""
50
+ email = email.strip().lower()
51
+ if email in set(self.emails()):
52
+ return False
53
+ rec = {
54
+ "email": email,
55
+ "ts": time.time() if now is None else now,
56
+ "source": source,
57
+ }
58
+ self._path.parent.mkdir(parents=True, exist_ok=True)
59
+ with self._path.open("a") as f:
60
+ f.write(json.dumps(rec) + "\n")
61
+ return True
62
+
63
+
64
+ def build_signup_email(email: str, to_email: str,
65
+ from_email: str = "Voxa <onboarding@resend.dev>",
66
+ count: int | None = None) -> dict:
67
+ lines = [f"{email} just joined the Voxa waitlist."]
68
+ if count is not None:
69
+ lines.append(f"\nTotal signups so far: {count}.")
70
+ return {
71
+ "from": from_email,
72
+ "to": [to_email],
73
+ "subject": f"New Voxa waitlist signup: {email}",
74
+ "text": "\n".join(lines),
75
+ }
76
+
77
+
78
+ class ResendNotifier:
79
+ """Best-effort 'new signup' email via Resend's HTTP API. A no-op unless
80
+ both api_key and to_email are set, so it's safe to wire in before configured."""
81
+
82
+ def __init__(self, api_key: str = "", to_email: str = "",
83
+ from_email: str = "Voxa <onboarding@resend.dev>"):
84
+ self.api_key = (api_key or "").strip()
85
+ self.to_email = (to_email or "").strip()
86
+ self.from_email = from_email
87
+
88
+ @property
89
+ def enabled(self) -> bool:
90
+ return bool(self.api_key and self.to_email)
91
+
92
+ async def notify(self, email: str, count: int | None = None) -> bool:
93
+ if not self.enabled:
94
+ return False
95
+ payload = build_signup_email(email, self.to_email, self.from_email, count)
96
+ async with httpx.AsyncClient(timeout=10) as client:
97
+ resp = await client.post(
98
+ "https://api.resend.com/emails",
99
+ headers={"Authorization": f"Bearer {self.api_key}",
100
+ "Content-Type": "application/json"},
101
+ content=json.dumps(payload),
102
+ )
103
+ return resp.status_code < 300
104
+
105
+
106
+ def add_waitlist_routes(app, store: WaitlistStore, admin_token: str = "",
107
+ notifier=None) -> None:
108
+ @app.post("/waitlist")
109
+ async def join_waitlist(request: Request):
110
+ body = await request.json() or {}
111
+ email = body.get("email", "")
112
+ if not valid_email(email):
113
+ return JSONResponse({"ok": False, "reason": "invalid"}, status_code=400)
114
+ # Bound the client-supplied source so a caller can't write huge values into
115
+ # the durable JSONL (disk-fill vector); it is untrusted free text.
116
+ source = str(body.get("source") or "marketing-site")[:64]
117
+ added = store.add(email, source=source)
118
+ if added and notifier is not None:
119
+ try:
120
+ await notifier.notify(email.strip().lower(), count=len(store.emails()))
121
+ except Exception:
122
+ logging.warning("waitlist notify failed", exc_info=True)
123
+ return {"ok": True}
124
+
125
+ @app.get("/waitlist")
126
+ async def list_waitlist(request: Request):
127
+ if not admin_token or request.query_params.get("token") != admin_token:
128
+ return JSONResponse({"ok": False, "reason": "forbidden"}, status_code=403)
129
+ emails = store.emails()
130
+ return {"ok": True, "count": len(emails), "emails": emails}