mindin 0.14.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. mindin/__init__.py +3 -0
  2. mindin/auth/__init__.py +268 -0
  3. mindin/auth/cimd.py +229 -0
  4. mindin/auth/database.py +107 -0
  5. mindin/auth/device.py +272 -0
  6. mindin/auth/hosted.py +705 -0
  7. mindin/auth/hosted_access.py +204 -0
  8. mindin/auth/hosted_deletion.py +142 -0
  9. mindin/auth/hosted_history.py +184 -0
  10. mindin/auth/hosted_keys.py +367 -0
  11. mindin/auth/hosted_layout.py +53 -0
  12. mindin/auth/hosted_members.py +126 -0
  13. mindin/auth/hosted_ready.py +76 -0
  14. mindin/auth/hosted_shared_pages.py +367 -0
  15. mindin/auth/hosted_snapshots.py +48 -0
  16. mindin/auth/hosted_store.py +900 -0
  17. mindin/auth/hosted_transfer.py +181 -0
  18. mindin/auth/hosted_vault_pages.py +543 -0
  19. mindin/auth/hosted_vaults.py +376 -0
  20. mindin/auth/hosted_web.py +427 -0
  21. mindin/auth/identities.py +299 -0
  22. mindin/auth/login.py +164 -0
  23. mindin/auth/login_email.py +140 -0
  24. mindin/auth/login_forwarded.py +125 -0
  25. mindin/auth/login_github.py +139 -0
  26. mindin/auth/login_oidc.py +90 -0
  27. mindin/auth/login_passkey.py +280 -0
  28. mindin/auth/pages.py +241 -0
  29. mindin/auth/prefix.py +25 -0
  30. mindin/auth/scopes.py +15 -0
  31. mindin/auth/upstream.py +171 -0
  32. mindin/backends/__init__.py +120 -0
  33. mindin/backends/local.py +143 -0
  34. mindin/backends/s3.py +177 -0
  35. mindin/backends/sealed.py +211 -0
  36. mindin/changelog.py +173 -0
  37. mindin/changes.py +182 -0
  38. mindin/cli.py +995 -0
  39. mindin/config.py +199 -0
  40. mindin/credentials.py +204 -0
  41. mindin/dav.py +419 -0
  42. mindin/embed_worker.py +410 -0
  43. mindin/embedding.py +403 -0
  44. mindin/extension_api.py +82 -0
  45. mindin/extensions.py +105 -0
  46. mindin/frontmatter.py +172 -0
  47. mindin/git.py +62 -0
  48. mindin/hooks.py +417 -0
  49. mindin/human.py +377 -0
  50. mindin/index.py +498 -0
  51. mindin/login.py +144 -0
  52. mindin/notes.py +897 -0
  53. mindin/places.py +281 -0
  54. mindin/presets.py +119 -0
  55. mindin/provision.py +146 -0
  56. mindin/proxy.py +128 -0
  57. mindin/remote.py +378 -0
  58. mindin/schema.py +598 -0
  59. mindin/search.py +428 -0
  60. mindin/server.py +544 -0
  61. mindin/shared.py +189 -0
  62. mindin/snapshot.py +147 -0
  63. mindin/stemming.py +77 -0
  64. mindin/tasks.py +260 -0
  65. mindin/templates/Areas.base +49 -0
  66. mindin/templates/Backlog.base +62 -0
  67. mindin/templates/Log.base +40 -0
  68. mindin/templates/Open tasks.base +64 -0
  69. mindin/templates/Resources.base +54 -0
  70. mindin/templates/__init__.py +0 -0
  71. mindin/templates/claude-md-snippet.md +22 -0
  72. mindin/templates/presets/custom/All notes.base +39 -0
  73. mindin/templates/presets/custom/schema.yml +25 -0
  74. mindin/templates/presets/journal/Backlog.base +62 -0
  75. mindin/templates/presets/journal/Days.base +29 -0
  76. mindin/templates/presets/journal/Log.base +40 -0
  77. mindin/templates/presets/journal/Open work.base +64 -0
  78. mindin/templates/presets/journal/Templates/Bases/Day.base +25 -0
  79. mindin/templates/presets/journal/Templates/Daily Note Template.md +10 -0
  80. mindin/templates/presets/journal/schema.yml +37 -0
  81. mindin/templates/presets/para/Areas.base +39 -0
  82. mindin/templates/presets/para/Backlog.base +62 -0
  83. mindin/templates/presets/para/Log.base +40 -0
  84. mindin/templates/presets/para/Projects.base +64 -0
  85. mindin/templates/presets/para/Resources.base +47 -0
  86. mindin/templates/presets/para/schema.yml +37 -0
  87. mindin/templates/presets/shared/Areas.base +49 -0
  88. mindin/templates/presets/shared/Resources.base +54 -0
  89. mindin/templates/presets/shared/schema.yml +35 -0
  90. mindin/templates/presets/simple/Backlog.base +62 -0
  91. mindin/templates/presets/simple/Categories.base +16 -0
  92. mindin/templates/presets/simple/Open work.base +64 -0
  93. mindin/templates/presets/simple/Recent.base +36 -0
  94. mindin/templates/presets/simple/Templates/Bases/Category.base +32 -0
  95. mindin/templates/presets/simple/Templates/Bases/Daily.base +25 -0
  96. mindin/templates/presets/simple/Templates/Category Template.md +5 -0
  97. mindin/templates/presets/simple/Templates/Daily Note Template.md +5 -0
  98. mindin/templates/presets/simple/Templates/Project Template.md +4 -0
  99. mindin/templates/presets/simple/schema.yml +29 -0
  100. mindin/templates/schema.yml +47 -0
  101. mindin/templates/welcome.md +41 -0
  102. mindin/terminal.py +110 -0
  103. mindin/vault.py +527 -0
  104. mindin/vectors.py +282 -0
  105. mindin/views.py +66 -0
  106. mindin-0.14.0.dist-info/METADATA +87 -0
  107. mindin-0.14.0.dist-info/RECORD +110 -0
  108. mindin-0.14.0.dist-info/WHEEL +4 -0
  109. mindin-0.14.0.dist-info/entry_points.txt +2 -0
  110. mindin-0.14.0.dist-info/licenses/LICENSE +21 -0
