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.
- pinecall/__init__.py +0 -0
- pinecall/channels/__init__.py +0 -0
- pinecall/channels/_chooser.py +45 -0
- pinecall/channels/offers.py +219 -0
- pinecall/channels/rooms.py +176 -0
- pinecall/channels/routes.py +275 -0
- pinecall/channels/telephony/__init__.py +0 -0
- pinecall/channels/telephony/_twilio.py +349 -0
- pinecall/channels/telephony/carrier.py +229 -0
- pinecall/channels/telephony/carrier_catalog.py +92 -0
- pinecall/channels/telephony/carriers.csv +41 -0
- pinecall/channels/telephony/dialing.py +419 -0
- pinecall/channels/telephony/firewall.py +58 -0
- pinecall/channels/telephony/hand_over.py +78 -0
- pinecall/channels/telephony/number_path.py +176 -0
- pinecall/channels/telephony/numbers.py +530 -0
- pinecall/channels/telephony/origination.py +77 -0
- pinecall/channels/telephony/sip.py +448 -0
- pinecall/channels/whatsapp.py +413 -0
- pinecall/cli/__init__.py +0 -0
- pinecall/cli/_archive.py +85 -0
- pinecall/cli/_drift.py +38 -0
- pinecall/cli/_facts.py +67 -0
- pinecall/cli/_load.py +433 -0
- pinecall/cli/_operator.py +626 -0
- pinecall/cli/_sessions.py +164 -0
- pinecall/cli/_traceback.py +95 -0
- pinecall/cli/_usage.py +34 -0
- pinecall/cli/main.py +521 -0
- pinecall/domain/__init__.py +0 -0
- pinecall/domain/agent.py +446 -0
- pinecall/domain/call.py +152 -0
- pinecall/domain/country_codes.py +32 -0
- pinecall/domain/errors.py +151 -0
- pinecall/domain/names.py +115 -0
- pinecall/domain/org.py +120 -0
- pinecall/domain/person.py +208 -0
- pinecall/domain/scope.py +21 -0
- pinecall/evals/__init__.py +0 -0
- pinecall/evals/_evidence.py +158 -0
- pinecall/evals/calibration.py +112 -0
- pinecall/evals/callers.py +160 -0
- pinecall/evals/case.py +327 -0
- pinecall/evals/checks.py +437 -0
- pinecall/evals/compliance.py +197 -0
- pinecall/evals/dataset.py +451 -0
- pinecall/evals/goldens.py +331 -0
- pinecall/evals/judges.py +525 -0
- pinecall/evals/runs.py +252 -0
- pinecall/evals/spoken.py +356 -0
- pinecall/fleet/__init__.py +0 -0
- pinecall/fleet/client.py +569 -0
- pinecall/fleet/demand.py +51 -0
- pinecall/fleet/heartbeat.py +194 -0
- pinecall/fleet/measures.py +157 -0
- pinecall/fleet/roster.py +232 -0
- pinecall/fleet/worlds.py +44 -0
- pinecall/gateway/__init__.py +0 -0
- pinecall/gateway/_call_setup.py +53 -0
- pinecall/gateway/_deps.py +556 -0
- pinecall/gateway/_gateway.py +75 -0
- pinecall/gateway/_served.py +396 -0
- pinecall/gateway/_sockets.py +444 -0
- pinecall/gateway/_streams.py +83 -0
- pinecall/gateway/_text_calls.py +182 -0
- pinecall/gateway/api/__init__.py +0 -0
- pinecall/gateway/api/accounts.py +324 -0
- pinecall/gateway/api/agents.py +150 -0
- pinecall/gateway/api/apps.py +478 -0
- pinecall/gateway/api/box.py +658 -0
- pinecall/gateway/api/calibration.py +58 -0
- pinecall/gateway/api/callbacks.py +105 -0
- pinecall/gateway/api/calls.py +655 -0
- pinecall/gateway/api/chat.py +223 -0
- pinecall/gateway/api/dataset.py +163 -0
- pinecall/gateway/api/desk.py +113 -0
- pinecall/gateway/api/entries.py +77 -0
- pinecall/gateway/api/evals.py +647 -0
- pinecall/gateway/api/fleet.py +73 -0
- pinecall/gateway/api/hosting.py +241 -0
- pinecall/gateway/api/judges.py +97 -0
- pinecall/gateway/api/keys.py +110 -0
- pinecall/gateway/api/line.py +142 -0
- pinecall/gateway/api/members.py +197 -0
- pinecall/gateway/api/metrics.py +226 -0
- pinecall/gateway/api/numbers.py +476 -0
- pinecall/gateway/api/ops.py +428 -0
- pinecall/gateway/api/org.py +283 -0
- pinecall/gateway/api/personas.py +157 -0
- pinecall/gateway/api/pipeline.py +206 -0
- pinecall/gateway/api/prompts.py +50 -0
- pinecall/gateway/api/providers.py +240 -0
- pinecall/gateway/api/recordings.py +89 -0
- pinecall/gateway/api/relay.py +169 -0
- pinecall/gateway/api/retrieval.py +403 -0
- pinecall/gateway/api/runner.py +109 -0
- pinecall/gateway/api/sessions.py +102 -0
- pinecall/gateway/api/settings.py +463 -0
- pinecall/gateway/api/signup.py +164 -0
- pinecall/gateway/api/sso_login.py +168 -0
- pinecall/gateway/api/threads.py +187 -0
- pinecall/gateway/api/usage.py +303 -0
- pinecall/gateway/api/visitors.py +222 -0
- pinecall/gateway/api/whatsapp.py +48 -0
- pinecall/gateway/api/widget.py +49 -0
- pinecall/gateway/app.py +504 -0
- pinecall/gateway/calls/__init__.py +0 -0
- pinecall/gateway/calls/binding.py +63 -0
- pinecall/gateway/calls/commands.py +18 -0
- pinecall/gateway/calls/inbox.py +100 -0
- pinecall/gateway/calls/known.py +23 -0
- pinecall/gateway/calls/owners.py +87 -0
- pinecall/gateway/calls/pump.py +33 -0
- pinecall/gateway/calls/serving.py +118 -0
- pinecall/gateway/calls/threads.py +367 -0
- pinecall/gateway/dispatching/__init__.py +0 -0
- pinecall/gateway/dispatching/arrivals.py +58 -0
- pinecall/gateway/dispatching/sweep.py +43 -0
- pinecall/gateway/ending/__init__.py +0 -0
- pinecall/gateway/ending/reaper.py +160 -0
- pinecall/gateway/ending/seal.py +312 -0
- pinecall/gateway/ending/stranded.py +117 -0
- pinecall/log/__init__.py +0 -0
- pinecall/log/_histogram.py +74 -0
- pinecall/log/_relay.py +259 -0
- pinecall/log/_writer.py +454 -0
- pinecall/log/days.py +154 -0
- pinecall/log/drift.py +550 -0
- pinecall/log/facts.py +358 -0
- pinecall/log/inbox.py +169 -0
- pinecall/log/lists.py +155 -0
- pinecall/log/logs.py +581 -0
- pinecall/log/openings.py +71 -0
- pinecall/log/private.py +135 -0
- pinecall/log/queries.py +229 -0
- pinecall/log/readers.py +207 -0
- pinecall/log/reduce.py +659 -0
- pinecall/log/refold.py +217 -0
- pinecall/log/store.py +370 -0
- pinecall/log/usage.py +242 -0
- pinecall/postgres/__init__.py +0 -0
- pinecall/postgres/migrate.py +202 -0
- pinecall/postgres/migrations/0001_schema.sql +591 -0
- pinecall/postgres/migrations/0002_worlds.sql +15 -0
- pinecall/postgres/migrations/0003_lent.sql +4 -0
- pinecall/postgres/migrations/0004_carriers.sql +23 -0
- pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
- pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
- pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
- pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
- pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
- pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
- pinecall/postgres/migrations/0011_erasure.sql +32 -0
- pinecall/postgres/migrations/0012_org_policy.sql +10 -0
- pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
- pinecall/postgres/migrations/0014_consents.sql +24 -0
- pinecall/postgres/migrations/0015_disclosure.sql +7 -0
- pinecall/postgres/migrations/0016_call_records.sql +21 -0
- pinecall/postgres/migrations/0017_reads.sql +17 -0
- pinecall/postgres/migrations/0018_hosting.sql +41 -0
- pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
- pinecall/postgres/migrations/0020_written_once.sql +7 -0
- pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
- pinecall/postgres/migrations/0022_sealed.sql +39 -0
- pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
- pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
- pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
- pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
- pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
- pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
- pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
- pinecall/postgres/migrations/0060_drift.sql +62 -0
- pinecall/postgres/migrations/0061_canaries.sql +24 -0
- pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
- pinecall/postgres/migrations/0063_prompts.sql +16 -0
- pinecall/postgres/migrations/0075_call_private.sql +14 -0
- pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
- pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
- pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
- pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
- pinecall/postgres/migrations/0080_call_openings.sql +20 -0
- pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
- pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
- pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
- pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
- pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
- pinecall/postgres/migrations/0086_route_origin.sql +17 -0
- pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
- pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
- pinecall/postgres/migrations/0089_offers.sql +21 -0
- pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
- pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
- pinecall/postgres/migrations/0092_key_parent.sql +6 -0
- pinecall/postgres/migrations/0093_code_tries.sql +10 -0
- pinecall/postgres/migrations/0094_route_approval.sql +12 -0
- pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
- pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
- pinecall/postgres/migrations/0097_case_status.sql +19 -0
- pinecall/postgres/pool.py +190 -0
- pinecall/process/__init__.py +0 -0
- pinecall/process/_objects.py +97 -0
- pinecall/process/box_settings.py +23 -0
- pinecall/process/connections.py +124 -0
- pinecall/process/metrics.py +127 -0
- pinecall/process/recordings.py +323 -0
- pinecall/process/resolver.py +41 -0
- pinecall/process/sealed_audio.py +173 -0
- pinecall/process/settings.py +534 -0
- pinecall/process/shared.py +158 -0
- pinecall/process/signal.py +416 -0
- pinecall/providers/__init__.py +0 -0
- pinecall/providers/build.py +570 -0
- pinecall/providers/catalog.py +266 -0
- pinecall/providers/credentials.py +230 -0
- pinecall/providers/declared.py +69 -0
- pinecall/providers/prices.py +240 -0
- pinecall/providers/tried.py +104 -0
- pinecall/providers/voices.py +174 -0
- pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
- pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
- pinecall/public/console/favicon.png +0 -0
- pinecall/public/console/index.html +20 -0
- pinecall/public/console/logo-mark.png +0 -0
- pinecall/public/console/pinecall-logo-dark.png +0 -0
- pinecall/public/console/pinecall-logo.png +0 -0
- pinecall/public/console/pinecall-mark.png +0 -0
- pinecall/public/console/sw.js +37 -0
- pinecall/public/widget/pinecall-widget.js +853 -0
- pinecall/py.typed +0 -0
- pinecall/retrieval/__init__.py +0 -0
- pinecall/retrieval/_search.py +186 -0
- pinecall/retrieval/_sections.py +70 -0
- pinecall/retrieval/embed.py +340 -0
- pinecall/retrieval/extraction.py +403 -0
- pinecall/retrieval/knowledge.py +659 -0
- pinecall/retrieval/lookups.py +192 -0
- pinecall/retrieval/memory.py +603 -0
- pinecall/runner/__init__.py +0 -0
- pinecall/runner/_kube.py +459 -0
- pinecall/runner/_plan.py +157 -0
- pinecall/runner/main.py +306 -0
- pinecall/session/__init__.py +0 -0
- pinecall/session/_agent.py +141 -0
- pinecall/session/_hearing.py +101 -0
- pinecall/session/_livekit.py +195 -0
- pinecall/session/_prompt.py +147 -0
- pinecall/session/_recording.py +237 -0
- pinecall/session/call.py +367 -0
- pinecall/session/clock.py +40 -0
- pinecall/session/hold.py +239 -0
- pinecall/session/room.py +626 -0
- pinecall/session/session.py +690 -0
- pinecall/session/text.py +144 -0
- pinecall/session/tools.py +483 -0
- pinecall/session/voice.py +118 -0
- pinecall/session/widget.py +201 -0
- pinecall/tenancy/__init__.py +0 -0
- pinecall/tenancy/admission.py +204 -0
- pinecall/tenancy/agents.py +164 -0
- pinecall/tenancy/canary.py +126 -0
- pinecall/tenancy/carrier_networks.py +156 -0
- pinecall/tenancy/carriers.py +265 -0
- pinecall/tenancy/codes.py +196 -0
- pinecall/tenancy/consents.py +167 -0
- pinecall/tenancy/dial_policy.py +405 -0
- pinecall/tenancy/disclosure.py +41 -0
- pinecall/tenancy/erasure.py +204 -0
- pinecall/tenancy/export.py +167 -0
- pinecall/tenancy/hosted_running.py +166 -0
- pinecall/tenancy/hosting.py +475 -0
- pinecall/tenancy/judges.py +126 -0
- pinecall/tenancy/keys.py +467 -0
- pinecall/tenancy/knocks.py +40 -0
- pinecall/tenancy/letters.py +378 -0
- pinecall/tenancy/mail.py +458 -0
- pinecall/tenancy/org_secrets.py +123 -0
- pinecall/tenancy/orgs.py +78 -0
- pinecall/tenancy/people.py +543 -0
- pinecall/tenancy/personas.py +145 -0
- pinecall/tenancy/policy.py +65 -0
- pinecall/tenancy/prompts.py +48 -0
- pinecall/tenancy/reads.py +73 -0
- pinecall/tenancy/recording_keys.py +41 -0
- pinecall/tenancy/remembered.py +148 -0
- pinecall/tenancy/retention.py +86 -0
- pinecall/tenancy/scopes.py +406 -0
- pinecall/tenancy/signin.py +622 -0
- pinecall/tenancy/spend.py +69 -0
- pinecall/tenancy/sso.py +431 -0
- pinecall/tenancy/sso_domains.py +132 -0
- pinecall/tenancy/throttle.py +88 -0
- pinecall/tenancy/tokens.py +400 -0
- pinecall/tenancy/traceback.py +70 -0
- pinecall/tenancy/usage.py +136 -0
- pinecall/tenancy/vault.py +204 -0
- pinecall/tenancy/words.py +144 -0
- pinecall/wire/__init__.py +0 -0
- pinecall/wire/commands.py +350 -0
- pinecall/wire/events.py +655 -0
- pinecall/wire/frames.py +65 -0
- pinecall/wire/metrics.py +287 -0
- pinecall/wire/parts.py +320 -0
- pinecall/wire/rest/__init__.py +0 -0
- pinecall/wire/rest/accounts.py +512 -0
- pinecall/wire/rest/agents.py +172 -0
- pinecall/wire/rest/calls.py +397 -0
- pinecall/wire/rest/evals.py +453 -0
- pinecall/wire/rest/fleet.py +69 -0
- pinecall/wire/rest/hosting.py +174 -0
- pinecall/wire/rest/numbers.py +280 -0
- pinecall/wire/rest/ops.py +342 -0
- pinecall/wire/rest/providers.py +83 -0
- pinecall/wire/rest/retrieval.py +301 -0
- pinecall/wire/rest/settings.py +184 -0
- pinecall/wire/rest/usage.py +245 -0
- pinecall/wire/room.py +61 -0
- pinecall/wire/scores.py +49 -0
- pinecall/wire/state.py +243 -0
- pinecall/worker/__init__.py +0 -0
- pinecall/worker/_job.py +655 -0
- pinecall/worker/_recorder.py +82 -0
- pinecall/worker/_traces.py +48 -0
- pinecall/worker/main.py +307 -0
- pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
- pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
- pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
- pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
- 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()
|