alphadesk 0.2.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.
- alphadesk/__init__.py +0 -0
- alphadesk/app/__init__.py +0 -0
- alphadesk/app/admin.py +261 -0
- alphadesk/app/agent_access.py +134 -0
- alphadesk/app/agent_oauth.py +490 -0
- alphadesk/app/agent_tools.py +156 -0
- alphadesk/app/auth.py +616 -0
- alphadesk/app/dashboard.py +1976 -0
- alphadesk/app/scheduler.py +21 -0
- alphadesk/app/static/assets/AboutPage-oxNLXB47.js +1 -0
- alphadesk/app/static/assets/AccountPage-BrXHInm4.js +3 -0
- alphadesk/app/static/assets/AdminPage-COQHh86g.js +1 -0
- alphadesk/app/static/assets/AnalysisPage-BbSV4gxS.js +1 -0
- alphadesk/app/static/assets/CalendarsPage-B54VFiH0.js +1 -0
- alphadesk/app/static/assets/ChartPage-CadU2DBq.js +1 -0
- alphadesk/app/static/assets/ChartToolbar-CWzYW6Bd.js +1 -0
- alphadesk/app/static/assets/CompanyPage-DrVRb-eS.js +1 -0
- alphadesk/app/static/assets/Compare-DFd0oIQR.js +1 -0
- alphadesk/app/static/assets/ComparisonPanel-CSrjgKp8.js +1 -0
- alphadesk/app/static/assets/ComposedBoard-CRE1mFDm.js +1 -0
- alphadesk/app/static/assets/CustomViewPage-4LNYlWWz.js +1 -0
- alphadesk/app/static/assets/DashboardPage-BV5sMaz9.js +1 -0
- alphadesk/app/static/assets/DeleteAccountDialog-BSi_IL-b.js +1 -0
- alphadesk/app/static/assets/DisclaimerPage-xt9LSEZz.js +1 -0
- alphadesk/app/static/assets/EarningsPage-DHBsV6yo.js +1 -0
- alphadesk/app/static/assets/EarningsPanels-4aL-CG5P.js +1 -0
- alphadesk/app/static/assets/EarningsTranscript-CfHNraiy.js +3 -0
- alphadesk/app/static/assets/KeyPrompt--grznbQl.js +1 -0
- alphadesk/app/static/assets/KeyStatistics-B9q0klZ4.js +1 -0
- alphadesk/app/static/assets/NewsPage-BT3BOe-f.js +1 -0
- alphadesk/app/static/assets/OptionsPage-PZuatx9X.js +1 -0
- alphadesk/app/static/assets/Ownership-BAZloUrb.js +1 -0
- alphadesk/app/static/assets/PortfolioPage-C9n79v8S.js +1 -0
- alphadesk/app/static/assets/PrivacyPage-Dinx_Kxw.js +1 -0
- alphadesk/app/static/assets/RelatedFunds-IEziSpAo.js +1 -0
- alphadesk/app/static/assets/SectorsPage-CkaHSXTf.js +1 -0
- alphadesk/app/static/assets/SymbolNews-Cz4HxVhK.js +1 -0
- alphadesk/app/static/assets/TermsPage-DKYkfa4g.js +1 -0
- alphadesk/app/static/assets/ThemePage-DAakTiGI.js +1 -0
- alphadesk/app/static/assets/Treemap-BFLHgW5y.js +1 -0
- alphadesk/app/static/assets/api-D5JAm53L.js +1 -0
- alphadesk/app/static/assets/archivo-latin-ext-wght-normal-C4zznr8T.woff2 +0 -0
- alphadesk/app/static/assets/archivo-latin-wght-normal-E0tuGl4L.woff2 +0 -0
- alphadesk/app/static/assets/archivo-vietnamese-wght-normal-XAtsl5Q_.woff2 +0 -0
- alphadesk/app/static/assets/boardLayout-C8TGdgmo.js +1 -0
- alphadesk/app/static/assets/chart-BQSADIEr.js +1 -0
- alphadesk/app/static/assets/chartEngine-qKIm8vAv.js +1 -0
- alphadesk/app/static/assets/desk-CoWotHFP.js +1 -0
- alphadesk/app/static/assets/earningsClock-BQTvDz4R.js +1 -0
- alphadesk/app/static/assets/ibm-plex-sans-cyrillic-400-normal-BTotfTJu.woff +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-cyrillic-400-normal-DZqxrq2p.woff2 +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-cyrillic-500-normal-ByOcLdNv.woff +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-cyrillic-500-normal-CocWQlwt.woff2 +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-cyrillic-600-normal-71GNu3SW.woff2 +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-cyrillic-600-normal-BGq0mW3O.woff +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-cyrillic-ext-400-normal-Dsrv2Tcn.woff +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-cyrillic-ext-400-normal-g30qAdWV.woff2 +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-cyrillic-ext-500-normal-Cs5J6C77.woff2 +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-cyrillic-ext-500-normal-DB5PtV2g.woff +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-cyrillic-ext-600-normal-Bz0x94Yp.woff +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-cyrillic-ext-600-normal-DUMzJB7m.woff2 +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-greek-400-normal-D9ESIMu3.woff +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-greek-400-normal-_efipK4i.woff2 +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-greek-500-normal-CuWXN6rf.woff +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-greek-500-normal-JMMifIXV.woff2 +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-greek-600-normal-D-CqTdkO.woff +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-greek-600-normal-DzTrcv_p.woff2 +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-latin-ext-400-normal-C5H60-Va.woff2 +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-latin-ext-400-normal-RBey6euL.woff +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-latin-ext-500-normal-D0aIdm-b.woff +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-latin-ext-500-normal-DakdToA3.woff2 +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-latin-ext-600-normal-DIrixKbi.woff +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-latin-ext-600-normal-DOrvGEcy.woff2 +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-vietnamese-400-normal-DG4YqDda.woff2 +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-vietnamese-400-normal-fK1oJ5dG.woff +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-vietnamese-500-normal-BEb3_waV.woff +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-vietnamese-500-normal-e4dixQRQ.woff2 +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-vietnamese-600-normal-DgdngZtN.woff +0 -0
- alphadesk/app/static/assets/ibm-plex-sans-vietnamese-600-normal-DpPYBSTl.woff2 +0 -0
- alphadesk/app/static/assets/index-LlFeul9Q.css +2 -0
- alphadesk/app/static/assets/index-WmwcDgNM.js +12 -0
- alphadesk/app/static/assets/indicators-epcKBeyS.js +1 -0
- alphadesk/app/static/assets/jsx-runtime-DUAcabCT.js +1 -0
- alphadesk/app/static/assets/legal-Djpb1OsS.js +1 -0
- alphadesk/app/static/assets/live-Zo6aYIWu.js +1 -0
- alphadesk/app/static/assets/market-CBOwi-aq.js +1 -0
- alphadesk/app/static/assets/newsClock-CuRm4aSM.js +1 -0
- alphadesk/app/static/assets/olderNews-BKzmY_JB.js +1 -0
- alphadesk/app/static/assets/search-D1dJQn0P.js +1 -0
- alphadesk/app/static/assets/vendors-B8n10WJm.js +1 -0
- alphadesk/app/static/assets/x-CZJ1eVbC.js +1 -0
- alphadesk/app/static/favicon.svg +1 -0
- alphadesk/app/static/index.html +66 -0
- alphadesk/app/static/landing/chart.jpg +0 -0
- alphadesk/app/static/landing/earnings.jpg +0 -0
- alphadesk/app/static/landing/markets.jpg +0 -0
- alphadesk/app/static/landing/news.jpg +0 -0
- alphadesk/app/static/landing/options.jpg +0 -0
- alphadesk/app/static/llms.txt +65 -0
- alphadesk/app/static/robots.txt +9 -0
- alphadesk/app/static/sitemap.xml +8 -0
- alphadesk/app/static/third-party-notices.txt +538 -0
- alphadesk/billing.py +335 -0
- alphadesk/config.py +652 -0
- alphadesk/cryptonews.py +78 -0
- alphadesk/deploy/env.example +96 -0
- alphadesk/desk/__init__.py +0 -0
- alphadesk/desk/filings.py +66 -0
- alphadesk/desk/screener.py +135 -0
- alphadesk/desk/today.py +150 -0
- alphadesk/desk/transcripts.py +118 -0
- alphadesk/extwidgets.py +266 -0
- alphadesk/identity.py +37 -0
- alphadesk/ingest/__init__.py +0 -0
- alphadesk/ingest/analysts.py +61 -0
- alphadesk/ingest/background_fill.py +72 -0
- alphadesk/ingest/calendar_accuracy.py +98 -0
- alphadesk/ingest/coingecko.py +102 -0
- alphadesk/ingest/company.py +485 -0
- alphadesk/ingest/compare.py +52 -0
- alphadesk/ingest/corporate_actions.py +125 -0
- alphadesk/ingest/corporate_calendars.py +283 -0
- alphadesk/ingest/earnings.py +172 -0
- alphadesk/ingest/earnings_announcements.py +421 -0
- alphadesk/ingest/earnings_calendar.py +1050 -0
- alphadesk/ingest/earnings_record.py +80 -0
- alphadesk/ingest/economic.py +48 -0
- alphadesk/ingest/edgar.py +545 -0
- alphadesk/ingest/edgar_financials.py +136 -0
- alphadesk/ingest/edgar_releases.py +363 -0
- alphadesk/ingest/events.py +82 -0
- alphadesk/ingest/funds.py +37 -0
- alphadesk/ingest/insider.py +166 -0
- alphadesk/ingest/keystats.py +58 -0
- alphadesk/ingest/movers.py +531 -0
- alphadesk/ingest/news.py +372 -0
- alphadesk/ingest/options.py +68 -0
- alphadesk/ingest/options_flow.py +371 -0
- alphadesk/ingest/ownership.py +26 -0
- alphadesk/ingest/prices.py +734 -0
- alphadesk/ingest/related_funds.py +185 -0
- alphadesk/ingest/release_times.py +109 -0
- alphadesk/ingest/secfacts.py +91 -0
- alphadesk/ingest/sectors.py +305 -0
- alphadesk/ingest/stream.py +685 -0
- alphadesk/ledger/__init__.py +0 -0
- alphadesk/ledger/db.py +217 -0
- alphadesk/ledger/store.py +1926 -0
- alphadesk/ledger/vault.py +80 -0
- alphadesk/main.py +359 -0
- alphadesk/mcp_server.py +1070 -0
- alphadesk/net.py +69 -0
- alphadesk/newsquery.py +175 -0
- alphadesk/prewarm.py +185 -0
- alphadesk/providers/__init__.py +25 -0
- alphadesk/providers/alpaca.py +1591 -0
- alphadesk/providers/base.py +293 -0
- alphadesk/providers/builtin.py +6 -0
- alphadesk/providers/catalogue.py +200 -0
- alphadesk/providers/company_vendors.py +1185 -0
- alphadesk/providers/news.py +684 -0
- alphadesk/providers/prices.py +1286 -0
- alphadesk/providers/registry.py +435 -0
- alphadesk/providers/transcripts.py +268 -0
- alphadesk/semantic.py +335 -0
- alphadesk-0.2.0.dist-info/METADATA +694 -0
- alphadesk-0.2.0.dist-info/RECORD +176 -0
- alphadesk-0.2.0.dist-info/WHEEL +5 -0
- alphadesk-0.2.0.dist-info/licenses/LICENSE +672 -0
- alphadesk-0.2.0.dist-info/top_level.txt +1 -0
alphadesk/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
alphadesk/app/admin.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""The owner's admin routes and every reader's plan routes (2026-09-18).
|
|
2
|
+
|
|
3
|
+
/api/admin/* OWNER ONLY (ALPHADESK_OWNER_EMAILS): every account, and the
|
|
4
|
+
switches an operator needs — disable, sign out everywhere,
|
|
5
|
+
extend a trial. Anyone else gets 403, not a hint of the list.
|
|
6
|
+
/api/billing the signed-in reader's own access, and the doors to the
|
|
7
|
+
payment processor's checkout and billing portal. With no
|
|
8
|
+
processor configured those answer 503 and charge nothing.
|
|
9
|
+
/api/billing/webhook/{provider} the processor's signed notifications. The
|
|
10
|
+
signature is the processor's own; an unverifiable body changes
|
|
11
|
+
nothing.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
|
|
18
|
+
from fastapi import APIRouter, Body, HTTPException, Request, Response
|
|
19
|
+
from pydantic import BaseModel, Field
|
|
20
|
+
|
|
21
|
+
from alphadesk import billing
|
|
22
|
+
from alphadesk.app import auth
|
|
23
|
+
from alphadesk.ledger import store
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
router = APIRouter()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _claims(request: Request) -> dict:
|
|
31
|
+
claims = auth.current_user(request)
|
|
32
|
+
if not claims or not claims.get("uid"):
|
|
33
|
+
raise HTTPException(401, "sign in required")
|
|
34
|
+
return claims
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _owner(request: Request) -> dict:
|
|
38
|
+
claims = _claims(request)
|
|
39
|
+
if not billing.is_owner(claims.get("email")):
|
|
40
|
+
raise HTTPException(403, "owners only")
|
|
41
|
+
return claims
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ── the owner's admin page ──────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
@router.get("/api/admin/users")
|
|
47
|
+
def admin_users(request: Request):
|
|
48
|
+
"""Every account, newest first, with its access decided exactly as the
|
|
49
|
+
gate decides it — and the totals the page leads with."""
|
|
50
|
+
_owner(request)
|
|
51
|
+
users = []
|
|
52
|
+
for u in store.admin_list_users():
|
|
53
|
+
users.append({
|
|
54
|
+
"user_id": u["user_id"],
|
|
55
|
+
"email": u["email"],
|
|
56
|
+
"disabled": bool(u["disabled"]),
|
|
57
|
+
"created_at": u["created_at"],
|
|
58
|
+
"last_seen_at": u["last_seen_at"],
|
|
59
|
+
"sign_ins": u["sign_ins"],
|
|
60
|
+
"counts": u["counts"],
|
|
61
|
+
"access": billing.access(u),
|
|
62
|
+
})
|
|
63
|
+
totals = {"accounts": len(users), "disabled": sum(1 for u in users if u["disabled"])}
|
|
64
|
+
for state in ("owner", "subscribed", "trialing", "expired"):
|
|
65
|
+
totals[state] = sum(1 for u in users if u["access"]["state"] == state and not u["disabled"])
|
|
66
|
+
return {"users": users, "totals": totals, "enforced": billing.enforced(),
|
|
67
|
+
"trial_days": billing.trial_days(), "payments": billing.provider() is not None}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class DisableIn(BaseModel):
|
|
71
|
+
disabled: bool
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@router.post("/api/admin/users/{user_id}/disabled")
|
|
75
|
+
def admin_disable(user_id: str, body: DisableIn, request: Request):
|
|
76
|
+
"""Disable or re-enable an account. Disabling also signs it out
|
|
77
|
+
everywhere, so an open tab stops at its next request."""
|
|
78
|
+
claims = _owner(request)
|
|
79
|
+
if user_id == claims["uid"] and body.disabled:
|
|
80
|
+
raise HTTPException(422, "you cannot disable your own account")
|
|
81
|
+
if not store.set_user_disabled(user_id, body.disabled):
|
|
82
|
+
raise HTTPException(404, "no such account")
|
|
83
|
+
if body.disabled:
|
|
84
|
+
auth.invalidate_sessions(user_id)
|
|
85
|
+
else:
|
|
86
|
+
auth._session_states.pop(user_id, None)
|
|
87
|
+
billing.forget(user_id)
|
|
88
|
+
log.info("admin %s set disabled=%s on %s", claims["email"], body.disabled, user_id)
|
|
89
|
+
return {"ok": True}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@router.post("/api/admin/users/{user_id}/sign-out")
|
|
93
|
+
def admin_sign_out(user_id: str, request: Request):
|
|
94
|
+
claims = _owner(request)
|
|
95
|
+
if not auth.invalidate_sessions(user_id):
|
|
96
|
+
raise HTTPException(404, "no such account")
|
|
97
|
+
log.info("admin %s signed out %s everywhere", claims["email"], user_id)
|
|
98
|
+
return {"ok": True}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TrialIn(BaseModel):
|
|
102
|
+
days: int = Field(ge=1, le=365)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@router.post("/api/admin/users/{user_id}/trial")
|
|
106
|
+
def admin_extend_trial(user_id: str, body: TrialIn, request: Request):
|
|
107
|
+
"""Give an account `days` more of trial, counted from the later of now
|
|
108
|
+
and its current end — so extending a live trial adds to it."""
|
|
109
|
+
claims = _owner(request)
|
|
110
|
+
row = store.user_access_row(user_id)
|
|
111
|
+
if not row:
|
|
112
|
+
raise HTTPException(404, "no such account")
|
|
113
|
+
from datetime import datetime, timedelta, timezone
|
|
114
|
+
now = datetime.now(timezone.utc)
|
|
115
|
+
current = billing._parse(row.get("trial_ends_at"))
|
|
116
|
+
start = current if current and current > now else now
|
|
117
|
+
end = (start + timedelta(days=body.days)).isoformat(timespec="seconds")
|
|
118
|
+
store.set_trial_end(user_id, end)
|
|
119
|
+
billing.forget(user_id)
|
|
120
|
+
log.info("admin %s extended %s's trial to %s", claims["email"], user_id, end)
|
|
121
|
+
return {"ok": True, "trial_ends_at": end}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ── deleting an account ─────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
def _erase(user_id: str) -> dict[str, int] | None:
|
|
127
|
+
"""Delete the account and everything it holds (store.delete_account).
|
|
128
|
+
Sessions end first, so an open tab stops at its next request; cached keys
|
|
129
|
+
are dropped. The reader's held news socket is released on the news
|
|
130
|
+
loop's next cycle (the account is no longer an active reader), and a live
|
|
131
|
+
price stream closes with the reader's last tab."""
|
|
132
|
+
from alphadesk.providers import registry
|
|
133
|
+
auth.invalidate_sessions(user_id)
|
|
134
|
+
gone = store.delete_account(user_id)
|
|
135
|
+
registry.forget_user_keys(user_id)
|
|
136
|
+
billing.forget(user_id)
|
|
137
|
+
auth._session_states.pop(user_id, None)
|
|
138
|
+
return gone
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class DeleteIn(BaseModel):
|
|
142
|
+
# The account's email, typed back: a delete cannot be a stray click.
|
|
143
|
+
confirm: str
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@router.post("/api/account/delete")
|
|
147
|
+
def delete_my_account(body: DeleteIn, request: Request, response: Response):
|
|
148
|
+
"""A reader deletes their own account and everything in it. Owners are
|
|
149
|
+
refused here — deleting the owner would lock the operator out of the
|
|
150
|
+
admin page; take the address off ALPHADESK_OWNER_EMAILS first."""
|
|
151
|
+
claims = _claims(request)
|
|
152
|
+
if body.confirm.strip().lower() != (claims.get("email") or "").lower():
|
|
153
|
+
raise HTTPException(422, "type your account's email address to confirm")
|
|
154
|
+
if billing.is_owner(claims.get("email")):
|
|
155
|
+
raise HTTPException(422, "an owner account cannot be deleted here")
|
|
156
|
+
if _erase(claims["uid"]) is None:
|
|
157
|
+
raise HTTPException(404, "no such account")
|
|
158
|
+
response.delete_cookie(auth.SESSION_COOKIE)
|
|
159
|
+
log.info("account %s deleted by its reader", claims["uid"])
|
|
160
|
+
return {"ok": True}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@router.post("/api/admin/users/{user_id}/delete")
|
|
164
|
+
def admin_delete(user_id: str, body: DeleteIn, request: Request):
|
|
165
|
+
"""The owner deletes an account (a reader's written request, or abuse).
|
|
166
|
+
The account's email must be typed back, as for a reader's own delete."""
|
|
167
|
+
claims = _owner(request)
|
|
168
|
+
if user_id == claims["uid"]:
|
|
169
|
+
raise HTTPException(422, "you cannot delete your own account")
|
|
170
|
+
row = store.user_access_row(user_id)
|
|
171
|
+
if not row:
|
|
172
|
+
raise HTTPException(404, "no such account")
|
|
173
|
+
if body.confirm.strip().lower() != row["email"].lower():
|
|
174
|
+
raise HTTPException(422, "type the account's email address to confirm")
|
|
175
|
+
_erase(user_id)
|
|
176
|
+
log.info("admin %s deleted account %s", claims["email"], user_id)
|
|
177
|
+
return {"ok": True}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ── the reader's own plan ───────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
def _return_url(request: Request) -> str:
|
|
183
|
+
base = os.environ.get("ALPHADESK_BASE_URL", "").strip().rstrip("/") \
|
|
184
|
+
or f"{request.url.scheme}://{request.url.netloc}"
|
|
185
|
+
return base + "/account"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@router.get("/api/billing")
|
|
189
|
+
def my_billing(request: Request):
|
|
190
|
+
claims = _claims(request)
|
|
191
|
+
return billing.access(store.user_access_row(claims["uid"]))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _processor() -> "billing.BillingProvider":
|
|
195
|
+
p = billing.provider()
|
|
196
|
+
if p is None:
|
|
197
|
+
raise HTTPException(503, "payments are not set up yet")
|
|
198
|
+
return p
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _call(fn, *args, **kwargs) -> dict:
|
|
202
|
+
"""A processor call as a route answer: a missing setting is 503 naming
|
|
203
|
+
it, a refusal or an unreachable processor 502 — never a bare 500."""
|
|
204
|
+
try:
|
|
205
|
+
return {"url": fn(*args, **kwargs)}
|
|
206
|
+
except billing.NotConfigured as exc:
|
|
207
|
+
raise HTTPException(503, str(exc)) from None
|
|
208
|
+
except billing.ProcessorError as exc:
|
|
209
|
+
log.warning("billing processor error: %s", exc)
|
|
210
|
+
raise HTTPException(502, str(exc)) from None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@router.post("/api/billing/checkout")
|
|
214
|
+
@router.post("/api/billing/create-checkout-session")
|
|
215
|
+
def checkout(request: Request, body: dict | None = Body(default=None)):
|
|
216
|
+
"""{plan: "monthly" | "yearly"} → {url} of the processor's checkout."""
|
|
217
|
+
claims = _claims(request)
|
|
218
|
+
plan = str((body or {}).get("plan") or "monthly").lower()
|
|
219
|
+
if plan not in billing.PLANS:
|
|
220
|
+
raise HTTPException(400, f"plan must be one of {', '.join(billing.PLANS)}")
|
|
221
|
+
p = _processor()
|
|
222
|
+
return _call(p.checkout_url, claims["uid"], claims["email"], _return_url(request), plan=plan)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@router.post("/api/billing/portal")
|
|
226
|
+
@router.post("/api/billing/create-portal-session")
|
|
227
|
+
def portal(request: Request):
|
|
228
|
+
claims = _claims(request)
|
|
229
|
+
p = _processor()
|
|
230
|
+
row = store.user_access_row(claims["uid"]) or {}
|
|
231
|
+
if not row.get("plan_customer_id"):
|
|
232
|
+
raise HTTPException(409, "no subscription to manage yet")
|
|
233
|
+
return _call(p.portal_url, row["plan_customer_id"], _return_url(request))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
async def _webhook(provider_name: str, request: Request) -> dict:
|
|
237
|
+
p = billing.provider()
|
|
238
|
+
if p is None or p.name != provider_name:
|
|
239
|
+
raise HTTPException(404, "no such processor configured")
|
|
240
|
+
body = await request.body()
|
|
241
|
+
try:
|
|
242
|
+
updates = p.parse_webhook(body, {k.lower(): v for k, v in request.headers.items()})
|
|
243
|
+
except billing.NotConfigured as exc:
|
|
244
|
+
raise HTTPException(503, str(exc)) from None
|
|
245
|
+
except Exception as exc: # noqa: BLE001 — any verification failure is a refusal
|
|
246
|
+
log.warning("%s webhook refused: %s", provider_name, exc)
|
|
247
|
+
raise HTTPException(400, "webhook could not be verified") from None
|
|
248
|
+
return {"ok": True, "changed": billing.apply_updates(p.name, updates)}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@router.post("/api/billing/webhook")
|
|
252
|
+
async def stripe_webhook(request: Request):
|
|
253
|
+
"""Stripe's endpoint. Register <base-url>/api/billing/webhook in the
|
|
254
|
+
Stripe dashboard for checkout.session.completed and
|
|
255
|
+
customer.subscription.created / updated / deleted."""
|
|
256
|
+
return await _webhook("stripe", request)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@router.post("/api/billing/webhook/{provider_name}")
|
|
260
|
+
async def webhook(provider_name: str, request: Request):
|
|
261
|
+
return await _webhook(provider_name, request)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Readers connect their own agent to AlphaDesk (2026-09-17).
|
|
2
|
+
|
|
3
|
+
AlphaDesk does not run an agent of its own. A reader points the agent they
|
|
4
|
+
already use — Claude, ChatGPT, Cursor, opencode, anything that speaks MCP —
|
|
5
|
+
at this app's tool server, and every call runs as that reader on their own
|
|
6
|
+
keys (app/agent_tools.py). Their agent brings what it already has (web
|
|
7
|
+
browsing, memory, file uploads, their subscription); AlphaDesk brings the
|
|
8
|
+
data and the tools that verify their citations.
|
|
9
|
+
|
|
10
|
+
This module is the credential for that:
|
|
11
|
+
|
|
12
|
+
* ACCESS TOKENS a reader issues on the Account page, one per agent they
|
|
13
|
+
connect. A token is "adk_" plus 256 bits of randomness. It is shown once;
|
|
14
|
+
only its SHA-256 is stored — a fast hash is enough because the token is
|
|
15
|
+
random, not a password a person chose. Revoking one stops it at once:
|
|
16
|
+
every request looks the hash up, nothing is cached.
|
|
17
|
+
* A RATE LIMIT per token, so a looping agent cannot spend a reader's vendor
|
|
18
|
+
quota (or the instance's EDGAR allowance) in a burst. It is kept in
|
|
19
|
+
memory per instance: with several instances an agent may get a little
|
|
20
|
+
more than the limit, never unbounded.
|
|
21
|
+
* THE HOSTS the tool server answers: loopback, plus the instance's public
|
|
22
|
+
address from ALPHADESK_BASE_URL. A request naming any other host is
|
|
23
|
+
refused before a tool runs (the MCP library's DNS-rebinding check).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import collections
|
|
29
|
+
import hashlib
|
|
30
|
+
import os
|
|
31
|
+
import secrets
|
|
32
|
+
import threading
|
|
33
|
+
import time
|
|
34
|
+
from urllib.parse import urlsplit
|
|
35
|
+
|
|
36
|
+
TOKEN_PREFIX = "adk_"
|
|
37
|
+
#: Live tokens one reader may hold.
|
|
38
|
+
MAX_TOKENS = 10
|
|
39
|
+
#: Tool-server requests one token may make per minute. An MCP tool call is
|
|
40
|
+
#: one request; listing tools at connect is one more.
|
|
41
|
+
RATE_PER_MIN = 120
|
|
42
|
+
#: last-used is written at most this often per token.
|
|
43
|
+
TOUCH_EVERY_S = 60.0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def bearer(header_value: str | None) -> str | None:
|
|
47
|
+
"""The token from an `Authorization: Bearer …` header, or None."""
|
|
48
|
+
text = (header_value or "").strip()
|
|
49
|
+
if not text.lower().startswith("bearer "):
|
|
50
|
+
return None
|
|
51
|
+
return text[7:].strip() or None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TokenLimit(Exception):
|
|
55
|
+
"""The reader already holds MAX_TOKENS live tokens."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _hash(secret: str) -> str:
|
|
59
|
+
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def issue(user_id: str, name: str) -> tuple[dict, str]:
|
|
63
|
+
"""(the stored row as listed, the token itself — shown once)."""
|
|
64
|
+
from alphadesk.ledger import store
|
|
65
|
+
if len(store.list_agent_access_tokens(user_id)) >= MAX_TOKENS:
|
|
66
|
+
raise TokenLimit(f"revoke a token first — at most {MAX_TOKENS} can be live")
|
|
67
|
+
secret = TOKEN_PREFIX + secrets.token_urlsafe(32)
|
|
68
|
+
token_id = "tok_" + secrets.token_hex(6)
|
|
69
|
+
label = (name or "").strip()[:60] or "agent"
|
|
70
|
+
store.create_agent_access_token(user_id, token_id, label, _hash(secret), secret[-4:])
|
|
71
|
+
row = next(r for r in store.list_agent_access_tokens(user_id) if r["token_id"] == token_id)
|
|
72
|
+
return row, secret
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_touched: dict[str, float] = {}
|
|
76
|
+
_touch_lock = threading.Lock()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def resolve(secret: str) -> tuple[str, str] | None:
|
|
80
|
+
"""(token id, reader) for a live access token, else None."""
|
|
81
|
+
if not secret.startswith(TOKEN_PREFIX):
|
|
82
|
+
return None
|
|
83
|
+
from alphadesk.ledger import store
|
|
84
|
+
row = store.agent_access_token_by_hash(_hash(secret))
|
|
85
|
+
if row is None:
|
|
86
|
+
return None
|
|
87
|
+
now = time.monotonic()
|
|
88
|
+
with _touch_lock:
|
|
89
|
+
due = now - _touched.get(row["token_id"], -TOUCH_EVERY_S) >= TOUCH_EVERY_S
|
|
90
|
+
if due:
|
|
91
|
+
_touched[row["token_id"]] = now
|
|
92
|
+
if due:
|
|
93
|
+
store.touch_agent_access_token(row["token_id"])
|
|
94
|
+
return row["token_id"], row["user_id"]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class RateLimit:
|
|
98
|
+
"""A sliding one-minute window per key."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, per_min: int = RATE_PER_MIN) -> None:
|
|
101
|
+
self.per_min = per_min
|
|
102
|
+
self._hits: dict[str, collections.deque] = {}
|
|
103
|
+
self._lock = threading.Lock()
|
|
104
|
+
|
|
105
|
+
def check(self, key: str, now: float | None = None) -> float:
|
|
106
|
+
"""0 when the request may proceed (and counts it); otherwise the
|
|
107
|
+
seconds until it may."""
|
|
108
|
+
now = now if now is not None else time.monotonic()
|
|
109
|
+
with self._lock:
|
|
110
|
+
hits = self._hits.setdefault(key, collections.deque())
|
|
111
|
+
while hits and now - hits[0] >= 60.0:
|
|
112
|
+
hits.popleft()
|
|
113
|
+
if len(hits) >= self.per_min:
|
|
114
|
+
return max(0.1, 60.0 - (now - hits[0]))
|
|
115
|
+
hits.append(now)
|
|
116
|
+
return 0.0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
limiter = RateLimit()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def allowed_hosts() -> tuple[list[str], list[str]]:
|
|
123
|
+
"""(Host header values, Origin values) the tool server answers."""
|
|
124
|
+
hosts = ["127.0.0.1", "127.0.0.1:*", "localhost", "localhost:*", "[::1]", "[::1]:*"]
|
|
125
|
+
origins = ["http://127.0.0.1", "http://127.0.0.1:*", "http://localhost", "http://localhost:*"]
|
|
126
|
+
base = (os.environ.get("ALPHADESK_BASE_URL") or "").strip().rstrip("/")
|
|
127
|
+
if base:
|
|
128
|
+
parts = urlsplit(base)
|
|
129
|
+
if parts.netloc:
|
|
130
|
+
hosts.append(parts.netloc)
|
|
131
|
+
if ":" not in parts.netloc:
|
|
132
|
+
hosts.append(parts.netloc + ":*")
|
|
133
|
+
origins.append(f"{parts.scheme}://{parts.netloc}")
|
|
134
|
+
return hosts, origins
|