mindin/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,268 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ import ipaddress
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Literal
9
+ from urllib.parse import urlsplit
10
+
11
+ from mindin.config import ConfigError, cache_dir, env, private_dir
12
+
13
+ Registration = Literal["invite", "open"]
14
+ SmtpSecurity = Literal["starttls", "ssl", "none"]
15
+ READ_SCOPE = "vault:read"
16
+ WRITE_SCOPE = "vault:write"
17
+ SCOPES = (READ_SCOPE, WRITE_SCOPE)
18
+ MACHINE_KEY_PURPOSE = "device"
19
+ DEFAULT_IDENTITY_HEADER = "X-Forwarded-Identity"
20
+ DEFAULT_SUBJECT_PREFIX = "users"
21
+ DEFAULT_OIDC_SCOPES = ("openid", "email", "profile")
22
+ SMTP_PORTS = {"starttls": 587, "ssl": 465, "none": 25}
23
+
24
+ NO_PUBLIC_URL = (
25
+ "mindin: an HTTP server needs VAULT_PUBLIC_URL, the https address clients reach it on "
26
+ "(for example https://vault.example.com); there is no default and never a fallback."
27
+ )
28
+ NO_SIGN_IN = (
29
+ "mindin: no way to sign in is configured. Set at least one of VAULT_SMTP_HOST (email links), "
30
+ "VAULT_GITHUB_CLIENT_ID (GitHub), VAULT_OIDC_ISSUER (an OpenID provider) or VAULT_IDENTITY_PUBLIC_KEY "
31
+ "(a trusted proxy); passkeys are added on the account page after a first sign-in."
32
+ )
33
+ BAD_IDENTITY_KEY = "mindin: VAULT_IDENTITY_PUBLIC_KEY is not a PEM public key: {reason}"
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class SmtpConfig:
38
+ host: str
39
+ sender: str
40
+ port: int = 587
41
+ security: SmtpSecurity = "starttls"
42
+ username: str = ""
43
+ password: str = field(default="", repr=False)
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class GithubConfig:
48
+ client_id: str
49
+ client_secret: str = field(repr=False)
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class OidcConfig:
54
+ issuer: str
55
+ client_id: str
56
+ client_secret: str = field(repr=False)
57
+ scopes: tuple[str, ...] = DEFAULT_OIDC_SCOPES
58
+ name: str = "your organisation"
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class ForwardedConfig:
63
+ public_key: str
64
+ issuer: str
65
+ header: str = DEFAULT_IDENTITY_HEADER
66
+ typ: str = ""
67
+ write_claims: tuple[str, ...] = (WRITE_SCOPE,)
68
+ read_claims: tuple[str, ...] = (READ_SCOPE,)
69
+ name: str = "your company's sign-in"
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class AuthConfig:
74
+ public_url: str = ""
75
+ issuer: str = ""
76
+ auth_dir: Path | None = None
77
+ subject_prefix: str = DEFAULT_SUBJECT_PREFIX
78
+ allowed_hosts: tuple[str, ...] = ()
79
+ registration: Registration = "invite"
80
+ passkeys: bool = True
81
+ stateless_http: bool = False
82
+ index_snapshots: bool = False
83
+ snapshot_minutes: float = 10.0
84
+ forwarded_allow_ips: tuple[str, ...] = ()
85
+ smtp: SmtpConfig | None = None
86
+ github: GithubConfig | None = None
87
+ oidc: OidcConfig | None = None
88
+ forwarded: ForwardedConfig | None = None
89
+
90
+ @property
91
+ def path_prefix(self) -> str:
92
+ return urlsplit(self.public_url).path.rstrip("/") if self.public_url else ""
93
+
94
+ @property
95
+ def public_host(self) -> str:
96
+ url = self.public_url if "://" in self.public_url else f"//{self.public_url}"
97
+ return urlsplit(url).hostname or ""
98
+
99
+ @property
100
+ def public_origin(self) -> str:
101
+ parts = urlsplit(self.public_url)
102
+ return f"{parts.scheme}://{parts.netloc}"
103
+
104
+ @property
105
+ def resource_url(self) -> str:
106
+ return f"{self.public_url.rstrip('/')}/mcp"
107
+
108
+ @property
109
+ def dav_url(self) -> str:
110
+ return f"{self.public_url.rstrip('/')}/dav/"
111
+
112
+ @property
113
+ def oidc_redirect_uri(self) -> str:
114
+ return f"{self.public_url.rstrip('/')}/oidc/callback"
115
+
116
+ @property
117
+ def github_redirect_uri(self) -> str:
118
+ return f"{self.public_url.rstrip('/')}/signin/github/callback"
119
+
120
+
121
+ def auth_dir() -> Path:
122
+ raw = env("VAULT_AUTH_DIR")
123
+ return private_dir(Path(raw).expanduser() if raw else cache_dir() / "auth")
124
+
125
+
126
+ def public_key_pem(raw: str) -> str:
127
+ text = raw.strip()
128
+ if not text.startswith("-----BEGIN"):
129
+ try:
130
+ text = base64.b64decode(text, validate=True).decode("utf-8").strip()
131
+ except (binascii.Error, UnicodeDecodeError) as exc:
132
+ raise ConfigError(BAD_IDENTITY_KEY.format(reason="neither PEM nor base64 PEM")) from exc
133
+ from cryptography.hazmat.primitives import serialization
134
+
135
+ try:
136
+ serialization.load_pem_public_key(text.encode("utf-8"))
137
+ except ValueError as exc:
138
+ raise ConfigError(BAD_IDENTITY_KEY.format(reason=exc)) from exc
139
+ return text
140
+
141
+
142
+ def _text(name: str) -> str:
143
+ return (env(name) or "").strip()
144
+
145
+
146
+ def _words(name: str, default: tuple[str, ...]) -> tuple[str, ...]:
147
+ return tuple(_text(name).replace(",", " ").split()) or default
148
+
149
+
150
+ def _needs(name: str, because: str) -> str:
151
+ value = _text(name)
152
+ if not value:
153
+ raise ConfigError(f"mindin: {because} needs {name} as well")
154
+ return value
155
+
156
+
157
+ def _minutes(name: str, default: float) -> float:
158
+ raw = _text(name)
159
+ try:
160
+ value = float(raw) if raw else default
161
+ except ValueError:
162
+ value = -1.0
163
+ if value < 0:
164
+ raise ConfigError(f"mindin: {name} must be a number of minutes, 0 or more")
165
+ return value
166
+
167
+
168
+ def _choice(name: str, choices: tuple[str, ...], default: str) -> str:
169
+ value = _text(name).lower() or default
170
+ if value not in choices:
171
+ raise ConfigError(f"mindin: {name} must be one of {', '.join(choices)}")
172
+ return value
173
+
174
+
175
+ def proxy_addresses(name: str) -> tuple[str, ...]:
176
+ entries = _words(name, ())
177
+ if entries == ("*",):
178
+ return entries
179
+ for entry in entries:
180
+ try:
181
+ ipaddress.ip_network(entry, strict=False)
182
+ except ValueError as exc:
183
+ raise ConfigError(f"mindin: {name} holds {entry!r}, which is no IP address or CIDR range") from exc
184
+ return entries
185
+
186
+
187
+ def auth_config() -> AuthConfig:
188
+ public_url = _text("VAULT_PUBLIC_URL").rstrip("/")
189
+ if not public_url.startswith("https://") and not public_url.startswith("http://localhost"):
190
+ raise ConfigError(NO_PUBLIC_URL)
191
+ config = AuthConfig(
192
+ public_url=public_url,
193
+ issuer=public_url,
194
+ auth_dir=auth_dir(),
195
+ subject_prefix=(_text("VAULT_SUBJECT_PREFIX") or DEFAULT_SUBJECT_PREFIX).strip("/"),
196
+ allowed_hosts=_words("VAULT_ALLOWED_HOSTS", ()),
197
+ registration="open" if _choice("VAULT_REGISTRATION", ("invite", "open"), "invite") == "open" else "invite",
198
+ passkeys=_choice("VAULT_PASSKEYS", ("on", "off"), "on") == "on",
199
+ stateless_http=_choice("VAULT_HTTP_STATELESS", ("on", "off"), "off") == "on",
200
+ index_snapshots=_choice("VAULT_INDEX_SNAPSHOTS", ("on", "off"), "off") == "on",
201
+ snapshot_minutes=_minutes("VAULT_INDEX_SNAPSHOT_MINUTES", 10.0),
202
+ forwarded_allow_ips=proxy_addresses("VAULT_FORWARDED_ALLOW_IPS"),
203
+ smtp=smtp_config(),
204
+ github=github_config(),
205
+ oidc=oidc_config(),
206
+ forwarded=forwarded_config(),
207
+ )
208
+ if not (config.smtp or config.github or config.oidc or config.forwarded):
209
+ raise ConfigError(NO_SIGN_IN)
210
+ return config
211
+
212
+
213
+ def smtp_config() -> SmtpConfig | None:
214
+ host = _text("VAULT_SMTP_HOST")
215
+ if not host:
216
+ return None
217
+ security = _choice("VAULT_SMTP_SECURITY", ("starttls", "ssl", "none"), "starttls")
218
+ port = _text("VAULT_SMTP_PORT") or str(SMTP_PORTS[security])
219
+ if not port.isdigit():
220
+ raise ConfigError("mindin: VAULT_SMTP_PORT must be a port number")
221
+ username, password = _text("VAULT_SMTP_USERNAME"), env("VAULT_SMTP_PASSWORD") or ""
222
+ if security == "none" and (username or password):
223
+ raise ConfigError(
224
+ "mindin: VAULT_SMTP_SECURITY=none would send the SMTP password in the clear; use starttls or ssl"
225
+ )
226
+ return SmtpConfig(
227
+ host=host,
228
+ sender=_needs("VAULT_SMTP_FROM", "VAULT_SMTP_HOST"),
229
+ port=int(port),
230
+ security="ssl" if security == "ssl" else "none" if security == "none" else "starttls",
231
+ username=username,
232
+ password=password,
233
+ )
234
+
235
+
236
+ def github_config() -> GithubConfig | None:
237
+ client_id = _text("VAULT_GITHUB_CLIENT_ID")
238
+ if not client_id:
239
+ return None
240
+ return GithubConfig(client_id=client_id, client_secret=_needs("VAULT_GITHUB_CLIENT_SECRET", "GitHub sign-in"))
241
+
242
+
243
+ def oidc_config() -> OidcConfig | None:
244
+ issuer = _text("VAULT_OIDC_ISSUER").rstrip("/")
245
+ if not issuer:
246
+ return None
247
+ return OidcConfig(
248
+ issuer=issuer,
249
+ client_id=_needs("VAULT_OIDC_CLIENT_ID", "VAULT_OIDC_ISSUER"),
250
+ client_secret=_needs("VAULT_OIDC_CLIENT_SECRET", "VAULT_OIDC_ISSUER"),
251
+ scopes=_words("VAULT_OIDC_SCOPES", DEFAULT_OIDC_SCOPES),
252
+ name=_text("VAULT_OIDC_NAME") or OidcConfig.name,
253
+ )
254
+
255
+
256
+ def forwarded_config() -> ForwardedConfig | None:
257
+ raw_key = _text("VAULT_IDENTITY_PUBLIC_KEY")
258
+ if not raw_key:
259
+ return None
260
+ return ForwardedConfig(
261
+ public_key=public_key_pem(raw_key),
262
+ issuer=_needs("VAULT_IDENTITY_ISSUER", "VAULT_IDENTITY_PUBLIC_KEY"),
263
+ header=_text("VAULT_IDENTITY_HEADER") or DEFAULT_IDENTITY_HEADER,
264
+ typ=_text("VAULT_IDENTITY_TYP"),
265
+ write_claims=_words("VAULT_IDENTITY_WRITE_CLAIMS", (WRITE_SCOPE,)),
266
+ read_claims=_words("VAULT_IDENTITY_READ_CLAIMS", (READ_SCOPE,)),
267
+ name=_text("VAULT_IDENTITY_NAME") or ForwardedConfig.name,
268
+ )
mindin/auth/cimd.py ADDED
@@ -0,0 +1,229 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import ipaddress
5
+ import json
6
+ import re
7
+ import socket
8
+ import time
9
+ from collections.abc import Awaitable, Callable
10
+ from typing import Any
11
+ from urllib.parse import urlsplit, urlunsplit
12
+
13
+ import httpx2
14
+ from mcp.shared.auth import InvalidRedirectUriError, OAuthClientInformationFull
15
+ from pydantic import AnyUrl, ValidationError
16
+
17
+ from mindin.auth import SCOPES
18
+
19
+ MAX_DOCUMENT_BYTES = 5 * 1024
20
+ FETCH_TIMEOUT = 5.0
21
+ DEFAULT_CACHE_SECONDS = 3600
22
+ MAX_CACHE_SECONDS = 24 * 3600
23
+ FETCHES_PER_MINUTE = 30
24
+ LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "[::1]"})
25
+ ALLOWED_GRANTS = frozenset({"authorization_code", "refresh_token"})
26
+ MAX_AGE_RE = re.compile(r"max-age=(\d+)")
27
+ SCHEME_RE = re.compile(r"[a-z][a-z0-9+.-]*")
28
+ UNSAFE_SCHEMES = frozenset({"javascript", "data", "file", "vbscript", "blob", "about"})
29
+
30
+ Resolver = Callable[[str, int], Awaitable[list[str]]]
31
+
32
+
33
+ class ClientMetadataError(Exception):
34
+ pass
35
+
36
+
37
+ def is_metadata_url(client_id: str) -> bool:
38
+ return client_id.startswith("https://")
39
+
40
+
41
+ def is_loopback(url: str) -> bool:
42
+ parts = urlsplit(url)
43
+ return parts.scheme == "http" and (parts.hostname or "") in LOOPBACK_HOSTS
44
+
45
+
46
+ def same_redirect(registered: str, requested: str) -> bool:
47
+ if registered == requested:
48
+ return True
49
+ if not (is_loopback(registered) and is_loopback(requested)):
50
+ return False
51
+ left, right = urlsplit(registered), urlsplit(requested)
52
+ return (left.hostname, left.path, left.query, left.fragment) == (
53
+ right.hostname,
54
+ right.path,
55
+ right.query,
56
+ right.fragment,
57
+ )
58
+
59
+
60
+ def allowed_redirect(uri: str) -> bool:
61
+ parts = urlsplit(uri)
62
+ if parts.scheme == "https":
63
+ return bool(parts.hostname)
64
+ if parts.scheme == "http":
65
+ return is_loopback(uri)
66
+ return bool(SCHEME_RE.fullmatch(parts.scheme)) and parts.scheme not in UNSAFE_SCHEMES
67
+
68
+
69
+ class HostedClient(OAuthClientInformationFull):
70
+ def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl:
71
+ if redirect_uri is None:
72
+ return super().validate_redirect_uri(None)
73
+ requested = str(redirect_uri)
74
+ if any(same_redirect(str(registered), requested) for registered in self.redirect_uris or []):
75
+ return redirect_uri
76
+ raise InvalidRedirectUriError(f"Redirect URI '{redirect_uri}' not registered for client")
77
+
78
+
79
+ def check_metadata_url(url: str) -> tuple[str, int]:
80
+ parts = urlsplit(url)
81
+ if parts.scheme != "https" or not parts.hostname:
82
+ raise ClientMetadataError("a client id metadata URL must be https with a host")
83
+ if parts.username or parts.password or parts.fragment:
84
+ raise ClientMetadataError("a client id metadata URL may carry neither credentials nor a fragment")
85
+ if parts.path in ("", "/") or any(segment in (".", "..") for segment in parts.path.split("/")):
86
+ raise ClientMetadataError("a client id metadata URL needs a path without dot segments")
87
+ return parts.hostname, parts.port or 443
88
+
89
+
90
+ def public_address(address: str) -> bool:
91
+ ip = ipaddress.ip_address(address)
92
+ if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
93
+ ip = ip.ipv4_mapped
94
+ return ip.is_global and not ip.is_multicast
95
+
96
+
97
+ async def system_resolver(host: str, port: int) -> list[str]:
98
+ loop = asyncio.get_running_loop()
99
+ answers = await loop.getaddrinfo(host, port, type=socket.SOCK_STREAM)
100
+ return list(dict.fromkeys(str(answer[4][0]) for answer in answers))
101
+
102
+
103
+ def cache_seconds(header: str) -> int:
104
+ lowered = header.lower()
105
+ if "no-store" in lowered or "no-cache" in lowered:
106
+ return 0
107
+ found = MAX_AGE_RE.search(lowered)
108
+ if found is None:
109
+ return DEFAULT_CACHE_SECONDS
110
+ return min(int(found.group(1)), MAX_CACHE_SECONDS)
111
+
112
+
113
+ def client_from_document(url: str, document: Any) -> HostedClient:
114
+ if not isinstance(document, dict):
115
+ raise ClientMetadataError("the client metadata document is not a JSON object")
116
+ if document.get("client_id") != url:
117
+ raise ClientMetadataError("the document's client_id is not the URL it was fetched from")
118
+ redirects = document.get("redirect_uris")
119
+ if not isinstance(redirects, list) or not redirects or not all(isinstance(uri, str) for uri in redirects):
120
+ raise ClientMetadataError("the document lists no redirect_uris")
121
+ if not all(allowed_redirect(uri) for uri in redirects):
122
+ raise ClientMetadataError("a redirect URI is neither https, a loopback address nor an app scheme")
123
+ method = document.get("token_endpoint_auth_method") or "none"
124
+ if method != "none" or "client_secret" in document:
125
+ raise ClientMetadataError("only public clients (token_endpoint_auth_method none) may use a metadata document")
126
+ listed = document.get("grant_types") or ["authorization_code"]
127
+ if not isinstance(listed, list) or "authorization_code" not in listed:
128
+ raise ClientMetadataError("the document does not ask for the authorization_code grant")
129
+ grants = [grant for grant in listed if grant in ALLOWED_GRANTS]
130
+ name = document.get("client_name")
131
+ try:
132
+ return HostedClient(
133
+ client_id=url,
134
+ client_name=name if isinstance(name, str) and name.strip() else urlsplit(url).hostname,
135
+ redirect_uris=redirects,
136
+ token_endpoint_auth_method="none",
137
+ grant_types=[*grants, "refresh_token"] if "refresh_token" not in grants else grants,
138
+ response_types=["code"],
139
+ scope=" ".join(SCOPES),
140
+ )
141
+ except ValidationError as exc:
142
+ raise ClientMetadataError(f"the client metadata document is malformed: {exc.error_count()} errors") from exc
143
+
144
+
145
+ class ClientMetadataFetcher:
146
+ def __init__(
147
+ self,
148
+ transport: httpx2.AsyncBaseTransport | None = None,
149
+ resolver: Resolver | None = None,
150
+ clock: Callable[[], float] = time.monotonic,
151
+ ) -> None:
152
+ self.transport = transport
153
+ self.resolver = resolver or system_resolver
154
+ self.clock = clock
155
+ self._cache: dict[str, tuple[HostedClient, float]] = {}
156
+ self._fetches: list[float] = []
157
+
158
+ async def client(self, url: str) -> HostedClient:
159
+ cached = self._cache.get(url)
160
+ if cached is not None and cached[1] > self.clock():
161
+ return cached[0]
162
+ host, port = check_metadata_url(url)
163
+ self._throttle()
164
+ document, lifetime = await self._fetch(url, host, port)
165
+ client = client_from_document(url, document)
166
+ if lifetime:
167
+ self._cache[url] = (client, self.clock() + lifetime)
168
+ else:
169
+ self._cache.pop(url, None)
170
+ return client
171
+
172
+ def _throttle(self) -> None:
173
+ now = self.clock()
174
+ self._fetches = [moment for moment in self._fetches if now - moment < 60]
175
+ if len(self._fetches) >= FETCHES_PER_MINUTE:
176
+ raise ClientMetadataError("too many client metadata fetches; try again in a minute")
177
+ self._fetches.append(now)
178
+
179
+ async def _fetch(self, url: str, host: str, port: int) -> tuple[Any, int]:
180
+ try:
181
+ addresses = await asyncio.wait_for(self.resolver(host, port), FETCH_TIMEOUT)
182
+ except (OSError, TimeoutError) as exc:
183
+ raise ClientMetadataError(f"cannot resolve {host}") from exc
184
+ if not addresses or not all(public_address(address) for address in addresses):
185
+ raise ClientMetadataError(f"{host} resolves to an address this server will not fetch from")
186
+ pinned = pinned_url(url, addresses[0])
187
+ headers = {"Host": host if port == 443 else f"{host}:{port}", "Accept": "application/json"}
188
+ try:
189
+ async with httpx2.AsyncClient(
190
+ transport=self.transport, timeout=FETCH_TIMEOUT, follow_redirects=False, trust_env=False
191
+ ) as http:
192
+ request = http.build_request("GET", pinned, headers=headers, extensions={"sni_hostname": host})
193
+ response = await http.send(request, stream=True)
194
+ try:
195
+ body = await bounded_body(response)
196
+ finally:
197
+ await response.aclose()
198
+ except httpx2.HTTPError as exc:
199
+ raise ClientMetadataError(f"fetching the client metadata document failed: {type(exc).__name__}") from exc
200
+ if response.status_code != 200:
201
+ raise ClientMetadataError(f"the client metadata document answered {response.status_code}")
202
+ try:
203
+ document = json.loads(body)
204
+ except ValueError as exc:
205
+ raise ClientMetadataError("the client metadata document is not JSON") from exc
206
+ return document, cache_seconds(response.headers.get("cache-control", ""))
207
+
208
+
209
+ def pinned_url(url: str, address: str) -> str:
210
+ parts = urlsplit(url)
211
+ ip = ipaddress.ip_address(address)
212
+ netloc = f"[{ip}]" if ip.version == 6 else str(ip)
213
+ if parts.port:
214
+ netloc += f":{parts.port}"
215
+ return urlunsplit(("https", netloc, parts.path, parts.query, ""))
216
+
217
+
218
+ async def bounded_body(response: httpx2.Response) -> bytes:
219
+ declared = response.headers.get("content-length")
220
+ if declared is not None and declared.isdigit() and int(declared) > MAX_DOCUMENT_BYTES:
221
+ raise ClientMetadataError(f"the client metadata document is larger than {MAX_DOCUMENT_BYTES} bytes")
222
+ chunks: list[bytes] = []
223
+ size = 0
224
+ async for chunk in response.aiter_bytes():
225
+ size += len(chunk)
226
+ if size > MAX_DOCUMENT_BYTES:
227
+ raise ClientMetadataError(f"the client metadata document is larger than {MAX_DOCUMENT_BYTES} bytes")
228
+ chunks.append(chunk)
229
+ return b"".join(chunks)
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from collections.abc import Callable, Iterator, Sequence
5
+ from contextlib import AbstractContextManager, contextmanager
6
+ from pathlib import Path
7
+ from typing import Any, Protocol
8
+
9
+
10
+ class Connection(Protocol):
11
+ """One open connection: qmark parameters, rows readable by column name and by index."""
12
+
13
+ def execute(self, sql: str, parameters: Sequence[Any] = (), /) -> Any: ...
14
+
15
+
16
+ Step = Callable[[Any], None]
17
+
18
+
19
+ class Database(Protocol):
20
+ """Where the account store keeps its tables."""
21
+
22
+ dialect: str
23
+ IntegrityError: type[Exception]
24
+
25
+ def connect(self) -> AbstractContextManager[Connection]:
26
+ """A connection that commits when the block ends cleanly and rolls back when it raises."""
27
+ ...
28
+
29
+ def tables(self, connection: Connection) -> list[str]: ...
30
+
31
+ def columns(self, connection: Connection, table: str) -> list[str]: ...
32
+
33
+ def create(self, connection: Connection, script: str) -> None:
34
+ """Run a script of CREATE … IF NOT EXISTS statements, every time the store opens."""
35
+ ...
36
+
37
+ def migrate(self, steps: Sequence[Step]) -> None:
38
+ """Run, in one transaction, the upgrade steps numbered from 1 that this database has not run yet."""
39
+ ...
40
+
41
+
42
+ class SqliteDatabase:
43
+ dialect = "sqlite"
44
+ IntegrityError: type[Exception] = sqlite3.IntegrityError
45
+
46
+ def __init__(self, path: Path) -> None:
47
+ self.path = path
48
+
49
+ @contextmanager
50
+ def connect(self) -> Iterator[sqlite3.Connection]:
51
+ connection = sqlite3.connect(self.path)
52
+ connection.row_factory = sqlite3.Row
53
+ connection.execute("PRAGMA foreign_keys = ON")
54
+ try:
55
+ yield connection
56
+ connection.commit()
57
+ finally:
58
+ connection.close()
59
+
60
+ def tables(self, connection: sqlite3.Connection) -> list[str]:
61
+ return tables(connection)
62
+
63
+ def columns(self, connection: sqlite3.Connection, table: str) -> list[str]:
64
+ return columns(connection, table)
65
+
66
+ def create(self, connection: sqlite3.Connection, script: str) -> None:
67
+ connection.executescript(script)
68
+
69
+ def migrate(self, steps: Sequence[Step]) -> None:
70
+ connection = sqlite3.connect(self.path, isolation_level=None)
71
+ connection.row_factory = sqlite3.Row
72
+ try:
73
+ connection.execute("BEGIN IMMEDIATE")
74
+ try:
75
+ version = int(connection.execute("PRAGMA user_version").fetchone()[0])
76
+ for number, step in enumerate(steps, start=1):
77
+ if version < number:
78
+ step(connection)
79
+ if version < len(steps):
80
+ connection.execute(f"PRAGMA user_version = {len(steps)}")
81
+ connection.execute("COMMIT")
82
+ except BaseException:
83
+ connection.execute("ROLLBACK")
84
+ raise
85
+ finally:
86
+ connection.close()
87
+
88
+
89
+ def tables(connection: sqlite3.Connection) -> list[str]:
90
+ return [row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type = 'table'")]
91
+
92
+
93
+ def columns(connection: sqlite3.Connection, table: str) -> list[str]:
94
+ return [row[1] for row in connection.execute(f'PRAGMA table_info("{table}")')]
95
+
96
+
97
+ def has_table(connection: sqlite3.Connection, table: str) -> bool:
98
+ return table in tables(connection)
99
+
100
+
101
+ def add_column(connection: sqlite3.Connection, table: str, column: str, declaration: str) -> None:
102
+ if has_table(connection, table) and column not in columns(connection, table):
103
+ connection.execute(f'ALTER TABLE "{table}" ADD COLUMN {column} {declaration}')
104
+
105
+
106
+ def account_tables(database: Database, connection: Connection) -> list[str]:
107
+ return [table for table in database.tables(connection) if "account_id" in database.columns(connection, table)]