mindyard 0.1.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.
- mindyard/__init__.py +4 -0
- mindyard/auth/__init__.py +21 -0
- mindyard/auth/backends.py +169 -0
- mindyard/auth/consent.py +76 -0
- mindyard/auth/gate.py +78 -0
- mindyard/auth/policy.py +46 -0
- mindyard/auth/tokens.py +96 -0
- mindyard/cli.py +132 -0
- mindyard/core/config.py +66 -0
- mindyard/core/errors.py +235 -0
- mindyard/core/git.py +86 -0
- mindyard/core/operations.py +525 -0
- mindyard/core/paths.py +57 -0
- mindyard/core/render.py +111 -0
- mindyard/core/search.py +129 -0
- mindyard/identity.py +52 -0
- mindyard/init.py +46 -0
- mindyard/lint/__init__.py +16 -0
- mindyard/lint/checks.py +220 -0
- mindyard/lint/markdown.py +104 -0
- mindyard/lint/runner.py +97 -0
- mindyard/mcp/__init__.py +7 -0
- mindyard/mcp/accesslog.py +33 -0
- mindyard/mcp/app.py +105 -0
- mindyard/mcp/middleware.py +64 -0
- mindyard/mcp/server.py +225 -0
- mindyard/mcp/telemetry.py +145 -0
- mindyard/schema/__init__.py +7 -0
- mindyard/schema/discovery.py +52 -0
- mindyard/schema/manifest.py +180 -0
- mindyard/settings.py +135 -0
- mindyard/static/icon-96.png +0 -0
- mindyard/static/icon.svg +1 -0
- mindyard/templates/blank/.gitignore +2 -0
- mindyard/templates/blank/index.md +3 -0
- mindyard/templates/blank/log.md +6 -0
- mindyard/templates/blank/raw/.gitkeep +0 -0
- mindyard/templates/blank/schema.md +37 -0
- mindyard/templates/blank/wiki/.gitkeep +0 -0
- mindyard/templates/blank/wiki.yaml +18 -0
- mindyard/templates/demo/.gitignore +2 -0
- mindyard/templates/demo/index.md +11 -0
- mindyard/templates/demo/log.md +6 -0
- mindyard/templates/demo/raw/2026-03-seed-swap-notes.md +5 -0
- mindyard/templates/demo/schema.md +20 -0
- mindyard/templates/demo/wiki/concepts/companion-planting.md +11 -0
- mindyard/templates/demo/wiki/events/spring-seed-swap.md +13 -0
- mindyard/templates/demo/wiki/plants/basil.md +13 -0
- mindyard/templates/demo/wiki/plants/tomato.md +14 -0
- mindyard/templates/demo/wiki.yaml +18 -0
- mindyard/tenancy/__init__.py +26 -0
- mindyard/tenancy/context.py +38 -0
- mindyard/tenancy/resolver.py +121 -0
- mindyard/tenancy/tenant.py +42 -0
- mindyard-0.1.0.dist-info/METADATA +124 -0
- mindyard-0.1.0.dist-info/RECORD +59 -0
- mindyard-0.1.0.dist-info/WHEEL +4 -0
- mindyard-0.1.0.dist-info/entry_points.txt +2 -0
- mindyard-0.1.0.dist-info/licenses/LICENSE +661 -0
mindyard/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""Pluggable authentication and access policies."""
|
|
3
|
+
|
|
4
|
+
from .backends import AuthBackend, BearerAuth, GoogleAuth, NoAuth, google_identity
|
|
5
|
+
from .policy import AccessPolicy, AllowAll, AllowAuthenticated, EmailAllowlist
|
|
6
|
+
from .tokens import TokenStore, TokenStoreError, add_token
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AccessPolicy",
|
|
10
|
+
"AllowAll",
|
|
11
|
+
"AllowAuthenticated",
|
|
12
|
+
"AuthBackend",
|
|
13
|
+
"BearerAuth",
|
|
14
|
+
"EmailAllowlist",
|
|
15
|
+
"GoogleAuth",
|
|
16
|
+
"NoAuth",
|
|
17
|
+
"TokenStore",
|
|
18
|
+
"TokenStoreError",
|
|
19
|
+
"add_token",
|
|
20
|
+
"google_identity",
|
|
21
|
+
]
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""Authentication backends: who is calling.
|
|
3
|
+
|
|
4
|
+
A backend has two jobs. It supplies the FastMCP auth provider that guards the
|
|
5
|
+
HTTP endpoint (or none), and it turns the verified access token into a neutral
|
|
6
|
+
:class:`~mindyard.identity.Identity`. Deciding whether that identity may come in is
|
|
7
|
+
the access policy's job, and which wikis it sees is the tenant resolver's.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from typing import Any, Protocol
|
|
14
|
+
|
|
15
|
+
from fastmcp.server.auth import AccessToken, AuthProvider
|
|
16
|
+
from fastmcp.server.auth.providers.google import GoogleProvider
|
|
17
|
+
|
|
18
|
+
from mindyard.identity import Identity
|
|
19
|
+
|
|
20
|
+
from .consent import StableConsentMixin
|
|
21
|
+
from .gate import CallbackGateMixin
|
|
22
|
+
from .policy import AccessPolicy
|
|
23
|
+
from .tokens import BEARER_ISSUER, HashedTokenVerifier, TokenStore
|
|
24
|
+
|
|
25
|
+
GOOGLE_ISSUER = "https://accounts.google.com"
|
|
26
|
+
GOOGLE_SCOPES = ("openid", "email", "profile")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AuthBackend(Protocol):
|
|
30
|
+
name: str
|
|
31
|
+
|
|
32
|
+
def provider(self) -> AuthProvider | None: ...
|
|
33
|
+
|
|
34
|
+
def identity(self, token: AccessToken | None) -> Identity | None: ...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NoAuth:
|
|
38
|
+
"""Local development only. The server refuses to listen beyond loopback."""
|
|
39
|
+
|
|
40
|
+
name = "none"
|
|
41
|
+
|
|
42
|
+
def provider(self) -> AuthProvider | None:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
def identity(self, token: AccessToken | None) -> Identity | None:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class BearerAuth:
|
|
50
|
+
"""Static bearer tokens, stored hashed; one named principal per token.
|
|
51
|
+
|
|
52
|
+
Suited to Claude Code, Claude Desktop (through a stdio bridge) and scripts.
|
|
53
|
+
Claude.ai custom connectors accept static headers only for some accounts,
|
|
54
|
+
so OAuth remains the route there.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
name = "bearer"
|
|
58
|
+
|
|
59
|
+
def __init__(self, store: TokenStore) -> None:
|
|
60
|
+
self._verifier = HashedTokenVerifier(store)
|
|
61
|
+
|
|
62
|
+
def provider(self) -> AuthProvider | None:
|
|
63
|
+
return self._verifier
|
|
64
|
+
|
|
65
|
+
def identity(self, token: AccessToken | None) -> Identity | None:
|
|
66
|
+
if token is None:
|
|
67
|
+
return None
|
|
68
|
+
return Identity(issuer=BEARER_ISSUER, subject=token.client_id)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _attested(flag: Any) -> bool:
|
|
72
|
+
"""True only for an explicit affirmative flag, boolean or string.
|
|
73
|
+
|
|
74
|
+
Google's tokeninfo returns ``"true"``/``"false"`` strings and userinfo a JSON
|
|
75
|
+
boolean. ``"false"`` is a truthy Python string: a plain truth test would read
|
|
76
|
+
a refusal as a proof.
|
|
77
|
+
"""
|
|
78
|
+
if isinstance(flag, bool):
|
|
79
|
+
return flag
|
|
80
|
+
if isinstance(flag, str):
|
|
81
|
+
return flag.strip().lower() == "true"
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def email_verified(claims: Mapping[str, Any]) -> bool:
|
|
86
|
+
"""True only if every source present explicitly attests the email.
|
|
87
|
+
|
|
88
|
+
The flag has two homes: ``email_verified`` at the top level and
|
|
89
|
+
``verified_email`` in the ``google_user_data`` profile, which is ``None``
|
|
90
|
+
when the userinfo fetch fails. No flag is not a verification, and any source
|
|
91
|
+
that disagrees is a refusal.
|
|
92
|
+
"""
|
|
93
|
+
flags = [
|
|
94
|
+
source[key]
|
|
95
|
+
for source in (claims, claims.get("google_user_data"))
|
|
96
|
+
if isinstance(source, Mapping)
|
|
97
|
+
for key in ("email_verified", "verified_email")
|
|
98
|
+
if key in source
|
|
99
|
+
]
|
|
100
|
+
return bool(flags) and all(_attested(flag) for flag in flags)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def google_identity(claims: Mapping[str, Any] | None) -> Identity | None:
|
|
104
|
+
"""Identity from Google claims, or ``None`` without a subject."""
|
|
105
|
+
if not claims:
|
|
106
|
+
return None
|
|
107
|
+
subject = claims.get("sub")
|
|
108
|
+
if not isinstance(subject, str) or not subject:
|
|
109
|
+
return None
|
|
110
|
+
email = claims.get("email")
|
|
111
|
+
return Identity(
|
|
112
|
+
issuer=GOOGLE_ISSUER,
|
|
113
|
+
subject=subject,
|
|
114
|
+
email=email.strip().lower() if isinstance(email, str) and email.strip() else None,
|
|
115
|
+
email_verified=email_verified(claims),
|
|
116
|
+
claims=dict(claims),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class StableConsentGoogleProvider(StableConsentMixin, GoogleProvider):
|
|
121
|
+
"""Google OAuth proxy whose consent page tolerates a second load."""
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class GatedGoogleProvider(CallbackGateMixin, StableConsentGoogleProvider):
|
|
125
|
+
"""The same proxy, refusing an identity the policy denies at the callback."""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class GoogleAuth:
|
|
129
|
+
"""OAuth through Google, proxied by FastMCP so Claude.ai can connect.
|
|
130
|
+
|
|
131
|
+
The proxy persists client registrations and tokens under FastMCP's home
|
|
132
|
+
directory: losing it forces every client to sign in again. Given the access
|
|
133
|
+
policy, it also refuses a denied account at the callback, before the client
|
|
134
|
+
holds a token.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
name = "google"
|
|
138
|
+
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
client_id: str,
|
|
142
|
+
client_secret: str,
|
|
143
|
+
base_url: str,
|
|
144
|
+
redirect_path: str = "/auth/callback",
|
|
145
|
+
policy: AccessPolicy | None = None,
|
|
146
|
+
) -> None:
|
|
147
|
+
self._client_id = client_id
|
|
148
|
+
self._client_secret = client_secret
|
|
149
|
+
self._base_url = base_url
|
|
150
|
+
self._redirect_path = redirect_path
|
|
151
|
+
self._policy = policy
|
|
152
|
+
|
|
153
|
+
def provider(self) -> AuthProvider | None:
|
|
154
|
+
options: dict[str, Any] = {
|
|
155
|
+
"client_id": self._client_id,
|
|
156
|
+
"client_secret": self._client_secret,
|
|
157
|
+
"base_url": self._base_url,
|
|
158
|
+
"redirect_path": self._redirect_path,
|
|
159
|
+
"required_scopes": list(GOOGLE_SCOPES),
|
|
160
|
+
}
|
|
161
|
+
policy = self._policy
|
|
162
|
+
if policy is None:
|
|
163
|
+
return StableConsentGoogleProvider(**options)
|
|
164
|
+
return GatedGoogleProvider(
|
|
165
|
+
admit=lambda token: policy.allows(self.identity(token)), **options
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def identity(self, token: AccessToken | None) -> Identity | None:
|
|
169
|
+
return google_identity(token.claims if token is not None else None)
|
mindyard/auth/consent.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""A consent page whose CSRF token survives being loaded twice.
|
|
3
|
+
|
|
4
|
+
FastMCP's OAuth proxy issues a fresh CSRF token every time ``GET /consent`` is
|
|
5
|
+
served, and stores it on the transaction. Any second load of the same URL thus
|
|
6
|
+
invalidates the form the user is looking at. Second loads happen in practice:
|
|
7
|
+
some mobile browsers hand visited URLs to a scanning service, which fetches the
|
|
8
|
+
page within a second, so the user's "Approve" arrives with a stale token
|
|
9
|
+
("Invalid or expired consent token").
|
|
10
|
+
|
|
11
|
+
This mixin keeps the token stable for the lifetime of the transaction: when the
|
|
12
|
+
transaction already holds an unexpired token, a reload is served that same
|
|
13
|
+
token. The double-submit check is untouched. A third party learning the token
|
|
14
|
+
gains nothing: the token is only accepted together with the browser's own
|
|
15
|
+
``MCP_CONSENT_STATE`` cookie, which is ``SameSite=Lax`` and therefore never
|
|
16
|
+
attached to a cross-site POST.
|
|
17
|
+
|
|
18
|
+
It post-processes FastMCP's response instead of copying its private method, so
|
|
19
|
+
it depends on three internals only: the transaction store and its
|
|
20
|
+
``csrf_token``/``csrf_expires_at`` fields, the cookie helpers, and the token
|
|
21
|
+
appearing verbatim in the rendered form. ``tests/auth/test_consent.py`` replays
|
|
22
|
+
the race end to end and fails if any of them changes.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import time
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from starlette.requests import Request
|
|
31
|
+
from starlette.responses import HTMLResponse, RedirectResponse
|
|
32
|
+
|
|
33
|
+
CONSENT_COOKIE = "MCP_CONSENT_STATE"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class StableConsentMixin:
|
|
37
|
+
"""Mix into an ``OAuthProxy`` subclass, before it in the bases."""
|
|
38
|
+
|
|
39
|
+
async def _show_consent_page(self, request: Request) -> HTMLResponse | RedirectResponse:
|
|
40
|
+
proxy: Any = self
|
|
41
|
+
txn_id = request.query_params.get("txn_id")
|
|
42
|
+
previous: tuple[str, float] | None = None
|
|
43
|
+
if txn_id:
|
|
44
|
+
txn = await proxy._transaction_store.get(key=txn_id)
|
|
45
|
+
if txn is not None and txn.csrf_token and (txn.csrf_expires_at or 0) > time.time():
|
|
46
|
+
previous = (txn.csrf_token, float(txn.csrf_expires_at))
|
|
47
|
+
|
|
48
|
+
response: HTMLResponse | RedirectResponse = await super()._show_consent_page( # type: ignore[misc]
|
|
49
|
+
request
|
|
50
|
+
)
|
|
51
|
+
if previous is None or response.status_code != 200 or not txn_id:
|
|
52
|
+
return response
|
|
53
|
+
txn = await proxy._transaction_store.get(key=txn_id)
|
|
54
|
+
fresh = txn.csrf_token if txn is not None else None
|
|
55
|
+
body = bytes(response.body)
|
|
56
|
+
if not fresh or fresh == previous[0] or fresh.encode() not in body:
|
|
57
|
+
return response
|
|
58
|
+
|
|
59
|
+
token, expires_at = previous
|
|
60
|
+
txn.csrf_token, txn.csrf_expires_at = token, expires_at
|
|
61
|
+
ttl = max(1, int(expires_at - time.time()))
|
|
62
|
+
await proxy._transaction_store.put(key=txn_id, value=txn, ttl=ttl)
|
|
63
|
+
|
|
64
|
+
response.body = body.replace(fresh.encode(), token.encode())
|
|
65
|
+
response.headers["content-length"] = str(len(response.body))
|
|
66
|
+
cookie_name = proxy._cookie_name(CONSENT_COOKIE)
|
|
67
|
+
response.raw_headers = [
|
|
68
|
+
(k, v)
|
|
69
|
+
for k, v in response.raw_headers
|
|
70
|
+
if not (k == b"set-cookie" and v.startswith(cookie_name.encode() + b"="))
|
|
71
|
+
]
|
|
72
|
+
tokens = [t for t in proxy._decode_list_cookie(request, CONSENT_COOKIE) if t != token]
|
|
73
|
+
proxy._set_list_cookie(
|
|
74
|
+
response, CONSENT_COOKIE, proxy._encode_list_cookie([*tokens, token]), max_age=ttl
|
|
75
|
+
)
|
|
76
|
+
return response
|
mindyard/auth/gate.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""Refuse an identity at the OAuth callback, not only at the first MCP request.
|
|
3
|
+
|
|
4
|
+
FastMCP's OAuth proxy hands an authorization code to any account the upstream
|
|
5
|
+
provider signs in. With Google that is any account on earth: the access policy
|
|
6
|
+
then refuses every MCP request, but the client already holds a token. Claude.ai
|
|
7
|
+
keeps such a token and the connector stays stuck until it is disconnected.
|
|
8
|
+
|
|
9
|
+
This mixin checks the identity as soon as Google's tokens are in hand. A refused
|
|
10
|
+
identity gets no code: its code is deleted and the client is redirected with
|
|
11
|
+
``error=access_denied`` (RFC 6749, section 4.1.2.1), so it never stores a token.
|
|
12
|
+
The per-request gate stays the authority; this is the early, friendly refusal.
|
|
13
|
+
|
|
14
|
+
Like the consent mixin, it post-processes FastMCP's response instead of copying
|
|
15
|
+
its handler. It depends on the code store, the ``idp_tokens`` of a stored code
|
|
16
|
+
and the upstream token verifier; ``tests/auth/test_gate.py`` drives the whole
|
|
17
|
+
callback and fails if any of them changes.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections.abc import Awaitable, Callable
|
|
23
|
+
from typing import Any
|
|
24
|
+
from urllib.parse import parse_qs, urlencode, urlparse
|
|
25
|
+
|
|
26
|
+
from fastmcp.server.auth import AccessToken
|
|
27
|
+
from starlette.requests import Request
|
|
28
|
+
from starlette.responses import HTMLResponse, RedirectResponse
|
|
29
|
+
|
|
30
|
+
Admit = Callable[[AccessToken | None], bool]
|
|
31
|
+
|
|
32
|
+
DENIED_DESCRIPTION = "This account is not allowed on this server."
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CallbackGateMixin:
|
|
36
|
+
"""Mix into an ``OAuthProxy`` subclass, before it in the bases."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, *args: Any, admit: Admit, **kwargs: Any) -> None:
|
|
39
|
+
super().__init__(*args, **kwargs)
|
|
40
|
+
self._admit = admit
|
|
41
|
+
|
|
42
|
+
async def _handle_idp_callback(self, request: Request) -> HTMLResponse | RedirectResponse:
|
|
43
|
+
proxy: Any = self
|
|
44
|
+
parent: Callable[[Request], Awaitable[HTMLResponse | RedirectResponse]]
|
|
45
|
+
parent = super()._handle_idp_callback # type: ignore[misc]
|
|
46
|
+
response = await parent(request)
|
|
47
|
+
if not isinstance(response, RedirectResponse):
|
|
48
|
+
return response
|
|
49
|
+
target = urlparse(response.headers["location"])
|
|
50
|
+
params = parse_qs(target.query)
|
|
51
|
+
code = params.get("code", [None])[0]
|
|
52
|
+
if not code:
|
|
53
|
+
return response
|
|
54
|
+
stored = await proxy._code_store.get(key=code)
|
|
55
|
+
if stored is not None and self._admit(await self._verify(stored.idp_tokens)):
|
|
56
|
+
return response
|
|
57
|
+
|
|
58
|
+
await proxy._code_store.delete(key=code)
|
|
59
|
+
denied = {"error": "access_denied", "error_description": DENIED_DESCRIPTION}
|
|
60
|
+
if "state" in params:
|
|
61
|
+
denied["state"] = params["state"][0]
|
|
62
|
+
kept = [(k, v) for k, vs in params.items() if k not in ("code", "state") for v in vs]
|
|
63
|
+
location = target._replace(query=urlencode([*kept, *denied.items()])).geturl()
|
|
64
|
+
refused = RedirectResponse(url=location, status_code=302)
|
|
65
|
+
refused.raw_headers += [(k, v) for k, v in response.raw_headers if k == b"set-cookie"]
|
|
66
|
+
return refused
|
|
67
|
+
|
|
68
|
+
async def _verify(self, idp_tokens: dict[str, Any]) -> AccessToken | None:
|
|
69
|
+
"""The upstream verifier's verdict on the fresh tokens; failure denies."""
|
|
70
|
+
proxy: Any = self
|
|
71
|
+
access_token = idp_tokens.get("access_token")
|
|
72
|
+
if not isinstance(access_token, str) or not access_token:
|
|
73
|
+
return None
|
|
74
|
+
try:
|
|
75
|
+
verified: AccessToken | None = await proxy._token_validator.verify_token(access_token)
|
|
76
|
+
except Exception:
|
|
77
|
+
return None
|
|
78
|
+
return verified
|
mindyard/auth/policy.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""Access policies: may this identity use the server at all.
|
|
3
|
+
|
|
4
|
+
Applied to every MCP request, the handshake included: the server instructions
|
|
5
|
+
and the tool list already describe the wikis, so an identity off the list must
|
|
6
|
+
not even learn what is behind the door.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
|
|
13
|
+
from mindyard.identity import Identity
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AccessPolicy(Protocol):
|
|
17
|
+
def allows(self, identity: Identity | None) -> bool: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AllowAll:
|
|
21
|
+
"""For backends whose credentials are explicit grants (bearer, no auth)."""
|
|
22
|
+
|
|
23
|
+
def allows(self, identity: Identity | None) -> bool:
|
|
24
|
+
return True
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AllowAuthenticated:
|
|
28
|
+
"""Any verified identity; the tenant map then decides what it sees."""
|
|
29
|
+
|
|
30
|
+
def allows(self, identity: Identity | None) -> bool:
|
|
31
|
+
return identity is not None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class EmailAllowlist:
|
|
35
|
+
"""A verified email on the list. An empty list denies everyone."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, emails: frozenset[str]) -> None:
|
|
38
|
+
self._emails = frozenset(e.strip().lower() for e in emails if e.strip())
|
|
39
|
+
|
|
40
|
+
def allows(self, identity: Identity | None) -> bool:
|
|
41
|
+
return (
|
|
42
|
+
identity is not None
|
|
43
|
+
and identity.email_verified
|
|
44
|
+
and identity.email is not None
|
|
45
|
+
and identity.email in self._emails
|
|
46
|
+
)
|
mindyard/auth/tokens.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""Static bearer tokens, stored as SHA-256 digests in ``tokens.yaml``.
|
|
3
|
+
|
|
4
|
+
Tokens are 256-bit random strings, so a fast hash is enough: there is nothing
|
|
5
|
+
to brute-force, and the file never holds a usable secret. Lookup is by digest,
|
|
6
|
+
so comparing a guess costs the same whatever it matches.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import re
|
|
13
|
+
import secrets
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import yaml
|
|
18
|
+
from fastmcp.server.auth import AccessToken, TokenVerifier
|
|
19
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
20
|
+
|
|
21
|
+
BEARER_ISSUER = "mindyard:bearer"
|
|
22
|
+
TOKEN_PREFIX = "myd_"
|
|
23
|
+
_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_.-]{0,62}$")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TokenStoreError(ValueError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def digest(token: str) -> str:
|
|
31
|
+
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def new_token() -> str:
|
|
35
|
+
return TOKEN_PREFIX + secrets.token_urlsafe(32)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _Entry(BaseModel):
|
|
39
|
+
model_config = ConfigDict(extra="forbid")
|
|
40
|
+
name: str
|
|
41
|
+
sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _File(BaseModel):
|
|
45
|
+
model_config = ConfigDict(extra="forbid")
|
|
46
|
+
tokens: list[_Entry] = []
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class TokenStore:
|
|
51
|
+
"""Token digest to principal name. Loaded once, at startup."""
|
|
52
|
+
|
|
53
|
+
by_digest: dict[str, str]
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def load(cls, path: Path) -> TokenStore:
|
|
57
|
+
if not path.is_file():
|
|
58
|
+
return cls({})
|
|
59
|
+
try:
|
|
60
|
+
parsed = _File.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")) or {})
|
|
61
|
+
except (yaml.YAMLError, ValidationError) as exc:
|
|
62
|
+
raise TokenStoreError(f"{path}: invalid ({exc})") from exc
|
|
63
|
+
by_digest: dict[str, str] = {}
|
|
64
|
+
for entry in parsed.tokens:
|
|
65
|
+
if entry.sha256 in by_digest:
|
|
66
|
+
raise TokenStoreError(f"{path}: duplicate token digest")
|
|
67
|
+
by_digest[entry.sha256] = entry.name
|
|
68
|
+
return cls(by_digest)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def add_token(path: Path, name: str) -> str:
|
|
72
|
+
"""Mint a token for ``name``, store its digest, return the clear token once."""
|
|
73
|
+
if not _NAME_RE.match(name):
|
|
74
|
+
raise TokenStoreError(f"invalid token name {name!r}")
|
|
75
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) if path.is_file() else None
|
|
76
|
+
entries = list((data or {}).get("tokens", []))
|
|
77
|
+
if any(e.get("name") == name for e in entries):
|
|
78
|
+
raise TokenStoreError(f"a token named {name!r} already exists")
|
|
79
|
+
token = new_token()
|
|
80
|
+
entries.append({"name": name, "sha256": digest(token)})
|
|
81
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
path.write_text(yaml.safe_dump({"tokens": entries}, sort_keys=False), encoding="utf-8")
|
|
83
|
+
path.chmod(0o600)
|
|
84
|
+
return token
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class HashedTokenVerifier(TokenVerifier):
|
|
88
|
+
def __init__(self, store: TokenStore) -> None:
|
|
89
|
+
super().__init__()
|
|
90
|
+
self._store = store
|
|
91
|
+
|
|
92
|
+
async def verify_token(self, token: str) -> AccessToken | None:
|
|
93
|
+
name = self._store.by_digest.get(digest(token))
|
|
94
|
+
if name is None:
|
|
95
|
+
return None
|
|
96
|
+
return AccessToken(token=token, client_id=name, scopes=[], claims={"sub": name})
|
mindyard/cli.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""The ``mindyard`` command line: init, serve, lint, token."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from mindyard import __version__
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _cmd_init(args: argparse.Namespace) -> int:
|
|
16
|
+
from mindyard.init import InitError, init_vault
|
|
17
|
+
|
|
18
|
+
target = Path(args.directory)
|
|
19
|
+
label = args.label or target.name.replace("-", " ").capitalize()
|
|
20
|
+
try:
|
|
21
|
+
init_vault(target, label, "demo" if args.demo else "blank")
|
|
22
|
+
except InitError as exc:
|
|
23
|
+
print(f"mindyard init: {exc}", file=sys.stderr)
|
|
24
|
+
return 2
|
|
25
|
+
print(f"Vault created in {target}. Edit wiki.yaml and schema.md to make it yours.")
|
|
26
|
+
return 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _cmd_serve(args: argparse.Namespace) -> int:
|
|
30
|
+
from mindyard.mcp import run
|
|
31
|
+
from mindyard.settings import Settings, SettingsError
|
|
32
|
+
from mindyard.tenancy import TenantConfigError
|
|
33
|
+
|
|
34
|
+
env = dict(os.environ)
|
|
35
|
+
for flag, name in (
|
|
36
|
+
("data_root", "MINDYARD_DATA_ROOT"),
|
|
37
|
+
("host", "MINDYARD_HOST"),
|
|
38
|
+
("port", "MINDYARD_PORT"),
|
|
39
|
+
):
|
|
40
|
+
value = getattr(args, flag)
|
|
41
|
+
if value is not None:
|
|
42
|
+
env[name] = str(value)
|
|
43
|
+
try:
|
|
44
|
+
run(Settings.from_env(env))
|
|
45
|
+
except (SettingsError, TenantConfigError) as exc:
|
|
46
|
+
print(f"mindyard serve: {exc}", file=sys.stderr)
|
|
47
|
+
return 2
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _cmd_lint(args: argparse.Namespace) -> int:
|
|
52
|
+
from mindyard.core.config import CONFIG_FILE
|
|
53
|
+
from mindyard.lint import fix_table_pipes, lint_wiki, write_report
|
|
54
|
+
from mindyard.schema import ManifestError, Wiki, load_manifest
|
|
55
|
+
|
|
56
|
+
root = Path(args.vault).resolve()
|
|
57
|
+
if not (root / CONFIG_FILE).is_file():
|
|
58
|
+
print(f"mindyard lint: no {CONFIG_FILE} in {root}", file=sys.stderr)
|
|
59
|
+
return 2
|
|
60
|
+
try:
|
|
61
|
+
manifest = load_manifest(root)
|
|
62
|
+
except ManifestError as exc:
|
|
63
|
+
print(f"mindyard lint: {exc}", file=sys.stderr)
|
|
64
|
+
return 2
|
|
65
|
+
wiki = Wiki(manifest.to_config(root.name, root), manifest)
|
|
66
|
+
if args.fix:
|
|
67
|
+
for page, count in fix_table_pipes(wiki):
|
|
68
|
+
print(f"fixed {count} table pipe(s) in {page}")
|
|
69
|
+
findings = lint_wiki(wiki)
|
|
70
|
+
report = write_report(wiki, findings)
|
|
71
|
+
print(f"{findings.total} issue(s), {len(findings.items) - findings.total} notice(s): {report}")
|
|
72
|
+
return 1 if findings.total else 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _cmd_token(args: argparse.Namespace) -> int:
|
|
76
|
+
from mindyard.auth import TokenStoreError, add_token
|
|
77
|
+
|
|
78
|
+
data_root = args.data_root or os.environ.get("MINDYARD_DATA_ROOT")
|
|
79
|
+
if not data_root:
|
|
80
|
+
print("mindyard token: set MINDYARD_DATA_ROOT or pass --data-root", file=sys.stderr)
|
|
81
|
+
return 2
|
|
82
|
+
try:
|
|
83
|
+
token = add_token(Path(data_root) / "tokens.yaml", args.name)
|
|
84
|
+
except TokenStoreError as exc:
|
|
85
|
+
print(f"mindyard token: {exc}", file=sys.stderr)
|
|
86
|
+
return 2
|
|
87
|
+
print(token)
|
|
88
|
+
print(
|
|
89
|
+
"Shown once: only its digest is stored. Restart the server to load it.",
|
|
90
|
+
file=sys.stderr,
|
|
91
|
+
)
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
96
|
+
parser = argparse.ArgumentParser(prog="mindyard", description=__doc__)
|
|
97
|
+
parser.add_argument("--version", action="version", version=f"mindyard {__version__}")
|
|
98
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
99
|
+
|
|
100
|
+
p = sub.add_parser("init", help="create a vault")
|
|
101
|
+
p.add_argument("directory")
|
|
102
|
+
p.add_argument("--label", help="display name (default: from the directory name)")
|
|
103
|
+
p.add_argument("--demo", action="store_true", help="start from the demo garden vault")
|
|
104
|
+
p.set_defaults(func=_cmd_init)
|
|
105
|
+
|
|
106
|
+
p = sub.add_parser("serve", help="run the MCP server (configured by MINDYARD_* variables)")
|
|
107
|
+
p.add_argument("--data-root")
|
|
108
|
+
p.add_argument("--host")
|
|
109
|
+
p.add_argument("--port", type=int)
|
|
110
|
+
p.set_defaults(func=_cmd_serve)
|
|
111
|
+
|
|
112
|
+
p = sub.add_parser("lint", help="lint one vault and write its LINT_REPORT.md")
|
|
113
|
+
p.add_argument("vault")
|
|
114
|
+
p.add_argument("--fix", action="store_true", help="escape table-cell wikilink pipes first")
|
|
115
|
+
p.set_defaults(func=_cmd_lint)
|
|
116
|
+
|
|
117
|
+
p = sub.add_parser("token", help="mint a bearer token")
|
|
118
|
+
p.add_argument("action", choices=["new"])
|
|
119
|
+
p.add_argument("name", help="principal name, e.g. laptop")
|
|
120
|
+
p.add_argument("--data-root")
|
|
121
|
+
p.set_defaults(func=_cmd_token)
|
|
122
|
+
return parser
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
126
|
+
args = build_parser().parse_args(argv)
|
|
127
|
+
code: int = args.func(args)
|
|
128
|
+
return code
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
if __name__ == "__main__":
|
|
132
|
+
raise SystemExit(main())
|
mindyard/core/config.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
"""Resolved, immutable configuration of one wiki, as the core sees it.
|
|
3
|
+
|
|
4
|
+
The core never parses configuration files: ``mindyard.schema`` turns a vault's
|
|
5
|
+
``wiki.yaml`` into a :class:`WikiConfig`, and the tenancy layer hands the core a
|
|
6
|
+
:class:`WikiSet` already scoped to the caller. Nothing in ``mindyard.core`` knows
|
|
7
|
+
where a wiki came from or whom it belongs to.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Iterator, Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .errors import UnknownWikiError
|
|
17
|
+
|
|
18
|
+
CONFIG_FILE = "wiki.yaml"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class WikiConfig:
|
|
23
|
+
"""Layout of a single wiki vault.
|
|
24
|
+
|
|
25
|
+
``index_file``, ``log_file`` and ``schema_file`` are the only root files the
|
|
26
|
+
write path may touch. ``config_file`` (``wiki.yaml``) is deliberately not one
|
|
27
|
+
of them: the model must not be able to rewrite the rules that bind it.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
id: str
|
|
31
|
+
root: Path
|
|
32
|
+
label: str
|
|
33
|
+
description: str = ""
|
|
34
|
+
wiki_dir: str = "wiki"
|
|
35
|
+
raw_dir: str = "raw"
|
|
36
|
+
index_file: str = "index.md"
|
|
37
|
+
log_file: str = "log.md"
|
|
38
|
+
schema_file: str = "schema.md"
|
|
39
|
+
lint_report_file: str = "LINT_REPORT.md"
|
|
40
|
+
log_sentinel: str = "<!-- log-end -->"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class WikiSet(Mapping[str, WikiConfig]):
|
|
44
|
+
"""The wikis visible to one caller, keyed by id.
|
|
45
|
+
|
|
46
|
+
:meth:`get_wiki` is the single validation gate for a ``wiki`` argument. The
|
|
47
|
+
error lists only the ids of this set, so it never reveals another tenant's.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, wikis: Mapping[str, WikiConfig]) -> None:
|
|
51
|
+
self._wikis = dict(wikis)
|
|
52
|
+
|
|
53
|
+
def __getitem__(self, key: str) -> WikiConfig:
|
|
54
|
+
return self._wikis[key]
|
|
55
|
+
|
|
56
|
+
def __iter__(self) -> Iterator[str]:
|
|
57
|
+
return iter(self._wikis)
|
|
58
|
+
|
|
59
|
+
def __len__(self) -> int:
|
|
60
|
+
return len(self._wikis)
|
|
61
|
+
|
|
62
|
+
def get_wiki(self, wiki_id: str) -> WikiConfig:
|
|
63
|
+
try:
|
|
64
|
+
return self._wikis[wiki_id]
|
|
65
|
+
except KeyError:
|
|
66
|
+
raise UnknownWikiError(wiki_id, list(self._wikis)) from None
|