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.
- server/__init__.py +0 -0
- server/apns.py +89 -0
- server/app.py +589 -0
- server/appattest.py +310 -0
- server/appstore.py +141 -0
- server/attested_store.py +60 -0
- server/auth.py +70 -0
- server/ax_controller.py +202 -0
- server/billing.py +177 -0
- server/call_manager.py +91 -0
- server/certs/AppleRootCA-G3.pem +15 -0
- server/certs/Apple_App_Attestation_Root_CA.pem +14 -0
- server/claude_controller.py +156 -0
- server/cli.py +365 -0
- server/cloud_app.py +345 -0
- server/config.py +56 -0
- server/device_registry.py +52 -0
- server/gemini_operator.py +677 -0
- server/hooks.py +202 -0
- server/orchestrator.py +315 -0
- server/push_routes.py +50 -0
- server/ratelimit.py +41 -0
- server/relay.py +157 -0
- server/relay_client.py +89 -0
- server/remote_operator.py +128 -0
- server/session_hub.py +33 -0
- server/terminal_watcher.py +241 -0
- server/terminals.py +510 -0
- server/tmux_controller.py +580 -0
- server/transcript_monitor.py +134 -0
- server/transcripts.py +143 -0
- server/users.py +90 -0
- server/voxa_cloud.py +132 -0
- server/waitlist.py +130 -0
- static/app.js +388 -0
- static/favicon.svg +1 -0
- static/index.html +253 -0
- static/pcm-worklet.js +69 -0
- static/pro.html +29 -0
- static/pro2.html +33 -0
- static/voxa-mark-white.svg +1 -0
- voxa_code-0.1.0.dist-info/METADATA +227 -0
- voxa_code-0.1.0.dist-info/RECORD +47 -0
- voxa_code-0.1.0.dist-info/WHEEL +5 -0
- voxa_code-0.1.0.dist-info/entry_points.txt +2 -0
- voxa_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- voxa_code-0.1.0.dist-info/top_level.txt +2 -0
|
@@ -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}
|