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/cloud_app.py ADDED
@@ -0,0 +1,345 @@
1
+ """Voxa Cloud: the central billing API (and home of the metered V2V proxy).
2
+
3
+ This is the service YOU host (not the customer's laptop), because it holds the
4
+ minute balances and, for the metered proxy, your Gemini key. Endpoints:
5
+ GET /billing/balance?account=... -> remaining minutes
6
+ POST /billing/purchase?account=... {jws} -> verify StoreKit2 txn, credit
7
+ GET /healthz
8
+
9
+ The metered Gemini proxy (/live) lives alongside this in production; see RUNBOOK.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import binascii
16
+ import contextlib
17
+ import json
18
+ import logging
19
+ import os
20
+ import secrets
21
+ import time
22
+ import uuid
23
+
24
+ from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
25
+ from fastapi.responses import JSONResponse
26
+
27
+ from server.appattest import verify_attestation
28
+ from server.appstore import verify_transaction
29
+ from server.attested_store import AttestedStore
30
+ from server.auth import bearer_user
31
+ from server.billing import PRODUCT_MINUTES, Billing, MeteredSession
32
+
33
+
34
+ def _default_cloud_operator_factory(account, handle_tool_call, voice=""):
35
+ """The proxy's V2V brain: GeminiOperator using the CLOUD's key (never the
36
+ customer's). Tool calls are RPC'd back to the laptop via handle_tool_call."""
37
+ from types import SimpleNamespace
38
+ from server.gemini_operator import GeminiOperator
39
+ cfg = SimpleNamespace(
40
+ gemini_api_key=os.environ["GEMINI_API_KEY"],
41
+ gemini_live_model=os.environ.get("GEMINI_LIVE_MODEL", "gemini-3.1-flash-live-preview"),
42
+ )
43
+ return GeminiOperator(cfg, handle_tool_call, voice=voice)
44
+
45
+
46
+ def add_billing_routes(app: FastAPI, billing: Billing | None = None,
47
+ verifier=verify_transaction, operator_factory=None,
48
+ auth_secret: str | None = None,
49
+ attested_store: AttestedStore | None = None,
50
+ attest_verifier=verify_attestation) -> Billing:
51
+ """Register billing + metered /live routes on an existing app. Returns the
52
+ Billing instance (so the combined app can share it). Bearer is required for
53
+ all accounts except anonymous device accounts (ids starting with 'd-'), which
54
+ may use ?account=<id> (balance) or body account field (purchase) without a token.
55
+
56
+ App Attest (device attestation) is added behind VOXA_REQUIRE_ATTESTATION. When
57
+ that flag is OFF (default) behavior is unchanged. When ON, a d- account must
58
+ have registered a verified App Attest key before it can mint a free trial
59
+ (balance) or open a metered session (/live)."""
60
+ billing = billing or Billing(os.environ.get("VOXA_BILLING_FILE", "billing.json"))
61
+ bundle = os.environ.get("APNS_BUNDLE_ID", "").strip()
62
+ proxy_token = os.environ.get("VOXA_PROXY_TOKEN", "").strip()
63
+ auth_secret = auth_secret if auth_secret is not None else os.environ.get("VOXA_AUTH_SECRET", "")
64
+ operator_factory = operator_factory or _default_cloud_operator_factory
65
+ app.state.billing = billing
66
+
67
+ # --- App Attest: device binding (feature-flagged; off => no behavior change) ---
68
+ attested_store = attested_store or AttestedStore()
69
+ app.state.attested_store = attested_store
70
+ require_attestation = os.environ.get("VOXA_REQUIRE_ATTESTATION", "").strip().lower() in (
71
+ "1", "true", "yes", "on")
72
+ # app_id for App Attest is "<TeamID>.<BundleID>"; rpIdHash = sha256(app_id).
73
+ attest_app_id = os.environ.get("VOXA_APPATTEST_APP_ID", "").strip()
74
+ _attest_allow_dev = os.environ.get("VOXA_APPATTEST_ALLOW_DEV", "1").strip().lower() in (
75
+ "1", "true", "yes", "on")
76
+ _challenge_ttl = float(os.environ.get("VOXA_ATTEST_CHALLENGE_TTL", "300"))
77
+ _challenges: dict[str, float] = {} # hex challenge -> issued monotonic time
78
+
79
+ def _issue_challenge() -> str:
80
+ now = time.monotonic()
81
+ # Opportunistically drop expired challenges so the set stays bounded.
82
+ for k in [k for k, t in _challenges.items() if now - t > _challenge_ttl]:
83
+ _challenges.pop(k, None)
84
+ ch = secrets.token_hex(32)
85
+ _challenges[ch] = now
86
+ return ch
87
+
88
+ def _consume_challenge(ch: str) -> bool:
89
+ """Single-use: accept a still-valid challenge exactly once."""
90
+ issued = _challenges.pop(ch, None)
91
+ return issued is not None and (time.monotonic() - issued) <= _challenge_ttl
92
+
93
+ def _attested_ok(account: str) -> bool:
94
+ # When attestation is required, a d- account must have a bound key. Non-d-
95
+ # accounts (signed-in users) are unaffected. Flag off => always True.
96
+ if not require_attestation or not account.startswith("d-"):
97
+ return True
98
+ return attested_store.account_is_attested(account)
99
+
100
+ # Throttle creation of NEW anonymous (d-) accounts, i.e. the free-trial grant,
101
+ # so random ids can't farm free minutes or open /live at will. Existing accounts
102
+ # and signed-in users are never throttled. (Full per-device binding is App
103
+ # Attest; see SECURITY.md. This bounds the abuse in the meantime.)
104
+ from server.ratelimit import SlidingWindowLimiter
105
+ _WINDOW = 3600.0
106
+ _per_ip = SlidingWindowLimiter(int(os.environ.get("VOXA_NEW_ACCT_PER_IP", "5")), _WINDOW)
107
+ _global = SlidingWindowLimiter(int(os.environ.get("VOXA_NEW_ACCT_GLOBAL", "200")), _WINDOW)
108
+
109
+ def _client_ip(conn) -> str:
110
+ xff = conn.headers.get("x-forwarded-for")
111
+ if xff:
112
+ return xff.split(",")[0].strip()
113
+ return conn.client.host if conn.client else "unknown"
114
+
115
+ def _allow_new_anon(account: str, conn) -> bool:
116
+ # Gates ONLY the first-touch that would mint a new anonymous trial account.
117
+ if not account.startswith("d-") or billing.exists(account):
118
+ return True
119
+ return _per_ip.allow(_client_ip(conn)) and _global.allow("*")
120
+
121
+ @app.get("/billing/balance")
122
+ async def balance(request: Request):
123
+ account = bearer_user(request, auth_secret)
124
+ if not account:
125
+ anon = request.query_params.get("account", "")
126
+ if anon.startswith("d-"):
127
+ account = anon
128
+ else:
129
+ return JSONResponse({"error": "unauthorized"}, status_code=401)
130
+ # A brand-new anonymous account over the creation limit: report 0 without
131
+ # minting a free trial (throttles free-minute farming).
132
+ if not _allow_new_anon(account, request):
133
+ return {"account": account, "minutes": 0.0}
134
+ # With attestation required, an unattested device account gets no trial.
135
+ if not _attested_ok(account):
136
+ return {"account": account, "minutes": 0.0}
137
+ return {"account": account, "minutes": billing.balance_minutes(account)}
138
+
139
+ @app.post("/attest/challenge")
140
+ async def attest_challenge():
141
+ """Issue a single-use, short-TTL challenge for App Attest. No auth needed:
142
+ the challenge is meaningless without a valid Secure Enclave attestation."""
143
+ return {"challenge": _issue_challenge()}
144
+
145
+ @app.post("/attest/register")
146
+ async def attest_register(request: Request):
147
+ """Bind an App-Attested Secure Enclave key to a d- account.
148
+
149
+ Body: {account, key_id(hex), attestation(base64), challenge(hex)}.
150
+ Verifies the attestation with real crypto; on success stores the device's
151
+ public key so the account counts as attested."""
152
+ body = await request.json()
153
+ account = (body or {}).get("account", "")
154
+ key_id_hex = (body or {}).get("key_id", "")
155
+ attestation_b64 = (body or {}).get("attestation", "")
156
+ challenge_hex = (body or {}).get("challenge", "")
157
+ if not account.startswith("d-"):
158
+ return JSONResponse({"error": "invalid account"}, status_code=400)
159
+ if not (key_id_hex and attestation_b64 and challenge_hex):
160
+ return JSONResponse({"error": "missing fields"}, status_code=400)
161
+ if not attest_app_id:
162
+ logger.error("App Attest: VOXA_APPATTEST_APP_ID not configured; cannot register")
163
+ return JSONResponse({"error": "attestation unavailable"}, status_code=400)
164
+ if not _consume_challenge(challenge_hex):
165
+ return JSONResponse({"error": "unknown or expired challenge"}, status_code=400)
166
+ import base64
167
+ try:
168
+ key_id = binascii.unhexlify(key_id_hex)
169
+ challenge = binascii.unhexlify(challenge_hex)
170
+ attestation = base64.b64decode(attestation_b64)
171
+ except Exception:
172
+ return JSONResponse({"error": "malformed input"}, status_code=400)
173
+ result = attest_verifier(attestation, key_id, challenge, attest_app_id,
174
+ allow_dev=_attest_allow_dev)
175
+ if not result:
176
+ return JSONResponse({"error": "attestation failed"}, status_code=400)
177
+ attested_store.put(key_id_hex, account, result["public_key_pem"],
178
+ result["counter"])
179
+ return {"ok": True}
180
+
181
+ @app.post("/auth/merge")
182
+ async def merge(request: Request):
183
+ account = bearer_user(request, auth_secret)
184
+ if not account:
185
+ return JSONResponse({"error": "unauthorized"}, status_code=401)
186
+ body = await request.json()
187
+ device = (body or {}).get("device_account", "")
188
+ if not device:
189
+ return JSONResponse({"error": "missing device_account"}, status_code=400)
190
+ if not device.startswith("d-"):
191
+ return JSONResponse({"error": "invalid device_account"}, status_code=400)
192
+ minutes = billing.merge_account(account, device)
193
+ return {"ok": True, "account": account, "minutes": minutes}
194
+
195
+ @app.post("/billing/purchase")
196
+ async def purchase(request: Request):
197
+ body = await request.json()
198
+ account = bearer_user(request, auth_secret)
199
+ if not account:
200
+ anon = (body or {}).get("account", "")
201
+ if anon.startswith("d-"):
202
+ account = anon
203
+ else:
204
+ return JSONResponse({"error": "unauthorized"}, status_code=401)
205
+ jws = (body or {}).get("jws", "")
206
+ if not jws:
207
+ return JSONResponse({"error": "missing jws"}, status_code=400)
208
+ payload = verifier(jws, bundle_id=bundle or None)
209
+ if not payload:
210
+ return JSONResponse({"error": "invalid transaction"}, status_code=400)
211
+ product = payload.get("productId", "")
212
+ if product not in PRODUCT_MINUTES:
213
+ return JSONResponse({"error": f"unknown product {product}"}, status_code=400)
214
+ minutes = billing.apply_purchase(
215
+ account, product, payload.get("transactionId", "")
216
+ )
217
+ return {"ok": True, "product": product, "minutes": minutes}
218
+
219
+ @app.websocket("/live")
220
+ async def live(websocket: WebSocket):
221
+ """Metered V2V relay: the laptop connects here instead of Google. We run the
222
+ Gemini bridge with the cloud key, meter the account's minutes, and cut off
223
+ at zero. Tool calls are RPC'd back to the laptop over this same socket."""
224
+ account = websocket.query_params.get("account", "")
225
+ if (proxy_token and websocket.query_params.get("token") != proxy_token) or not account:
226
+ await websocket.close(code=4401)
227
+ return
228
+ # Don't let a never-seen anonymous id spin up a metered session on the
229
+ # cloud's Gemini key beyond the creation limit (free-minute farming guard).
230
+ if not _allow_new_anon(account, websocket):
231
+ await websocket.close(code=4403)
232
+ return
233
+ # With attestation required, refuse unattested device accounts outright.
234
+ if not _attested_ok(account):
235
+ await websocket.close(code=4405)
236
+ return
237
+ if not billing.has_minutes(account):
238
+ await websocket.accept()
239
+ await websocket.send_json({"type": "status", "status": "no minutes"})
240
+ await websocket.close(code=4402)
241
+ return
242
+ await websocket.accept()
243
+
244
+ loop = asyncio.get_event_loop()
245
+ pending: dict[str, asyncio.Future] = {}
246
+
247
+ async def handle_tool_call(name: str, args: dict) -> dict:
248
+ rid = uuid.uuid4().hex
249
+ fut = loop.create_future()
250
+ pending[rid] = fut
251
+ await websocket.send_json({"type": "tool", "id": rid, "name": name, "args": args})
252
+ try:
253
+ return await asyncio.wait_for(fut, timeout=600)
254
+ finally:
255
+ pending.pop(rid, None)
256
+
257
+ # The phone's chosen voice rode through to here; pass it to factories that
258
+ # accept it (keeps simpler 2-arg factories working).
259
+ import inspect
260
+ voice = websocket.query_params.get("voice", "")
261
+ _args = [account, handle_tool_call]
262
+ if "voice" in inspect.signature(operator_factory).parameters:
263
+ _args.append(voice)
264
+
265
+ meter = MeteredSession(billing, account)
266
+ async with _as_cm(operator_factory(*_args)) as op:
267
+ op.set_audio_out(lambda pcm: websocket.send_bytes(pcm))
268
+ op.set_text_out(lambda m: websocket.send_json(m))
269
+ # The laptop always drives the spoken opening (contextual or a generic
270
+ # greeting). Never let the cloud brain auto-greet, or it races ahead with a
271
+ # generic "what would you like to do?" before the laptop's opening arrives.
272
+ if hasattr(op, "suppress_greeting"):
273
+ op.suppress_greeting()
274
+
275
+ async def recv_loop():
276
+ try:
277
+ while True:
278
+ msg = await websocket.receive()
279
+ if msg["type"] == "websocket.disconnect":
280
+ break
281
+ if msg.get("bytes") is not None:
282
+ await op.send_audio(msg["bytes"])
283
+ elif msg.get("text"):
284
+ data = json.loads(msg["text"])
285
+ mtype = data.get("type")
286
+ if mtype == "tool_result":
287
+ # Guard against a late result arriving after its future was
288
+ # cancelled/timed out: set_result on a done future raises
289
+ # InvalidStateError and would kill this loop.
290
+ fut = pending.get(data.get("id"))
291
+ if fut is not None and not fut.done():
292
+ fut.set_result(data.get("result", {}))
293
+ elif mtype == "speak":
294
+ await op.speak(data.get("text", ""),
295
+ immediate=bool(data.get("immediate")))
296
+ elif mtype == "suppress_greeting":
297
+ op.suppress_greeting()
298
+ elif mtype == "user_text":
299
+ await op.send_text(data.get("text", ""))
300
+ except (WebSocketDisconnect, RuntimeError):
301
+ pass
302
+
303
+ async def metering():
304
+ while True:
305
+ await asyncio.sleep(5)
306
+ meter.tick()
307
+ if meter.out_of_minutes():
308
+ with contextlib.suppress(Exception):
309
+ await websocket.send_json(
310
+ {"type": "status", "status": "out of minutes"})
311
+ return
312
+
313
+ tasks = [asyncio.ensure_future(op.run()),
314
+ asyncio.ensure_future(recv_loop()),
315
+ asyncio.ensure_future(metering())]
316
+ try:
317
+ await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
318
+ finally:
319
+ for t in tasks:
320
+ t.cancel()
321
+ meter.flush()
322
+
323
+ return billing
324
+
325
+
326
+ def create_cloud_app(billing: Billing | None = None, verifier=verify_transaction,
327
+ operator_factory=None) -> FastAPI:
328
+ app = FastAPI()
329
+
330
+ @app.get("/healthz")
331
+ async def healthz():
332
+ return {"ok": True}
333
+
334
+ add_billing_routes(app, billing=billing, verifier=verifier,
335
+ operator_factory=operator_factory)
336
+ return app
337
+
338
+
339
+ @contextlib.asynccontextmanager
340
+ async def _as_cm(obj):
341
+ if hasattr(obj, "__aenter__"):
342
+ async with obj as entered:
343
+ yield entered
344
+ else:
345
+ yield obj
server/config.py ADDED
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from typing import Mapping
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Config:
10
+ gemini_api_key: str
11
+ gemini_live_model: str
12
+ auth_token: str
13
+ host: str
14
+ port: int
15
+ apns_key_path: str = ""
16
+ apns_key: str = "" # the .p8 contents (preferred on container hosts)
17
+ apns_key_id: str = ""
18
+ apns_team_id: str = ""
19
+ apns_bundle_id: str = ""
20
+ apns_sandbox: bool = False
21
+
22
+ @property
23
+ def push_enabled(self) -> bool:
24
+ return all([
25
+ (self.apns_key or self.apns_key_path), self.apns_key_id,
26
+ self.apns_team_id, self.apns_bundle_id,
27
+ ])
28
+
29
+
30
+ def load_config(env: Mapping[str, str] | None = None) -> Config:
31
+ env = os.environ if env is None else env
32
+ api_key = env.get("GEMINI_API_KEY", "").strip()
33
+ auth_token = env.get("VOXA_AUTH_TOKEN", "").strip()
34
+ # GEMINI_API_KEY is required only in DIRECT mode (Gemini runs locally). In the
35
+ # metered/relay product the laptop has no key, the cloud /live proxy holds it,
36
+ # so it's optional here and only the server enforces having one.
37
+ if not api_key and not env.get("VOXA_LIVE_PROXY", "").strip():
38
+ raise ValueError("GEMINI_API_KEY is required (or set VOXA_LIVE_PROXY for metered mode)")
39
+ if not auth_token:
40
+ raise ValueError("VOXA_AUTH_TOKEN is required")
41
+ apns = {
42
+ "apns_key_path": env.get("APNS_KEY_PATH", "").strip(),
43
+ "apns_key": env.get("APNS_KEY", ""),
44
+ "apns_key_id": env.get("APNS_KEY_ID", "").strip(),
45
+ "apns_team_id": env.get("APNS_TEAM_ID", "").strip(),
46
+ "apns_bundle_id": env.get("APNS_BUNDLE_ID", "").strip(),
47
+ "apns_sandbox": env.get("APNS_SANDBOX", "").strip().lower() in ("1", "true", "yes"),
48
+ }
49
+ return Config(
50
+ gemini_api_key=api_key,
51
+ gemini_live_model=env.get("GEMINI_LIVE_MODEL", "gemini-2.0-flash-live-001").strip(),
52
+ auth_token=auth_token,
53
+ host=env.get("VOXA_HOST", "127.0.0.1").strip(),
54
+ port=int(env.get("VOXA_PORT", "8787")),
55
+ **apns,
56
+ )
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+
6
+
7
+ class DeviceRegistry:
8
+ """VoIP push tokens, keyed by account so the cloud can ring the right phone
9
+ for the right customer. Persisted as {account: [tokens]} (a legacy flat list
10
+ is migrated under account "")."""
11
+
12
+ def __init__(self, path: str):
13
+ self._path = path
14
+ self._by_account: dict[str, set[str]] = {}
15
+ if os.path.exists(path):
16
+ try:
17
+ data = json.load(open(path))
18
+ if isinstance(data, dict):
19
+ self._by_account = {a: set(t) for a, t in data.items()}
20
+ elif isinstance(data, list): # legacy flat list
21
+ self._by_account = {"": set(data)}
22
+ except (ValueError, OSError):
23
+ self._by_account = {}
24
+
25
+ def register(self, token: str, account: str = "") -> None:
26
+ if not token:
27
+ return
28
+ s = self._by_account.setdefault(account, set())
29
+ if token not in s:
30
+ s.add(token)
31
+ self._flush()
32
+
33
+ def remove(self, token: str) -> None:
34
+ changed = False
35
+ for s in self._by_account.values():
36
+ if token in s:
37
+ s.discard(token)
38
+ changed = True
39
+ if changed:
40
+ self._flush()
41
+
42
+ def tokens(self, account: str | None = None) -> list[str]:
43
+ """Tokens for one account, or all tokens if account is None."""
44
+ if account is None:
45
+ return [t for s in self._by_account.values() for t in s]
46
+ return list(self._by_account.get(account, set()))
47
+
48
+ def _flush(self) -> None:
49
+ tmp = self._path + ".tmp"
50
+ with open(tmp, "w") as f:
51
+ json.dump({a: sorted(t) for a, t in self._by_account.items()}, f)
52
+ os.replace(tmp, self._path)