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,467 @@
1
+ """API keys: minted, revoked, found by fingerprint, and the world and scope a request acts in."""
2
+
3
+ import secrets
4
+ from collections.abc import Sequence
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+
8
+ from psycopg import sql
9
+ from psycopg.rows import DictRow
10
+
11
+ from pinecall.domain.errors import DeclarationRefused, NotAllowed, NotFound, NotSignedIn
12
+ from pinecall.domain.names import ENVS, PRODUCTION, SANDBOX, Env, parse_env
13
+ from pinecall.domain.person import (
14
+ HOLDING,
15
+ KEY_SCOPES,
16
+ READS_ITS_OWN_CALL,
17
+ ROLE_SCOPES,
18
+ SERVER_SCOPES,
19
+ THE_FLEET,
20
+ THE_TEAM,
21
+ Key,
22
+ KeyScope,
23
+ Member,
24
+ Role,
25
+ RoomScope,
26
+ )
27
+ from pinecall.domain.scope import THE_ORGS_OWN, Scope
28
+ from pinecall.postgres.pool import Pool
29
+ from pinecall.tenancy.people import fingerprint, member_of
30
+
31
+ # The prefix says the world, so a key pasted into the wrong one is caught before it knocks. A
32
+ # person's one key is production's: it opens the sandbox too, and the request names the world.
33
+ PRODUCTION_PREFIX = "pc_live_"
34
+
35
+
36
+ SANDBOX_PREFIX = "pc_test_"
37
+
38
+
39
+ KEY_BYTES = 32
40
+
41
+
42
+ ID_PREFIX = "k_"
43
+
44
+
45
+ ID_BYTES = 8
46
+
47
+
48
+ NOT_A_WORLD = "pinecall-env is sandbox or production, not {asked!r}"
49
+
50
+
51
+ ONE_WORLD = (
52
+ "this key is a {world} server's token, and this request is for {asked}: "
53
+ "a server's token opens the world it was made in"
54
+ )
55
+
56
+
57
+ NO_PRODUCTION = "{name} has no production access: an admin gives it in Team"
58
+
59
+
60
+ NOT_OPENED = "this key does not open {scope}: it opens {opens}"
61
+
62
+
63
+ EXPIRED = "this key expired at {at}: it opens nothing now, and a new one is made in the console"
64
+
65
+
66
+ NOT_A_SERVERS = "a server's token opens some of {scopes}, and at least one; not {wanted}"
67
+
68
+
69
+ ALREADY_OVER = "a key's expiry is a moment to come, not {at}"
70
+
71
+
72
+ NOT_YOURS_TO_GRANT = (
73
+ "this key does not open everything {role} would: it opens {opens}, so it cannot grant {role}"
74
+ )
75
+
76
+
77
+ NOT_THEIR_AGENT = (
78
+ "{name} works on {agents}, and agent {slug} is not one of them: an admin adds it in Team"
79
+ )
80
+
81
+
82
+ NOT_YOURS_TO_SWITCH = "{name} has no production access, and cannot give it: an admin does"
83
+
84
+
85
+ CANNOT_LOOK_THERE = "only a key that sees every corner opens a colleague's, and only in the sandbox"
86
+
87
+
88
+ NOT_A_COLLEAGUE = "no active member of this org answers to that corner"
89
+
90
+
91
+ NO_DISPATCH = "the fleet's key acts in the corner of the call it serves: name the call"
92
+
93
+
94
+ NOT_THE_CALLS = "the call named is not in the scope the dispatch names: a worker acts in its call's"
95
+
96
+
97
+ NOT_THIS_FLEET = (
98
+ "this is the {world} fleet's key, and the call is the {asked}'s: the unit holds the wrong key"
99
+ )
100
+
101
+
102
+ # A key that names a person opens nothing once the person is gone, disabled, or (on a visit to
103
+ # another org) no longer runs the box: the member row is read with the key, every request. Its
104
+ # use is written at most once a minute, so a request is not a write. An expired key is found, so
105
+ # the refusal can say it expired; it opens nothing and its use is not written.
106
+ VERIFY = sql.SQL("""
107
+ WITH found AS (
108
+ SELECT id, org, label, env, scopes, subject, name, expires_at, last_used_at,
109
+ expires_at IS NOT NULL AND expires_at <= now() AS expired
110
+ FROM api_keys
111
+ WHERE hash = %(hash)s AND revoked_at IS NULL
112
+ ), touched AS (
113
+ UPDATE api_keys SET last_used_at = now() FROM found
114
+ WHERE api_keys.id = found.id AND NOT found.expired
115
+ AND (found.last_used_at IS NULL OR found.last_used_at < now() - interval '1 minute')
116
+ )
117
+ SELECT found.*, m.id AS member, {member}
118
+ FROM found LEFT JOIN members m ON m.id = found.subject
119
+ WHERE found.subject IS NULL
120
+ OR (m.status = 'active' AND (m.org = found.org OR m.operator))
121
+ """).format(
122
+ member=sql.SQL(", ").join(
123
+ sql.SQL("m.{} AS {}").format(sql.Identifier(column), sql.Identifier(f"m_{column}"))
124
+ for column in (
125
+ "org",
126
+ "email",
127
+ "name",
128
+ "role",
129
+ "agents",
130
+ "status",
131
+ "operator",
132
+ "production",
133
+ "verified_at",
134
+ )
135
+ )
136
+ )
137
+
138
+
139
+ ISSUE = """
140
+ INSERT INTO api_keys (id, hash, org, label, env, scopes, subject, name, created_by, expires_at,
141
+ parent)
142
+ VALUES (%(id)s, %(hash)s, %(org)s, %(label)s, %(env)s, %(scopes)s, %(subject)s, %(name)s,
143
+ %(created_by)s, %(expires_at)s, %(parent)s)
144
+ """
145
+
146
+
147
+ LISTED = """
148
+ SELECT id, hash, org, label, env, scopes, subject, name, created_by, created_at, last_used_at,
149
+ revoked_at, expires_at
150
+ FROM api_keys WHERE org = %(org)s ORDER BY created_at, id
151
+ """
152
+
153
+
154
+ # A second revoke changes nothing and says so.
155
+ # The key and every copy made of it, however deep: each fingerprint stopped is answered, so every
156
+ # gateway forgets each one it remembers.
157
+ REVOKE = """
158
+ WITH RECURSIVE stopped AS (
159
+ SELECT id FROM api_keys WHERE hash = %(hash)s AND revoked_at IS NULL
160
+ UNION
161
+ SELECT copy.id FROM api_keys copy JOIN stopped ON copy.parent = stopped.id
162
+ WHERE copy.revoked_at IS NULL
163
+ )
164
+ UPDATE api_keys SET revoked_at = now() WHERE id IN (SELECT id FROM stopped) RETURNING hash
165
+ """
166
+
167
+
168
+ # Room tokens. A room token is livekit's own JWT: the room is the call, our scope rides an
169
+ # attribute the worker reads without a lookup, and the same string joins the room and reads
170
+ # the log.
171
+ SUBJECT_ATTRIBUTE = "pinecall.subject"
172
+
173
+
174
+ NAME_ATTRIBUTE = "pinecall.name"
175
+
176
+
177
+ PROJECTION_ATTRIBUTE = "pinecall.projection"
178
+
179
+
180
+ CODE_ATTRIBUTE = "pinecall.code"
181
+
182
+
183
+ AGENT_ATTRIBUTE = "pinecall.agent"
184
+
185
+
186
+ ENV_ATTRIBUTE = "pinecall.env"
187
+
188
+
189
+ LONGEST_VISIT_TTL_S = 600
190
+
191
+
192
+ A_SEAT_LASTS_S = 15 * 60
193
+
194
+
195
+ A_VISITOR = "web_"
196
+
197
+
198
+ A_SEAT = "sup_"
199
+
200
+
201
+ # The one source a visitor may publish; livekit refuses a source not listed.
202
+ THE_MICROPHONE = "microphone"
203
+
204
+
205
+ # An API key holds no dots, so its shape alone tells it from a token.
206
+ JWT_PARTS = 3
207
+
208
+
209
+ MINTED = """
210
+ INSERT INTO tokens (call, org, env, agent, scope, expires_at)
211
+ VALUES (%(call)s, %(org)s, %(env)s, %(agent)s, %(scope)s, to_timestamp(%(expires_at)s))
212
+ """
213
+
214
+
215
+ # Valid for one call: the browser's scopes, and the one that sends verbs.
216
+ BOUND_TO_ONE_CALL: frozenset[RoomScope] = READS_ITS_OWN_CALL | frozenset[RoomScope]({"supervise"})
217
+
218
+
219
+ @dataclass(frozen=True)
220
+ class Issued:
221
+ """What a new key is made with."""
222
+
223
+ org: str
224
+ env: Env
225
+ scopes: frozenset[KeyScope] = KEY_SCOPES
226
+ label: str | None = None
227
+ subject: str | None = None
228
+ name: str | None = None
229
+ created_by: str | None = None
230
+ expires_at: datetime | None = None
231
+ # The key this one copies: revoked with it.
232
+ parent: str | None = None
233
+
234
+
235
+ @dataclass(frozen=True)
236
+ class Bearer:
237
+ """A verified key, and the person it was minted for, read in the same query."""
238
+
239
+ key: Key
240
+ member: Member | None = None
241
+
242
+
243
+ @dataclass(frozen=True)
244
+ class ListedKey:
245
+ """A key as the org's listing shows it: by fingerprint, never the key."""
246
+
247
+ fingerprint: str
248
+ key: Key
249
+ created_by: str | None
250
+ created_at: datetime
251
+ last_used_at: datetime | None
252
+ revoked_at: datetime | None
253
+
254
+
255
+ async def issue(pool: Pool, issued: Issued) -> tuple[Key, str]:
256
+ """Mint a key: the row keeps its fingerprint, and the key is handed back this once."""
257
+ prefix = PRODUCTION_PREFIX if issued.env == PRODUCTION else SANDBOX_PREFIX
258
+ secret = f"{prefix}{secrets.token_urlsafe(KEY_BYTES)}"
259
+ key = Key(
260
+ key_id=f"{ID_PREFIX}{secrets.token_hex(ID_BYTES)}",
261
+ org=issued.org,
262
+ label=issued.label,
263
+ env=issued.env,
264
+ scopes=issued.scopes,
265
+ subject=issued.subject,
266
+ name=issued.name,
267
+ expires_at=issued.expires_at,
268
+ )
269
+ values = {
270
+ "id": key.key_id,
271
+ "hash": fingerprint(secret),
272
+ "org": key.org,
273
+ "label": key.label,
274
+ "env": key.env,
275
+ "scopes": sorted(key.scopes),
276
+ "subject": key.subject,
277
+ "name": key.name,
278
+ "created_by": issued.created_by,
279
+ "expires_at": key.expires_at,
280
+ "parent": issued.parent,
281
+ }
282
+ async with pool.connection() as connection:
283
+ await connection.execute(ISSUE, values)
284
+ return key, secret
285
+
286
+
287
+ async def person_key(
288
+ pool: Pool, member: Member, *, parent: Key | None = None, label: str | None = None
289
+ ) -> tuple[Key, str]:
290
+ """A person's one key: the role's scopes, both worlds, never outliving the key it came from."""
291
+ issued = Issued(
292
+ org=member.org,
293
+ env=PRODUCTION,
294
+ scopes=member.scopes,
295
+ label=label,
296
+ subject=member.id,
297
+ name=member.name,
298
+ created_by=member.id,
299
+ expires_at=None if parent is None else parent.expires_at,
300
+ )
301
+ return await issue(pool, issued)
302
+
303
+
304
+ async def verify(pool: Pool, bearer: str) -> Bearer | None:
305
+ """The key and its person; None for a key unknown, revoked, or whose person is gone."""
306
+ async with pool.connection() as connection:
307
+ row = await (await connection.execute(VERIFY, {"hash": fingerprint(bearer)})).fetchone()
308
+ if row is None:
309
+ return None
310
+ if row["expired"]:
311
+ raise NotSignedIn(EXPIRED.format(at=row["expires_at"].isoformat(timespec="seconds")))
312
+ member = None if row["member"] is None else member_of(_member_columns(row))
313
+ return Bearer(key=_key(row), member=member)
314
+
315
+
316
+ async def listed(pool: Pool, org: str) -> list[ListedKey]:
317
+ """Every key of the org, oldest first, the revoked ones too."""
318
+ async with pool.connection() as connection:
319
+ rows = await (await connection.execute(LISTED, {"org": org})).fetchall()
320
+ return [
321
+ ListedKey(
322
+ fingerprint=row["hash"],
323
+ key=_key(row),
324
+ created_by=row["created_by"],
325
+ created_at=row["created_at"],
326
+ last_used_at=row["last_used_at"],
327
+ revoked_at=row["revoked_at"],
328
+ )
329
+ for row in rows
330
+ ]
331
+
332
+
333
+ async def revoke(pool: Pool, key_fingerprint: str) -> list[str]:
334
+ """Stop a key and every copy of it from the next request on; the fingerprints stopped."""
335
+ async with pool.connection() as connection:
336
+ revoked = await connection.execute(REVOKE, {"hash": key_fingerprint})
337
+ return [str(row["hash"]) for row in await revoked.fetchall()]
338
+
339
+
340
+ def world_of(bearer: Bearer, params: str | None) -> Env:
341
+ """The world a request acts in: a server key's own, else the one its header asks for."""
342
+ if params is not None and params not in ENVS:
343
+ raise NotAllowed(NOT_A_WORLD.format(asked=params))
344
+ wanted = None if params is None else parse_env(params)
345
+ if bearer.member is None:
346
+ if wanted is not None and wanted != bearer.key.env:
347
+ raise NotAllowed(ONE_WORLD.format(world=bearer.key.env, asked=wanted))
348
+ return bearer.key.env
349
+ world = SANDBOX if wanted is None else wanted
350
+ if world == PRODUCTION and not bearer.member.opens_production:
351
+ raise NotAllowed(NO_PRODUCTION.format(name=bearer.member.name))
352
+ return world
353
+
354
+
355
+ def scope_of(
356
+ bearer: Bearer,
357
+ world: Env,
358
+ *,
359
+ looking_at: Member | None = None,
360
+ dispatched: Scope | None = None,
361
+ called: Scope | None = None,
362
+ ) -> Scope:
363
+ """The scope a request acts in: the org's in production, the person's own in the sandbox."""
364
+ if THE_FLEET in bearer.key.scopes:
365
+ return _fleets_scope(bearer, dispatched, called)
366
+ if world == PRODUCTION or bearer.member is None:
367
+ return Scope(bearer.key.org, world, THE_ORGS_OWN)
368
+ if looking_at is None or looking_at.id == bearer.member.id:
369
+ return Scope(bearer.key.org, world, bearer.member.id)
370
+ # Both team and app: a manager holds team alone and does not open developers' sandboxes.
371
+ if not {THE_TEAM, HOLDING} <= bearer.key.scopes:
372
+ raise NotAllowed(CANNOT_LOOK_THERE)
373
+ if looking_at.org != bearer.key.org or looking_at.status != "active":
374
+ raise NotAllowed(NOT_A_COLLEAGUE)
375
+ return Scope(bearer.key.org, world, looking_at.id)
376
+
377
+
378
+ # Fewer scopes than a server's, never others: a token for pushing knowledge alone opens that.
379
+ def server_scopes(wanted: Sequence[str] | None) -> frozenset[KeyScope]:
380
+ """The scopes a server's token is made with: all of a server's, or those of them asked for."""
381
+ if wanted is None:
382
+ return SERVER_SCOPES
383
+ scopes = frozenset[KeyScope](scope for scope in SERVER_SCOPES if scope in wanted)
384
+ if not scopes or len(scopes) != len(set(wanted)):
385
+ wanted = ", ".join(sorted(set(wanted))) or "none"
386
+ raise DeclarationRefused(
387
+ NOT_A_SERVERS.format(scopes=", ".join(sorted(SERVER_SCOPES)), wanted=wanted)
388
+ )
389
+ return scopes
390
+
391
+
392
+ def check_expiry(expires_at: datetime | None, now: datetime) -> None:
393
+ """Refuse an expiry that is not a moment to come; None is a key that never expires."""
394
+ if expires_at is not None and expires_at <= now:
395
+ raise DeclarationRefused(ALREADY_OVER.format(at=expires_at.isoformat(timespec="seconds")))
396
+
397
+
398
+ def check_opens(bearer: Bearer, *scopes: KeyScope) -> None:
399
+ """Refuse, naming what the key does open, unless it holds one of the scopes."""
400
+ if any(scope in bearer.key.scopes for scope in scopes):
401
+ return
402
+ opens = " · ".join(sorted(bearer.key.scopes)) or "nothing"
403
+ raise NotAllowed(NOT_OPENED.format(scope=" or ".join(sorted(scopes)), opens=opens))
404
+
405
+
406
+ # A member's agents bind their own org alone: on a visit the member row is the visitor's own
407
+ # org's, and names none of the visited org's agents.
408
+ def check_agent(bearer: Bearer, slug: str) -> None:
409
+ """Refuse a person whose list of agents leaves this one out; an empty list is every agent."""
410
+ member = bearer.member
411
+ if member is None or not member.agents or member.org != bearer.key.org:
412
+ return
413
+ if slug in member.agents:
414
+ return
415
+ agents = ", ".join(sorted(member.agents))
416
+ raise NotAllowed(NOT_THEIR_AGENT.format(name=member.name, agents=agents, slug=slug))
417
+
418
+
419
+ # A key grants only a role whose scopes it holds, or a manager would make an admin.
420
+ def check_may_grant(bearer: Bearer, role: Role | None, *, production: bool) -> None:
421
+ """Refuse a role or production access the key's holder does not have to give."""
422
+ if (
423
+ role is not None
424
+ and bearer.member is not None
425
+ and not ROLE_SCOPES[role] <= bearer.key.scopes
426
+ ):
427
+ opens = " · ".join(sorted(bearer.key.scopes)) or "nothing"
428
+ raise NotAllowed(NOT_YOURS_TO_GRANT.format(role=role, opens=opens))
429
+ acts_there = (
430
+ bearer.key.env == PRODUCTION if bearer.member is None else bearer.member.opens_production
431
+ )
432
+ if production and not acts_there:
433
+ name = bearer.key.name or "this key"
434
+ raise NotAllowed(NOT_YOURS_TO_SWITCH.format(name=name))
435
+
436
+
437
+ # The call's head row is the proof; the dispatch's word stands only where no call exists yet.
438
+ def _fleets_scope(bearer: Bearer, dispatched: Scope | None, called: Scope | None) -> Scope:
439
+ acting = called if called is not None else dispatched
440
+ if acting is None:
441
+ raise DeclarationRefused(NO_DISPATCH)
442
+ if dispatched is not None and dispatched != acting:
443
+ raise NotFound(NOT_THE_CALLS)
444
+ # A fleet serves one world: a unit holding the other world's key is refused here too.
445
+ if acting.env != bearer.key.env:
446
+ raise NotAllowed(NOT_THIS_FLEET.format(world=bearer.key.env, asked=acting.env))
447
+ return acting
448
+
449
+
450
+ def _key(row: DictRow) -> Key:
451
+ return Key(
452
+ key_id=row["id"],
453
+ org=row["org"],
454
+ label=row["label"],
455
+ env=row["env"],
456
+ scopes=frozenset(row["scopes"]),
457
+ subject=row["subject"],
458
+ name=row["name"],
459
+ expires_at=row["expires_at"],
460
+ )
461
+
462
+
463
+ def _member_columns(row: DictRow) -> DictRow:
464
+ columns = {
465
+ name.removeprefix("m_"): value for name, value in row.items() if name.startswith("m_")
466
+ }
467
+ return {**columns, "id": row["member"]}
@@ -0,0 +1,40 @@
1
+ """How many times a name knocked in the last minute, counted in Postgres for every gateway."""
2
+
3
+ import time
4
+ from collections.abc import Callable
5
+
6
+ from pinecall.postgres.pool import Pool
7
+
8
+ WINDOW_S = 60.0
9
+
10
+ # One name's knocks are counted and taken one at a time, whichever gateway they land on.
11
+ ONE_AT_A_TIME = "select pg_advisory_xact_lock(hashtext('knock:' || %(name)s))"
12
+
13
+ # A refused knock is not counted: the name waits for its oldest knock to leave the window.
14
+ KNOCKED = """
15
+ insert into knocks (name, at)
16
+ select %(name)s, %(now)s
17
+ where (select count(*) from knocks where name = %(name)s and at > %(since)s) < %(tries)s
18
+ returning at
19
+ """
20
+
21
+ GONE = "delete from knocks where at <= %(since)s"
22
+
23
+
24
+ class Throttle:
25
+ """So many knocks a minute per name, and the next one waits: across every gateway."""
26
+
27
+ def __init__(self, pool: Pool, tries: int, clock: Callable[[], float] = time.time) -> None:
28
+ """A throttle of so many tries a minute."""
29
+ self.pool = pool
30
+ self.tries = tries
31
+ self.clock = clock
32
+
33
+ async def allowed(self, name: str) -> bool:
34
+ """Count a knock, and say whether it is within the window's tries."""
35
+ now = self.clock()
36
+ wanted = {"name": name, "now": now, "since": now - WINDOW_S, "tries": self.tries}
37
+ async with self.pool.connection() as connection, connection.transaction():
38
+ await connection.execute(ONE_AT_A_TIME, wanted)
39
+ await connection.execute(GONE, wanted)
40
+ return await (await connection.execute(KNOCKED, wanted)).fetchone() is not None