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.
Files changed (176) hide show
  1. alphadesk/__init__.py +0 -0
  2. alphadesk/app/__init__.py +0 -0
  3. alphadesk/app/admin.py +261 -0
  4. alphadesk/app/agent_access.py +134 -0
  5. alphadesk/app/agent_oauth.py +490 -0
  6. alphadesk/app/agent_tools.py +156 -0
  7. alphadesk/app/auth.py +616 -0
  8. alphadesk/app/dashboard.py +1976 -0
  9. alphadesk/app/scheduler.py +21 -0
  10. alphadesk/app/static/assets/AboutPage-oxNLXB47.js +1 -0
  11. alphadesk/app/static/assets/AccountPage-BrXHInm4.js +3 -0
  12. alphadesk/app/static/assets/AdminPage-COQHh86g.js +1 -0
  13. alphadesk/app/static/assets/AnalysisPage-BbSV4gxS.js +1 -0
  14. alphadesk/app/static/assets/CalendarsPage-B54VFiH0.js +1 -0
  15. alphadesk/app/static/assets/ChartPage-CadU2DBq.js +1 -0
  16. alphadesk/app/static/assets/ChartToolbar-CWzYW6Bd.js +1 -0
  17. alphadesk/app/static/assets/CompanyPage-DrVRb-eS.js +1 -0
  18. alphadesk/app/static/assets/Compare-DFd0oIQR.js +1 -0
  19. alphadesk/app/static/assets/ComparisonPanel-CSrjgKp8.js +1 -0
  20. alphadesk/app/static/assets/ComposedBoard-CRE1mFDm.js +1 -0
  21. alphadesk/app/static/assets/CustomViewPage-4LNYlWWz.js +1 -0
  22. alphadesk/app/static/assets/DashboardPage-BV5sMaz9.js +1 -0
  23. alphadesk/app/static/assets/DeleteAccountDialog-BSi_IL-b.js +1 -0
  24. alphadesk/app/static/assets/DisclaimerPage-xt9LSEZz.js +1 -0
  25. alphadesk/app/static/assets/EarningsPage-DHBsV6yo.js +1 -0
  26. alphadesk/app/static/assets/EarningsPanels-4aL-CG5P.js +1 -0
  27. alphadesk/app/static/assets/EarningsTranscript-CfHNraiy.js +3 -0
  28. alphadesk/app/static/assets/KeyPrompt--grznbQl.js +1 -0
  29. alphadesk/app/static/assets/KeyStatistics-B9q0klZ4.js +1 -0
  30. alphadesk/app/static/assets/NewsPage-BT3BOe-f.js +1 -0
  31. alphadesk/app/static/assets/OptionsPage-PZuatx9X.js +1 -0
  32. alphadesk/app/static/assets/Ownership-BAZloUrb.js +1 -0
  33. alphadesk/app/static/assets/PortfolioPage-C9n79v8S.js +1 -0
  34. alphadesk/app/static/assets/PrivacyPage-Dinx_Kxw.js +1 -0
  35. alphadesk/app/static/assets/RelatedFunds-IEziSpAo.js +1 -0
  36. alphadesk/app/static/assets/SectorsPage-CkaHSXTf.js +1 -0
  37. alphadesk/app/static/assets/SymbolNews-Cz4HxVhK.js +1 -0
  38. alphadesk/app/static/assets/TermsPage-DKYkfa4g.js +1 -0
  39. alphadesk/app/static/assets/ThemePage-DAakTiGI.js +1 -0
  40. alphadesk/app/static/assets/Treemap-BFLHgW5y.js +1 -0
  41. alphadesk/app/static/assets/api-D5JAm53L.js +1 -0
  42. alphadesk/app/static/assets/archivo-latin-ext-wght-normal-C4zznr8T.woff2 +0 -0
  43. alphadesk/app/static/assets/archivo-latin-wght-normal-E0tuGl4L.woff2 +0 -0
  44. alphadesk/app/static/assets/archivo-vietnamese-wght-normal-XAtsl5Q_.woff2 +0 -0
  45. alphadesk/app/static/assets/boardLayout-C8TGdgmo.js +1 -0
  46. alphadesk/app/static/assets/chart-BQSADIEr.js +1 -0
  47. alphadesk/app/static/assets/chartEngine-qKIm8vAv.js +1 -0
  48. alphadesk/app/static/assets/desk-CoWotHFP.js +1 -0
  49. alphadesk/app/static/assets/earningsClock-BQTvDz4R.js +1 -0
  50. alphadesk/app/static/assets/ibm-plex-sans-cyrillic-400-normal-BTotfTJu.woff +0 -0
  51. alphadesk/app/static/assets/ibm-plex-sans-cyrillic-400-normal-DZqxrq2p.woff2 +0 -0
  52. alphadesk/app/static/assets/ibm-plex-sans-cyrillic-500-normal-ByOcLdNv.woff +0 -0
  53. alphadesk/app/static/assets/ibm-plex-sans-cyrillic-500-normal-CocWQlwt.woff2 +0 -0
  54. alphadesk/app/static/assets/ibm-plex-sans-cyrillic-600-normal-71GNu3SW.woff2 +0 -0
  55. alphadesk/app/static/assets/ibm-plex-sans-cyrillic-600-normal-BGq0mW3O.woff +0 -0
  56. alphadesk/app/static/assets/ibm-plex-sans-cyrillic-ext-400-normal-Dsrv2Tcn.woff +0 -0
  57. alphadesk/app/static/assets/ibm-plex-sans-cyrillic-ext-400-normal-g30qAdWV.woff2 +0 -0
  58. alphadesk/app/static/assets/ibm-plex-sans-cyrillic-ext-500-normal-Cs5J6C77.woff2 +0 -0
  59. alphadesk/app/static/assets/ibm-plex-sans-cyrillic-ext-500-normal-DB5PtV2g.woff +0 -0
  60. alphadesk/app/static/assets/ibm-plex-sans-cyrillic-ext-600-normal-Bz0x94Yp.woff +0 -0
  61. alphadesk/app/static/assets/ibm-plex-sans-cyrillic-ext-600-normal-DUMzJB7m.woff2 +0 -0
  62. alphadesk/app/static/assets/ibm-plex-sans-greek-400-normal-D9ESIMu3.woff +0 -0
  63. alphadesk/app/static/assets/ibm-plex-sans-greek-400-normal-_efipK4i.woff2 +0 -0
  64. alphadesk/app/static/assets/ibm-plex-sans-greek-500-normal-CuWXN6rf.woff +0 -0
  65. alphadesk/app/static/assets/ibm-plex-sans-greek-500-normal-JMMifIXV.woff2 +0 -0
  66. alphadesk/app/static/assets/ibm-plex-sans-greek-600-normal-D-CqTdkO.woff +0 -0
  67. alphadesk/app/static/assets/ibm-plex-sans-greek-600-normal-DzTrcv_p.woff2 +0 -0
  68. alphadesk/app/static/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
  69. alphadesk/app/static/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
  70. alphadesk/app/static/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
  71. alphadesk/app/static/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
  72. alphadesk/app/static/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
  73. alphadesk/app/static/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
  74. alphadesk/app/static/assets/ibm-plex-sans-latin-ext-400-normal-C5H60-Va.woff2 +0 -0
  75. alphadesk/app/static/assets/ibm-plex-sans-latin-ext-400-normal-RBey6euL.woff +0 -0
  76. alphadesk/app/static/assets/ibm-plex-sans-latin-ext-500-normal-D0aIdm-b.woff +0 -0
  77. alphadesk/app/static/assets/ibm-plex-sans-latin-ext-500-normal-DakdToA3.woff2 +0 -0
  78. alphadesk/app/static/assets/ibm-plex-sans-latin-ext-600-normal-DIrixKbi.woff +0 -0
  79. alphadesk/app/static/assets/ibm-plex-sans-latin-ext-600-normal-DOrvGEcy.woff2 +0 -0
  80. alphadesk/app/static/assets/ibm-plex-sans-vietnamese-400-normal-DG4YqDda.woff2 +0 -0
  81. alphadesk/app/static/assets/ibm-plex-sans-vietnamese-400-normal-fK1oJ5dG.woff +0 -0
  82. alphadesk/app/static/assets/ibm-plex-sans-vietnamese-500-normal-BEb3_waV.woff +0 -0
  83. alphadesk/app/static/assets/ibm-plex-sans-vietnamese-500-normal-e4dixQRQ.woff2 +0 -0
  84. alphadesk/app/static/assets/ibm-plex-sans-vietnamese-600-normal-DgdngZtN.woff +0 -0
  85. alphadesk/app/static/assets/ibm-plex-sans-vietnamese-600-normal-DpPYBSTl.woff2 +0 -0
  86. alphadesk/app/static/assets/index-LlFeul9Q.css +2 -0
  87. alphadesk/app/static/assets/index-WmwcDgNM.js +12 -0
  88. alphadesk/app/static/assets/indicators-epcKBeyS.js +1 -0
  89. alphadesk/app/static/assets/jsx-runtime-DUAcabCT.js +1 -0
  90. alphadesk/app/static/assets/legal-Djpb1OsS.js +1 -0
  91. alphadesk/app/static/assets/live-Zo6aYIWu.js +1 -0
  92. alphadesk/app/static/assets/market-CBOwi-aq.js +1 -0
  93. alphadesk/app/static/assets/newsClock-CuRm4aSM.js +1 -0
  94. alphadesk/app/static/assets/olderNews-BKzmY_JB.js +1 -0
  95. alphadesk/app/static/assets/search-D1dJQn0P.js +1 -0
  96. alphadesk/app/static/assets/vendors-B8n10WJm.js +1 -0
  97. alphadesk/app/static/assets/x-CZJ1eVbC.js +1 -0
  98. alphadesk/app/static/favicon.svg +1 -0
  99. alphadesk/app/static/index.html +66 -0
  100. alphadesk/app/static/landing/chart.jpg +0 -0
  101. alphadesk/app/static/landing/earnings.jpg +0 -0
  102. alphadesk/app/static/landing/markets.jpg +0 -0
  103. alphadesk/app/static/landing/news.jpg +0 -0
  104. alphadesk/app/static/landing/options.jpg +0 -0
  105. alphadesk/app/static/llms.txt +65 -0
  106. alphadesk/app/static/robots.txt +9 -0
  107. alphadesk/app/static/sitemap.xml +8 -0
  108. alphadesk/app/static/third-party-notices.txt +538 -0
  109. alphadesk/billing.py +335 -0
  110. alphadesk/config.py +652 -0
  111. alphadesk/cryptonews.py +78 -0
  112. alphadesk/deploy/env.example +96 -0
  113. alphadesk/desk/__init__.py +0 -0
  114. alphadesk/desk/filings.py +66 -0
  115. alphadesk/desk/screener.py +135 -0
  116. alphadesk/desk/today.py +150 -0
  117. alphadesk/desk/transcripts.py +118 -0
  118. alphadesk/extwidgets.py +266 -0
  119. alphadesk/identity.py +37 -0
  120. alphadesk/ingest/__init__.py +0 -0
  121. alphadesk/ingest/analysts.py +61 -0
  122. alphadesk/ingest/background_fill.py +72 -0
  123. alphadesk/ingest/calendar_accuracy.py +98 -0
  124. alphadesk/ingest/coingecko.py +102 -0
  125. alphadesk/ingest/company.py +485 -0
  126. alphadesk/ingest/compare.py +52 -0
  127. alphadesk/ingest/corporate_actions.py +125 -0
  128. alphadesk/ingest/corporate_calendars.py +283 -0
  129. alphadesk/ingest/earnings.py +172 -0
  130. alphadesk/ingest/earnings_announcements.py +421 -0
  131. alphadesk/ingest/earnings_calendar.py +1050 -0
  132. alphadesk/ingest/earnings_record.py +80 -0
  133. alphadesk/ingest/economic.py +48 -0
  134. alphadesk/ingest/edgar.py +545 -0
  135. alphadesk/ingest/edgar_financials.py +136 -0
  136. alphadesk/ingest/edgar_releases.py +363 -0
  137. alphadesk/ingest/events.py +82 -0
  138. alphadesk/ingest/funds.py +37 -0
  139. alphadesk/ingest/insider.py +166 -0
  140. alphadesk/ingest/keystats.py +58 -0
  141. alphadesk/ingest/movers.py +531 -0
  142. alphadesk/ingest/news.py +372 -0
  143. alphadesk/ingest/options.py +68 -0
  144. alphadesk/ingest/options_flow.py +371 -0
  145. alphadesk/ingest/ownership.py +26 -0
  146. alphadesk/ingest/prices.py +734 -0
  147. alphadesk/ingest/related_funds.py +185 -0
  148. alphadesk/ingest/release_times.py +109 -0
  149. alphadesk/ingest/secfacts.py +91 -0
  150. alphadesk/ingest/sectors.py +305 -0
  151. alphadesk/ingest/stream.py +685 -0
  152. alphadesk/ledger/__init__.py +0 -0
  153. alphadesk/ledger/db.py +217 -0
  154. alphadesk/ledger/store.py +1926 -0
  155. alphadesk/ledger/vault.py +80 -0
  156. alphadesk/main.py +359 -0
  157. alphadesk/mcp_server.py +1070 -0
  158. alphadesk/net.py +69 -0
  159. alphadesk/newsquery.py +175 -0
  160. alphadesk/prewarm.py +185 -0
  161. alphadesk/providers/__init__.py +25 -0
  162. alphadesk/providers/alpaca.py +1591 -0
  163. alphadesk/providers/base.py +293 -0
  164. alphadesk/providers/builtin.py +6 -0
  165. alphadesk/providers/catalogue.py +200 -0
  166. alphadesk/providers/company_vendors.py +1185 -0
  167. alphadesk/providers/news.py +684 -0
  168. alphadesk/providers/prices.py +1286 -0
  169. alphadesk/providers/registry.py +435 -0
  170. alphadesk/providers/transcripts.py +268 -0
  171. alphadesk/semantic.py +335 -0
  172. alphadesk-0.2.0.dist-info/METADATA +694 -0
  173. alphadesk-0.2.0.dist-info/RECORD +176 -0
  174. alphadesk-0.2.0.dist-info/WHEEL +5 -0
  175. alphadesk-0.2.0.dist-info/licenses/LICENSE +672 -0
  176. 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