pinecall-runtime 0.1.6__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 (328) hide show
  1. pinecall/__init__.py +0 -0
  2. pinecall/channels/__init__.py +0 -0
  3. pinecall/channels/_chooser.py +45 -0
  4. pinecall/channels/offers.py +219 -0
  5. pinecall/channels/rooms.py +176 -0
  6. pinecall/channels/routes.py +275 -0
  7. pinecall/channels/telephony/__init__.py +0 -0
  8. pinecall/channels/telephony/_twilio.py +349 -0
  9. pinecall/channels/telephony/carrier.py +229 -0
  10. pinecall/channels/telephony/carrier_catalog.py +92 -0
  11. pinecall/channels/telephony/carriers.csv +41 -0
  12. pinecall/channels/telephony/dialing.py +419 -0
  13. pinecall/channels/telephony/firewall.py +58 -0
  14. pinecall/channels/telephony/hand_over.py +78 -0
  15. pinecall/channels/telephony/number_path.py +176 -0
  16. pinecall/channels/telephony/numbers.py +530 -0
  17. pinecall/channels/telephony/origination.py +77 -0
  18. pinecall/channels/telephony/sip.py +448 -0
  19. pinecall/channels/whatsapp.py +413 -0
  20. pinecall/cli/__init__.py +0 -0
  21. pinecall/cli/_archive.py +85 -0
  22. pinecall/cli/_drift.py +38 -0
  23. pinecall/cli/_facts.py +67 -0
  24. pinecall/cli/_load.py +433 -0
  25. pinecall/cli/_operator.py +626 -0
  26. pinecall/cli/_sessions.py +164 -0
  27. pinecall/cli/_traceback.py +95 -0
  28. pinecall/cli/_usage.py +34 -0
  29. pinecall/cli/main.py +521 -0
  30. pinecall/domain/__init__.py +0 -0
  31. pinecall/domain/agent.py +446 -0
  32. pinecall/domain/call.py +152 -0
  33. pinecall/domain/country_codes.py +32 -0
  34. pinecall/domain/errors.py +151 -0
  35. pinecall/domain/names.py +115 -0
  36. pinecall/domain/org.py +120 -0
  37. pinecall/domain/person.py +208 -0
  38. pinecall/domain/scope.py +21 -0
  39. pinecall/evals/__init__.py +0 -0
  40. pinecall/evals/_evidence.py +158 -0
  41. pinecall/evals/calibration.py +112 -0
  42. pinecall/evals/callers.py +160 -0
  43. pinecall/evals/case.py +327 -0
  44. pinecall/evals/checks.py +437 -0
  45. pinecall/evals/compliance.py +197 -0
  46. pinecall/evals/dataset.py +451 -0
  47. pinecall/evals/goldens.py +331 -0
  48. pinecall/evals/judges.py +525 -0
  49. pinecall/evals/runs.py +252 -0
  50. pinecall/evals/spoken.py +356 -0
  51. pinecall/fleet/__init__.py +0 -0
  52. pinecall/fleet/client.py +569 -0
  53. pinecall/fleet/demand.py +51 -0
  54. pinecall/fleet/heartbeat.py +194 -0
  55. pinecall/fleet/measures.py +157 -0
  56. pinecall/fleet/roster.py +232 -0
  57. pinecall/fleet/worlds.py +44 -0
  58. pinecall/gateway/__init__.py +0 -0
  59. pinecall/gateway/_call_setup.py +53 -0
  60. pinecall/gateway/_deps.py +556 -0
  61. pinecall/gateway/_gateway.py +75 -0
  62. pinecall/gateway/_served.py +396 -0
  63. pinecall/gateway/_sockets.py +444 -0
  64. pinecall/gateway/_streams.py +83 -0
  65. pinecall/gateway/_text_calls.py +182 -0
  66. pinecall/gateway/api/__init__.py +0 -0
  67. pinecall/gateway/api/accounts.py +324 -0
  68. pinecall/gateway/api/agents.py +150 -0
  69. pinecall/gateway/api/apps.py +478 -0
  70. pinecall/gateway/api/box.py +658 -0
  71. pinecall/gateway/api/calibration.py +58 -0
  72. pinecall/gateway/api/callbacks.py +105 -0
  73. pinecall/gateway/api/calls.py +655 -0
  74. pinecall/gateway/api/chat.py +223 -0
  75. pinecall/gateway/api/dataset.py +163 -0
  76. pinecall/gateway/api/desk.py +113 -0
  77. pinecall/gateway/api/entries.py +77 -0
  78. pinecall/gateway/api/evals.py +647 -0
  79. pinecall/gateway/api/fleet.py +73 -0
  80. pinecall/gateway/api/hosting.py +241 -0
  81. pinecall/gateway/api/judges.py +97 -0
  82. pinecall/gateway/api/keys.py +110 -0
  83. pinecall/gateway/api/line.py +142 -0
  84. pinecall/gateway/api/members.py +197 -0
  85. pinecall/gateway/api/metrics.py +226 -0
  86. pinecall/gateway/api/numbers.py +476 -0
  87. pinecall/gateway/api/ops.py +428 -0
  88. pinecall/gateway/api/org.py +283 -0
  89. pinecall/gateway/api/personas.py +157 -0
  90. pinecall/gateway/api/pipeline.py +206 -0
  91. pinecall/gateway/api/prompts.py +50 -0
  92. pinecall/gateway/api/providers.py +240 -0
  93. pinecall/gateway/api/recordings.py +89 -0
  94. pinecall/gateway/api/relay.py +169 -0
  95. pinecall/gateway/api/retrieval.py +403 -0
  96. pinecall/gateway/api/runner.py +109 -0
  97. pinecall/gateway/api/sessions.py +102 -0
  98. pinecall/gateway/api/settings.py +463 -0
  99. pinecall/gateway/api/signup.py +164 -0
  100. pinecall/gateway/api/sso_login.py +168 -0
  101. pinecall/gateway/api/threads.py +187 -0
  102. pinecall/gateway/api/usage.py +303 -0
  103. pinecall/gateway/api/visitors.py +222 -0
  104. pinecall/gateway/api/whatsapp.py +48 -0
  105. pinecall/gateway/api/widget.py +49 -0
  106. pinecall/gateway/app.py +504 -0
  107. pinecall/gateway/calls/__init__.py +0 -0
  108. pinecall/gateway/calls/binding.py +63 -0
  109. pinecall/gateway/calls/commands.py +18 -0
  110. pinecall/gateway/calls/inbox.py +100 -0
  111. pinecall/gateway/calls/known.py +23 -0
  112. pinecall/gateway/calls/owners.py +87 -0
  113. pinecall/gateway/calls/pump.py +33 -0
  114. pinecall/gateway/calls/serving.py +118 -0
  115. pinecall/gateway/calls/threads.py +367 -0
  116. pinecall/gateway/dispatching/__init__.py +0 -0
  117. pinecall/gateway/dispatching/arrivals.py +58 -0
  118. pinecall/gateway/dispatching/sweep.py +43 -0
  119. pinecall/gateway/ending/__init__.py +0 -0
  120. pinecall/gateway/ending/reaper.py +160 -0
  121. pinecall/gateway/ending/seal.py +312 -0
  122. pinecall/gateway/ending/stranded.py +117 -0
  123. pinecall/log/__init__.py +0 -0
  124. pinecall/log/_histogram.py +74 -0
  125. pinecall/log/_relay.py +259 -0
  126. pinecall/log/_writer.py +454 -0
  127. pinecall/log/days.py +154 -0
  128. pinecall/log/drift.py +550 -0
  129. pinecall/log/facts.py +358 -0
  130. pinecall/log/inbox.py +169 -0
  131. pinecall/log/lists.py +155 -0
  132. pinecall/log/logs.py +581 -0
  133. pinecall/log/openings.py +71 -0
  134. pinecall/log/private.py +135 -0
  135. pinecall/log/queries.py +229 -0
  136. pinecall/log/readers.py +207 -0
  137. pinecall/log/reduce.py +659 -0
  138. pinecall/log/refold.py +217 -0
  139. pinecall/log/store.py +370 -0
  140. pinecall/log/usage.py +242 -0
  141. pinecall/postgres/__init__.py +0 -0
  142. pinecall/postgres/migrate.py +202 -0
  143. pinecall/postgres/migrations/0001_schema.sql +591 -0
  144. pinecall/postgres/migrations/0002_worlds.sql +15 -0
  145. pinecall/postgres/migrations/0003_lent.sql +4 -0
  146. pinecall/postgres/migrations/0004_carriers.sql +23 -0
  147. pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
  148. pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
  149. pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
  150. pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
  151. pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
  152. pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
  153. pinecall/postgres/migrations/0011_erasure.sql +32 -0
  154. pinecall/postgres/migrations/0012_org_policy.sql +10 -0
  155. pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
  156. pinecall/postgres/migrations/0014_consents.sql +24 -0
  157. pinecall/postgres/migrations/0015_disclosure.sql +7 -0
  158. pinecall/postgres/migrations/0016_call_records.sql +21 -0
  159. pinecall/postgres/migrations/0017_reads.sql +17 -0
  160. pinecall/postgres/migrations/0018_hosting.sql +41 -0
  161. pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
  162. pinecall/postgres/migrations/0020_written_once.sql +7 -0
  163. pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
  164. pinecall/postgres/migrations/0022_sealed.sql +39 -0
  165. pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
  166. pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
  167. pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
  168. pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
  169. pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
  170. pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
  171. pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
  172. pinecall/postgres/migrations/0060_drift.sql +62 -0
  173. pinecall/postgres/migrations/0061_canaries.sql +24 -0
  174. pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
  175. pinecall/postgres/migrations/0063_prompts.sql +16 -0
  176. pinecall/postgres/migrations/0075_call_private.sql +14 -0
  177. pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
  178. pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
  179. pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
  180. pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
  181. pinecall/postgres/migrations/0080_call_openings.sql +20 -0
  182. pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
  183. pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
  184. pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
  185. pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
  186. pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
  187. pinecall/postgres/migrations/0086_route_origin.sql +17 -0
  188. pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
  189. pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
  190. pinecall/postgres/migrations/0089_offers.sql +21 -0
  191. pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
  192. pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
  193. pinecall/postgres/migrations/0092_key_parent.sql +6 -0
  194. pinecall/postgres/migrations/0093_code_tries.sql +10 -0
  195. pinecall/postgres/migrations/0094_route_approval.sql +12 -0
  196. pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
  197. pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
  198. pinecall/postgres/migrations/0097_case_status.sql +19 -0
  199. pinecall/postgres/pool.py +190 -0
  200. pinecall/process/__init__.py +0 -0
  201. pinecall/process/_objects.py +97 -0
  202. pinecall/process/box_settings.py +23 -0
  203. pinecall/process/connections.py +124 -0
  204. pinecall/process/metrics.py +127 -0
  205. pinecall/process/recordings.py +323 -0
  206. pinecall/process/resolver.py +41 -0
  207. pinecall/process/sealed_audio.py +173 -0
  208. pinecall/process/settings.py +534 -0
  209. pinecall/process/shared.py +158 -0
  210. pinecall/process/signal.py +416 -0
  211. pinecall/providers/__init__.py +0 -0
  212. pinecall/providers/build.py +570 -0
  213. pinecall/providers/catalog.py +266 -0
  214. pinecall/providers/credentials.py +230 -0
  215. pinecall/providers/declared.py +69 -0
  216. pinecall/providers/prices.py +240 -0
  217. pinecall/providers/tried.py +104 -0
  218. pinecall/providers/voices.py +174 -0
  219. pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
  220. pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
  221. pinecall/public/console/favicon.png +0 -0
  222. pinecall/public/console/index.html +20 -0
  223. pinecall/public/console/logo-mark.png +0 -0
  224. pinecall/public/console/pinecall-logo-dark.png +0 -0
  225. pinecall/public/console/pinecall-logo.png +0 -0
  226. pinecall/public/console/pinecall-mark.png +0 -0
  227. pinecall/public/console/sw.js +37 -0
  228. pinecall/public/widget/pinecall-widget.js +853 -0
  229. pinecall/py.typed +0 -0
  230. pinecall/retrieval/__init__.py +0 -0
  231. pinecall/retrieval/_search.py +186 -0
  232. pinecall/retrieval/_sections.py +70 -0
  233. pinecall/retrieval/embed.py +340 -0
  234. pinecall/retrieval/extraction.py +403 -0
  235. pinecall/retrieval/knowledge.py +659 -0
  236. pinecall/retrieval/lookups.py +192 -0
  237. pinecall/retrieval/memory.py +603 -0
  238. pinecall/runner/__init__.py +0 -0
  239. pinecall/runner/_kube.py +459 -0
  240. pinecall/runner/_plan.py +157 -0
  241. pinecall/runner/main.py +306 -0
  242. pinecall/session/__init__.py +0 -0
  243. pinecall/session/_agent.py +141 -0
  244. pinecall/session/_hearing.py +101 -0
  245. pinecall/session/_livekit.py +195 -0
  246. pinecall/session/_prompt.py +147 -0
  247. pinecall/session/_recording.py +237 -0
  248. pinecall/session/call.py +367 -0
  249. pinecall/session/clock.py +40 -0
  250. pinecall/session/hold.py +239 -0
  251. pinecall/session/room.py +626 -0
  252. pinecall/session/session.py +690 -0
  253. pinecall/session/text.py +144 -0
  254. pinecall/session/tools.py +483 -0
  255. pinecall/session/voice.py +118 -0
  256. pinecall/session/widget.py +201 -0
  257. pinecall/tenancy/__init__.py +0 -0
  258. pinecall/tenancy/admission.py +204 -0
  259. pinecall/tenancy/agents.py +164 -0
  260. pinecall/tenancy/canary.py +126 -0
  261. pinecall/tenancy/carrier_networks.py +156 -0
  262. pinecall/tenancy/carriers.py +265 -0
  263. pinecall/tenancy/codes.py +196 -0
  264. pinecall/tenancy/consents.py +167 -0
  265. pinecall/tenancy/dial_policy.py +405 -0
  266. pinecall/tenancy/disclosure.py +41 -0
  267. pinecall/tenancy/erasure.py +204 -0
  268. pinecall/tenancy/export.py +167 -0
  269. pinecall/tenancy/hosted_running.py +166 -0
  270. pinecall/tenancy/hosting.py +475 -0
  271. pinecall/tenancy/judges.py +126 -0
  272. pinecall/tenancy/keys.py +467 -0
  273. pinecall/tenancy/knocks.py +40 -0
  274. pinecall/tenancy/letters.py +378 -0
  275. pinecall/tenancy/mail.py +458 -0
  276. pinecall/tenancy/org_secrets.py +123 -0
  277. pinecall/tenancy/orgs.py +78 -0
  278. pinecall/tenancy/people.py +543 -0
  279. pinecall/tenancy/personas.py +145 -0
  280. pinecall/tenancy/policy.py +65 -0
  281. pinecall/tenancy/prompts.py +48 -0
  282. pinecall/tenancy/reads.py +73 -0
  283. pinecall/tenancy/recording_keys.py +41 -0
  284. pinecall/tenancy/remembered.py +148 -0
  285. pinecall/tenancy/retention.py +86 -0
  286. pinecall/tenancy/scopes.py +406 -0
  287. pinecall/tenancy/signin.py +622 -0
  288. pinecall/tenancy/spend.py +69 -0
  289. pinecall/tenancy/sso.py +431 -0
  290. pinecall/tenancy/sso_domains.py +132 -0
  291. pinecall/tenancy/throttle.py +88 -0
  292. pinecall/tenancy/tokens.py +400 -0
  293. pinecall/tenancy/traceback.py +70 -0
  294. pinecall/tenancy/usage.py +136 -0
  295. pinecall/tenancy/vault.py +204 -0
  296. pinecall/tenancy/words.py +144 -0
  297. pinecall/wire/__init__.py +0 -0
  298. pinecall/wire/commands.py +350 -0
  299. pinecall/wire/events.py +655 -0
  300. pinecall/wire/frames.py +65 -0
  301. pinecall/wire/metrics.py +287 -0
  302. pinecall/wire/parts.py +320 -0
  303. pinecall/wire/rest/__init__.py +0 -0
  304. pinecall/wire/rest/accounts.py +512 -0
  305. pinecall/wire/rest/agents.py +172 -0
  306. pinecall/wire/rest/calls.py +397 -0
  307. pinecall/wire/rest/evals.py +453 -0
  308. pinecall/wire/rest/fleet.py +69 -0
  309. pinecall/wire/rest/hosting.py +174 -0
  310. pinecall/wire/rest/numbers.py +280 -0
  311. pinecall/wire/rest/ops.py +342 -0
  312. pinecall/wire/rest/providers.py +83 -0
  313. pinecall/wire/rest/retrieval.py +301 -0
  314. pinecall/wire/rest/settings.py +184 -0
  315. pinecall/wire/rest/usage.py +245 -0
  316. pinecall/wire/room.py +61 -0
  317. pinecall/wire/scores.py +49 -0
  318. pinecall/wire/state.py +243 -0
  319. pinecall/worker/__init__.py +0 -0
  320. pinecall/worker/_job.py +655 -0
  321. pinecall/worker/_recorder.py +82 -0
  322. pinecall/worker/_traces.py +48 -0
  323. pinecall/worker/main.py +307 -0
  324. pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
  325. pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
  326. pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
  327. pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
  328. pinecall_runtime-0.1.6.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,622 @@
1
+ """Sign-in: a password, a one-use code, a paired terminal, a sign-up, or the org's provider."""
2
+
3
+ import hashlib
4
+ import hmac
5
+ import ipaddress
6
+ import re
7
+ import secrets
8
+ from dataclasses import dataclass, replace
9
+ from typing import Literal
10
+
11
+ import httpx
12
+ from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
13
+
14
+ from pinecall.domain.errors import (
15
+ NotAllowed,
16
+ NotSignedIn,
17
+ )
18
+ from pinecall.domain.names import PRODUCTION
19
+ from pinecall.domain.org import Org
20
+ from pinecall.domain.person import KEY_SCOPES, Key, Member
21
+ from pinecall.postgres.pool import Pool
22
+ from pinecall.tenancy.keys import Issued, issue, person_key
23
+ from pinecall.tenancy.knocks import Throttle
24
+ from pinecall.tenancy.letters import Link, brand_of, card_link, forgotten_password_letter
25
+ from pinecall.tenancy.mail import Outbox
26
+ from pinecall.tenancy.orgs import create, find
27
+ from pinecall.tenancy.people import (
28
+ Invitee,
29
+ by_email,
30
+ invite,
31
+ join,
32
+ matches,
33
+ orgs_of,
34
+ password_of,
35
+ reset,
36
+ seat_founder,
37
+ )
38
+ from pinecall.tenancy.words import OneUse, Words
39
+
40
+ type Refusal = Literal["wrong", "expired", "burned"]
41
+
42
+
43
+ # What a login code signs a browser in as: a person, or the server's key that asked for it.
44
+ type Holder = Member | Key
45
+
46
+
47
+ # A browser signs in with a code, never a key in the URL: a URL lands in histories and logs.
48
+ LOGIN_CODE = ("lc_", 300.0)
49
+
50
+
51
+ # Ten minutes to open a browser and sign in.
52
+ PAIRING = ("cli_", 600.0)
53
+
54
+
55
+ # Only the opaque state rides the URL; the org, the nonce and the verifier stay here.
56
+ HANDSHAKE = ("st_", 600.0)
57
+
58
+
59
+ # One sentence for a wrong org, address or password: nobody learns who is a member.
60
+ NOBODY = "nobody answers to that email and password"
61
+
62
+
63
+ # These are said only once the password matched, so a stranger learns nothing from them.
64
+ NOT_YET = "{email} has not accepted their invitation yet: open the link and choose a password"
65
+
66
+
67
+ DISABLED = "{email} is disabled in {org}"
68
+
69
+
70
+ WITH_THE_PROVIDER = "{org} signs in with its identity provider: open /v1/login/sso?org={org}"
71
+
72
+
73
+ NOT_A_MEMBER = "the person this key was minted for is no longer an active member of this org"
74
+
75
+
76
+ NOT_A_PERSONS = "a server's token names nobody: a person's key signs another device in"
77
+
78
+
79
+ NOT_THEIRS = "{org} is not an org of this person's"
80
+
81
+
82
+ NOT_AN_ISSUER = "{issuer} does not answer {path}: it is not an OpenID provider, or it is down"
83
+
84
+
85
+ NOT_REACHED = (
86
+ "{issuer}'s {field} is {url!r}: an endpoint is https, at a public name, never an address"
87
+ )
88
+
89
+
90
+ NOT_ITS_OWN_ISSUER = "{issuer} publishes a configuration naming {said}: one of the two is wrong"
91
+
92
+
93
+ MISSING = "{issuer}'s configuration names no {field}"
94
+
95
+
96
+ NO_GRANT = "{issuer} refused the code connections: {said}"
97
+
98
+
99
+ NO_IDENTITY = "{issuer} answered the connections without an id_token"
100
+
101
+
102
+ NO_KEY = "{issuer} signed the id_token with a key its JWKS does not publish"
103
+
104
+
105
+ REFUSED = "the id_token did not check out: {said}"
106
+
107
+
108
+ ANOTHER_SIGN_IN = "the id_token answers a different sign-in: its nonce is not this one's"
109
+
110
+
111
+ NOT_THIS_CLIENTS = "the id_token was issued to {azp!r}: this gateway is {client_id!r}"
112
+
113
+
114
+ NO_EMAIL = "{issuer} vouched for somebody it gave no email address for"
115
+
116
+
117
+ NOT_VERIFIED = "{issuer} has not verified {email}: nobody is seated on an unverified address"
118
+
119
+
120
+ NOBODY_HERE = "nobody in {org} answers to {email}, and it seats nobody it was not told to"
121
+
122
+
123
+ CONFIGURATION = "/.well-known/openid-configuration"
124
+
125
+
126
+ SCOPE = "openid email profile"
127
+
128
+
129
+ # Asymmetric only: `none`, and HMAC forged with a secret this gateway holds, never pass.
130
+ ALGORITHMS = ["RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512"]
131
+
132
+
133
+ REQUIRED = ["iss", "aud", "exp", "iat", "sub"]
134
+
135
+
136
+ TIMEOUT_S = 5.0
137
+
138
+
139
+ # Only the verifier's sha256 crosses the browser, so a stolen code is worth nothing.
140
+ VERIFIER_BYTES = 32
141
+
142
+
143
+ NONCE_BYTES = 24
144
+
145
+
146
+ # The issuer and the endpoints are the tenant's to write, so only https at a public name is
147
+ # fetched: never an address, never a local name.
148
+ # `.svc`: a cluster's own services, `<service>.<namespace>.svc`.
149
+ LOCAL_SUFFIXES = (".localhost", ".local", ".internal", ".arpa", ".home", ".lan", ".svc")
150
+
151
+
152
+ # No query or fragment: it is compared with every id_token's `iss` as a string.
153
+ AN_ISSUER = re.compile(r"^https://[^\s?#]+[^/\s?#]$")
154
+
155
+
156
+ A_DOMAIN = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$")
157
+
158
+
159
+ # Six tries at six digits is a guess in 166 666; a code burned is sent again.
160
+ CODE_DIGITS = 6
161
+
162
+
163
+ CODE_TTL_S = 15 * 60.0
164
+
165
+
166
+ ATTEMPTS = 6
167
+
168
+
169
+ A_BROWSER = "console"
170
+
171
+
172
+ SIGNED_UP = "signup"
173
+
174
+
175
+ SSO = """
176
+ SELECT org, issuer, client_id, ciphertext, domains, role, required FROM org_sso WHERE org = %(org)s
177
+ """
178
+
179
+
180
+ # A domain offered is one the org proved its own (sso_domain_proofs, tenancy/sso_domains.py).
181
+ SSO_WITH_DOMAIN = """
182
+ SELECT org_sso.org, issuer, client_id, ciphertext, domains, role, required FROM org_sso
183
+ JOIN sso_domain_proofs AS proof
184
+ ON proof.org = org_sso.org AND proof.domain = %(domain)s AND proof.verified_at IS NOT NULL
185
+ WHERE %(domain)s = ANY (domains) ORDER BY set_at, org_sso.org
186
+ """
187
+
188
+
189
+ SSO_ONLY = "SELECT 1 FROM org_sso WHERE org = %(org)s AND required"
190
+
191
+
192
+ # Replaced whole, and no secret's history is kept.
193
+ PUT_SSO = """
194
+ INSERT INTO org_sso (org, issuer, client_id, ciphertext, domains, role, required)
195
+ VALUES (%(org)s, %(issuer)s, %(client_id)s, %(ciphertext)s, %(domains)s, %(role)s, %(required)s)
196
+ ON CONFLICT (org) DO UPDATE SET
197
+ issuer = excluded.issuer, client_id = excluded.client_id, ciphertext = excluded.ciphertext,
198
+ domains = excluded.domains, role = excluded.role, required = excluded.required,
199
+ set_at = now()
200
+ """
201
+
202
+
203
+ DROP_SSO = "DELETE FROM org_sso WHERE org = %(org)s RETURNING org"
204
+
205
+
206
+ # Every attempt counts, not only the misses: counting misses would say which guess was right.
207
+ TRIES = 5
208
+
209
+
210
+ TOO_MANY = "too many attempts for {email}: try again in a minute"
211
+
212
+
213
+ class ProviderRefusal(BaseModel):
214
+ """What an identity provider says when it refuses: its error and the words it gives."""
215
+
216
+ model_config = ConfigDict(extra="ignore")
217
+
218
+ error: str | None = None
219
+ error_description: str | None = None
220
+
221
+
222
+ @dataclass(frozen=True)
223
+ class SignedIn:
224
+ """The key a sign-in minted, handed over once, and the person it is for."""
225
+
226
+ key: Key
227
+ secret: str
228
+ member: Member | None = None
229
+
230
+
231
+ @dataclass(frozen=True)
232
+ class Asking:
233
+ """What a person typed: the address, the password, and the org when they have several."""
234
+
235
+ email: str
236
+ password: str
237
+ org: str | None = None
238
+ device: str | None = None
239
+
240
+
241
+ @dataclass
242
+ class WaitingTerminal:
243
+ """A terminal waiting: what it calls itself, and the key a browser gave it once approved."""
244
+
245
+ device: str | None = None
246
+ key: str | None = None
247
+ org: str | None = None
248
+
249
+
250
+ @dataclass(frozen=True)
251
+ class TerminalApproval:
252
+ """What the browser approving a terminal sees: which terminal, until when, and if answered."""
253
+
254
+ device: str | None
255
+ expires_at: float
256
+ answered: bool
257
+
258
+
259
+ @dataclass(frozen=True)
260
+ class Collected:
261
+ """What a terminal collects: the key once, or that it is still waiting."""
262
+
263
+ key: str | None
264
+ # False with no key: dead, collected already, or never opened.
265
+ waiting: bool
266
+
267
+
268
+ @dataclass(frozen=True)
269
+ class Signup:
270
+ """A sign-up waiting for its code: who, the org they name, and their password hashed."""
271
+
272
+ email: str
273
+ slug: str
274
+ person: str
275
+ hashed: str
276
+ name: str | None = None
277
+ device: str | None = None
278
+
279
+
280
+ @dataclass(frozen=True)
281
+ class Pending:
282
+ """A sign-up and its code, kept as a salted hash (hex), sealed with the rest."""
283
+
284
+ signup: Signup
285
+ code_hash: str
286
+ salt: str
287
+
288
+
289
+ @dataclass(frozen=True)
290
+ class Founded:
291
+ """An org just made, its admin, their first key, and the code that opens the console."""
292
+
293
+ org: Org
294
+ admin: Member
295
+ signed_in: SignedIn
296
+ code: str
297
+ code_expires_at: float
298
+
299
+
300
+ @dataclass(frozen=True)
301
+ class Handshake:
302
+ """A sign-in at a provider, in flight: its state, nonce, verifier and where it returns."""
303
+
304
+ state: str
305
+ org: str | None
306
+ nonce: str
307
+ verifier: str
308
+ # The token endpoint compares it with the authorization request's, character for character.
309
+ redirect_uri: str
310
+ # The terminal a `pinecall login` started from, to hand the key to when it returns.
311
+ pairing: str | None = None
312
+
313
+
314
+ # Kept in Postgres (tenancy/words.py), so a terminal paired on one gateway collects on another;
315
+ # the key a browser gives it is sealed there until it is collected, once.
316
+ class Pairings:
317
+ """Terminals waiting for a browser to sign them in."""
318
+
319
+ def __init__(self, words: Words) -> None:
320
+ """No terminal waiting."""
321
+ self.words: OneUse[WaitingTerminal] = OneUse(PAIRING, _WAITING, words)
322
+
323
+ async def open(self, device: str | None = None) -> tuple[str, float]:
324
+ """The word a terminal prints, and when it dies."""
325
+ return await self.words.mint(WaitingTerminal(device))
326
+
327
+ async def asking(self, code: str) -> TerminalApproval | None:
328
+ """What the approving card shows, without spending the word; None for a dead one."""
329
+ minted = await self.words.alive(code)
330
+ if minted is None:
331
+ return None
332
+ return TerminalApproval(
333
+ minted.value.device, minted.expires_at, answered=minted.value.key is not None
334
+ )
335
+
336
+ async def fill(self, code: str, key: str, org: str) -> bool:
337
+ """Hand the terminal the key a browser approved; False when dead or answered already."""
338
+ minted = await self.words.alive(code)
339
+ if minted is None or minted.value.key is not None:
340
+ return False
341
+ return await self.words.fill(code, replace(minted.value, key=key, org=org))
342
+
343
+ async def collect(self, code: str) -> Collected:
344
+ """The key, once, as the terminal asks after it."""
345
+ minted = await self.words.alive(code)
346
+ if minted is None:
347
+ return Collected(key=None, waiting=False)
348
+ if minted.value.key is None:
349
+ return Collected(key=None, waiting=True)
350
+ spent = await self.words.spend(code)
351
+ return Collected(key=None if spent is None else spent.key, waiting=False)
352
+
353
+
354
+ # Kept in Postgres under the address, sealed: a sign-up begun on one gateway is verified on any.
355
+ class Signups:
356
+ """Sign-ups waiting for the six digits that were mailed."""
357
+
358
+ def __init__(self, words: Words) -> None:
359
+ """No sign-up waiting."""
360
+ self.words: OneUse[Pending] = OneUse(SIGNUP, _PENDING, words)
361
+
362
+ # A second sign-up of the address replaces the first: only the newest code works.
363
+ async def begin(self, signup: Signup) -> tuple[str, float]:
364
+ """Keep the sign-up and hand back the code to mail, and when it dies."""
365
+ code = f"{secrets.randbelow(10**CODE_DIGITS):0{CODE_DIGITS}d}"
366
+ salt = secrets.token_bytes(16)
367
+ pending = Pending(signup, _hashed(salt, code).hex(), salt.hex())
368
+ return code, await self.words.keep(_signing_up(signup.email), pending)
369
+
370
+ async def renewed(self, email: str) -> tuple[Signup, str] | None:
371
+ """A new code for a sign-up still waiting, with its time again; None for nobody's."""
372
+ found = await self.words.alive(_signing_up(email))
373
+ if found is None:
374
+ return None
375
+ return found.value.signup, (await self.begin(found.value.signup))[0]
376
+
377
+ async def verify(self, email: str, code: str) -> Signup | Refusal:
378
+ """The sign-up, taken out, when the code is its own; else why not."""
379
+ word = _signing_up(email)
380
+ found = await self.words.read(word)
381
+ if found is None:
382
+ return "wrong"
383
+ if found.expires_at <= self.words.words.clock():
384
+ await self.words.forget(word)
385
+ return "expired"
386
+ if found.attempts >= ATTEMPTS:
387
+ return "burned"
388
+ wanted = _hashed(bytes.fromhex(found.value.salt), code).hex()
389
+ if not hmac.compare_digest(found.value.code_hash, wanted):
390
+ return "burned" if await self.words.tried(word) >= ATTEMPTS else "wrong"
391
+ await self.words.forget(word)
392
+ return found.value.signup
393
+
394
+
395
+ # Kept in Postgres, sealed, so any gateway spends what another minted; the knocks counted there
396
+ # too, so five a minute means five whichever gateway each lands on.
397
+ @dataclass(frozen=True)
398
+ class SignIns:
399
+ """What sign-in keeps between two requests: codes, terminals, sign-ups, handshakes, knocks."""
400
+
401
+ codes: OneUse[Holder]
402
+ pairings: Pairings
403
+ signups: Signups
404
+ handshakes: OneUse[Handshake]
405
+ throttle: Throttle
406
+
407
+ @classmethod
408
+ def kept(cls, words: Words) -> "SignIns":
409
+ """Every word kept in the words' table, every word and knock timed by their clock."""
410
+ return cls(
411
+ codes=OneUse(LOGIN_CODE, _HOLDER, words),
412
+ pairings=Pairings(words),
413
+ signups=Signups(words),
414
+ handshakes=OneUse(HANDSHAKE, _HANDSHAKE, words),
415
+ throttle=Throttle(words.pool, TRIES, words.clock),
416
+ )
417
+
418
+
419
+ # A sign-up is kept under its address, as long as its code lives.
420
+ SIGNUP = ("su_", CODE_TTL_S)
421
+ _WAITING: TypeAdapter[WaitingTerminal] = TypeAdapter(WaitingTerminal)
422
+ _PENDING: TypeAdapter[Pending] = TypeAdapter(Pending)
423
+ _HANDSHAKE: TypeAdapter[Handshake] = TypeAdapter(Handshake)
424
+ _HOLDER: TypeAdapter[Holder] = TypeAdapter(Holder)
425
+
426
+
427
+ async def sign_in_with_password(pool: Pool, asking: Asking) -> SignedIn:
428
+ """A person's key for the address and password; the org named, or the one a password opens."""
429
+ known = await password_of(pool, asking.email)
430
+ if not await matches(asking.password, known) or known is None:
431
+ raise NotSignedIn(NOBODY)
432
+ member = await _row_for(pool, asking)
433
+ if member is None:
434
+ raise NotSignedIn(NOBODY)
435
+ if await _sso_only(pool, member.org):
436
+ raise NotAllowed(WITH_THE_PROVIDER.format(org=await _slug(pool, member.org)))
437
+ if member.status == "disabled":
438
+ raise NotAllowed(DISABLED.format(email=member.email, org=await _slug(pool, member.org)))
439
+ if member.status == "invited":
440
+ # A password set through a link an admin handed over could be anybody's, so only a
441
+ # proven address joins with the password it has.
442
+ seated_now = await join(pool, member.org, member.id, known) if member.verified else None
443
+ if seated_now is None:
444
+ raise NotAllowed(NOT_YET.format(email=member.email))
445
+ member = seated_now
446
+ key, secret = await person_key(pool, member, label=asking.device)
447
+ return SignedIn(key, secret, member)
448
+
449
+
450
+ async def orgs_signed_into(pool: Pool, email: str, password: str) -> list[Member]:
451
+ """The orgs an address and password open, minting nothing; none for a wrong password."""
452
+ if not await matches(password, await password_of(pool, email)):
453
+ return []
454
+ return [item for item in await orgs_of(pool, email) if item.status != "disabled"]
455
+
456
+
457
+ # A key's code gives a copy of it, the person it names included, dying when it dies.
458
+ async def sign_in_with_code(
459
+ pool: Pool, codes: OneUse[Holder], code: str, *, device: str | None = None
460
+ ) -> SignedIn | None:
461
+ """The key the code was minted for, spent; None for a code dead, spent or nobody's."""
462
+ holder = await codes.spend(code)
463
+ if holder is None:
464
+ return None
465
+ label = device or A_BROWSER
466
+ if isinstance(holder, Member):
467
+ key, secret = await person_key(pool, holder, label=label)
468
+ return SignedIn(key, secret, holder)
469
+ copied = Issued(
470
+ org=holder.org,
471
+ env=holder.env,
472
+ scopes=holder.scopes,
473
+ label=label,
474
+ subject=holder.subject,
475
+ name=holder.name,
476
+ created_by=holder.subject,
477
+ expires_at=holder.expires_at,
478
+ parent=holder.key_id,
479
+ )
480
+ key, secret = await issue(pool, copied)
481
+ return SignedIn(key, secret)
482
+
483
+
484
+ # The scopes are the member's role today, not the asking key's.
485
+ async def another_key(
486
+ pool: Pool, key: Key, member: Member | None, *, device: str | None = None
487
+ ) -> SignedIn:
488
+ """The same person's key for another device, never outliving the key that asked."""
489
+ if key.subject is None or member is None:
490
+ raise NotAllowed(NOT_A_PERSONS)
491
+ if member.status != "active":
492
+ raise NotAllowed(NOT_A_MEMBER)
493
+ minted, secret = await person_key(pool, member, parent=key, label=device)
494
+ return SignedIn(minted, secret, member)
495
+
496
+
497
+ # A person of the box is let into any org, as an admin; anybody else only into an org of theirs.
498
+ async def key_in(pool: Pool, member: Member, org: str, *, label: str | None = None) -> SignedIn:
499
+ """The person's key in another org: their membership there, or a visit if they run the box."""
500
+ there = await by_email(pool, org, member.email)
501
+ # Opening an org one was invited to is taking the seat: the person chose it, by its name.
502
+ if there is not None and there.status == "invited" and there.verified:
503
+ there = await _joined(pool, there) or there
504
+ if there is not None and there.status == "active":
505
+ key, secret = await person_key(pool, there, label=label)
506
+ return SignedIn(key, secret, there)
507
+ if not member.operator or await find(pool, org) is None:
508
+ raise NotAllowed(NOT_THEIRS.format(org=org))
509
+ visit = Issued(
510
+ org=org,
511
+ env=PRODUCTION,
512
+ scopes=KEY_SCOPES,
513
+ label=label or A_BROWSER,
514
+ subject=member.id,
515
+ name=member.name,
516
+ created_by=member.id,
517
+ )
518
+ key, secret = await issue(pool, visit)
519
+ return SignedIn(key, secret, member)
520
+
521
+
522
+ # Answers the same for an address known or not, and mails in the background, so neither the
523
+ # answer nor its time says who is a member. The box's own mailbox carries it, never an org's: the
524
+ # link proves the address (vouched), so the password it sets is the person's one, in every org.
525
+ async def forgotten(pool: Pool, outbox: Outbox, email: str, base: str) -> None:
526
+ """Mail a one-use link that sets the person's password, through the box's own mailbox."""
527
+ # Asked before minting: a reset spends the older links, so an unmailable one would kill a link
528
+ # an admin handed over.
529
+ if await outbox.mailbox_for(None) is None:
530
+ return
531
+ for row in await orgs_of(pool, email):
532
+ if row.status != "active" or await _sso_only(pool, row.org):
533
+ continue
534
+ link = await reset(pool, row.org, row.id, vouched=True)
535
+ if link is None:
536
+ continue
537
+ org = await find(pool, row.org)
538
+ data = Link(
539
+ org=org.name if org else row.org, link=card_link(base, link.token), dies=link.expires_at
540
+ )
541
+ await outbox.post(None, forgotten_password_letter(row.email, data, await brand_of(pool)))
542
+ return
543
+
544
+
545
+ # Only once the address is verified, so a sign-up nobody proves never takes a slug.
546
+ async def found(pool: Pool, signup: Signup, codes: OneUse[Holder]) -> Founded:
547
+ """The org of a verified sign-up, its admin seated, their key, and the console's code."""
548
+ already = len(await orgs_of(pool, signup.email))
549
+ org = await create(pool, signup.slug, signup.name or signup.slug, already=already)
550
+ invited = await invite(pool, org.id, Invitee(signup.email, signup.person, "admin"), seats=None)
551
+ # The founder takes the seat at once, with the password the sign-up proved: one per person.
552
+ admin = await seat_founder(pool, invited.token, signup.hashed) or invited.member
553
+ key, secret = await person_key(pool, admin, label=signup.device or SIGNED_UP)
554
+ code, code_expires_at = await codes.mint(admin)
555
+ return Founded(
556
+ org=org,
557
+ admin=admin,
558
+ signed_in=SignedIn(key, secret, admin),
559
+ code=code,
560
+ code_expires_at=code_expires_at,
561
+ )
562
+
563
+
564
+ # Never the body whole: a token endpoint that echoes the request would show the client secret.
565
+ def why_refused(answer: httpx.Response) -> str:
566
+ """The reason a provider gave for refusing, or its HTTP status when it gave none."""
567
+ try:
568
+ payload = ProviderRefusal.model_validate(answer.json())
569
+ except (ValueError, ValidationError):
570
+ return f"HTTP {answer.status_code}"
571
+ return payload.error_description or payload.error or f"HTTP {answer.status_code}"
572
+
573
+
574
+ def an_address(host: str) -> bool:
575
+ """Whether the host is an IP address rather than a name."""
576
+ try:
577
+ ipaddress.ip_address(host.strip("[]"))
578
+ except ValueError:
579
+ return False
580
+ return True
581
+
582
+
583
+ async def _row_for(pool: Pool, asking: Asking) -> Member | None:
584
+ if asking.org is not None:
585
+ org = await find(pool, asking.org)
586
+ return None if org is None else await by_email(pool, org.id, asking.email)
587
+ rows = await orgs_of(pool, asking.email)
588
+ enabled = [row for row in rows if row.status != "disabled"]
589
+ # A sign-in that names no org opens one the person took: an invitation is taken by naming it.
590
+ taken = [row for row in enabled if row.status == "active"] or enabled
591
+ # An org a password opens first; one that signs in only with its provider is refused after.
592
+ for row in taken:
593
+ if not await _sso_only(pool, row.org):
594
+ return row
595
+ return next(iter(taken), rows[0] if rows else None)
596
+
597
+
598
+ # Seated with the one password the person already has; an org that signs in only with its
599
+ # provider seats nobody this way.
600
+ async def _joined(pool: Pool, invited: Member) -> Member | None:
601
+ known = await password_of(pool, invited.email)
602
+ if known is None or await _sso_only(pool, invited.org):
603
+ return None
604
+ return await join(pool, invited.org, invited.id, known)
605
+
606
+
607
+ async def _sso_only(pool: Pool, org: str) -> bool:
608
+ async with pool.connection() as connection:
609
+ return await (await connection.execute(SSO_ONLY, {"org": org})).fetchone() is not None
610
+
611
+
612
+ async def _slug(pool: Pool, org: str) -> str:
613
+ found_org = await find(pool, org)
614
+ return org if found_org is None else found_org.slug
615
+
616
+
617
+ def _hashed(salt: bytes, code: str) -> bytes:
618
+ return hashlib.sha256(salt + code.encode()).digest()
619
+
620
+
621
+ def _signing_up(email: str) -> str:
622
+ return f"{SIGNUP[0]}{email}"
@@ -0,0 +1,69 @@
1
+ """An org that spends strangely: today against its own trailing four weeks, said once a day."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import UTC, datetime
5
+
6
+ from pinecall.postgres.pool import Pool
7
+
8
+ # Today is unusual once it costs this many times the org's usual day, the mean of its trailing
9
+ # four weeks; a usual day under a dollar is too small to judge, so a new org is never flagged.
10
+ UNUSUAL_MULTIPLE = 3.0
11
+ USUAL_AT_LEAST_USD = 1.0
12
+ TRAILING_DAYS = 28
13
+
14
+ A_DAY_S = 86_400.0
15
+
16
+ # Both worlds and every holder: the dollars are the org's. Read off the facts, which the summary
17
+ # folded its cost into, by the day each call started.
18
+ SPENT = """
19
+ select coalesce(sum(f.cost_usd) filter (where head.started_at >= %(day)s), 0) as today,
20
+ coalesce(sum(f.cost_usd) filter (where head.started_at < %(day)s), 0) as before
21
+ from call_log_head head join call_facts f on f.call = head.log
22
+ where head.org = %(org)s and head.call is not null and head.started_at >= %(day)s - %(trailing)s
23
+ """
24
+
25
+ # Said once a day for the org: on whichever of its agents' logs the first unusual call sealed on,
26
+ # found by the entry's own org, so no head need have been claimed.
27
+ SAID_TODAY = """
28
+ select 1 from call_log entry
29
+ where entry.type = 'spend.unusual' and entry.ts >= %(day)s and entry.data ->> 'org' = %(org)s
30
+ limit 1
31
+ """
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Unusual:
36
+ """Today's spend against the usual day, and how many times it is."""
37
+
38
+ day: str
39
+ today_usd: float
40
+ usual_usd: float
41
+ multiple: float
42
+
43
+
44
+ async def unusual(pool: Pool, org: str, at: float) -> Unusual | None:
45
+ """Today's spend against the org's usual day, when it is over the line; None when usual."""
46
+ day = _day_of(at)
47
+ params = {"org": org, "day": day, "trailing": TRAILING_DAYS * A_DAY_S}
48
+ async with pool.connection() as connection:
49
+ row = await (await connection.execute(SPENT, params)).fetchone()
50
+ if row is None:
51
+ return None
52
+ today, usual = float(row["today"]), float(row["before"]) / TRAILING_DAYS
53
+ if usual < USUAL_AT_LEAST_USD or today < UNUSUAL_MULTIPLE * usual:
54
+ return None
55
+ named = datetime.fromtimestamp(day, UTC).date().isoformat()
56
+ return Unusual(named, round(today, 6), round(usual, 6), round(today / usual, 2))
57
+
58
+
59
+ async def said_today(pool: Pool, org: str, at: float) -> bool:
60
+ """Whether spend.unusual was already written for the org today."""
61
+ params = {"org": org, "day": _day_of(at)}
62
+ async with pool.connection() as connection:
63
+ return await (await connection.execute(SAID_TODAY, params)).fetchone() is not None
64
+
65
+
66
+ def _day_of(at: float) -> float:
67
+ """The UTC midnight the moment falls after, as a timestamp."""
68
+ moment = datetime.fromtimestamp(at, UTC)
69
+ return moment.replace(hour=0, minute=0, second=0, microsecond=0).timestamp()