memuron 0.1.1__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 (74) hide show
  1. memuron/__init__.py +3 -0
  2. memuron/actions/__init__.py +12 -0
  3. memuron/actions/context.py +63 -0
  4. memuron/actions/helpers.py +88 -0
  5. memuron/actions/memory.py +340 -0
  6. memuron/actions/memory_write.py +290 -0
  7. memuron/actions/nodes.py +340 -0
  8. memuron/actions/registry.py +5 -0
  9. memuron/actions/runtime.py +37 -0
  10. memuron/actions/spaces_documents.py +720 -0
  11. memuron/actions/sync.py +155 -0
  12. memuron/application/__init__.py +1 -0
  13. memuron/application/api.py +206 -0
  14. memuron/application/app.py +103 -0
  15. memuron/application/capabilities.py +82 -0
  16. memuron/application/cli.py +35 -0
  17. memuron/application/config.py +176 -0
  18. memuron/application/mcp.py +44 -0
  19. memuron/application/mcp_oauth.py +290 -0
  20. memuron/application/registry.py +52 -0
  21. memuron/context.py +532 -0
  22. memuron/documents/__init__.py +1 -0
  23. memuron/documents/link_guardian.py +192 -0
  24. memuron/documents/linking.py +292 -0
  25. memuron/documents/parser.py +1152 -0
  26. memuron/documents/storage.py +151 -0
  27. memuron/documents/url_ingest.py +375 -0
  28. memuron/domain/__init__.py +1 -0
  29. memuron/domain/decoders.py +1 -0
  30. memuron/domain/encoders.py +185 -0
  31. memuron/domain/lifecycles.py +8 -0
  32. memuron/domain/limits.py +6 -0
  33. memuron/domain/representations.py +56 -0
  34. memuron/domain/schemas.py +581 -0
  35. memuron/domain/scope_filter.py +104 -0
  36. memuron/graphfs/__init__.py +1 -0
  37. memuron/graphfs/manual.py +635 -0
  38. memuron/graphfs/projection.py +578 -0
  39. memuron/graphfs/query.py +1782 -0
  40. memuron/graphfs/read_model.py +574 -0
  41. memuron/ingest/__init__.py +1 -0
  42. memuron/ingest/guardian.py +213 -0
  43. memuron/ingest/jobs.py +424 -0
  44. memuron/ingest/prompts.py +147 -0
  45. memuron/memory/__init__.py +1 -0
  46. memuron/memory/engine.py +35 -0
  47. memuron/memory/projections.py +452 -0
  48. memuron/memory/recipes.py +3247 -0
  49. memuron/persistence/__init__.py +1 -0
  50. memuron/persistence/db_pool.py +57 -0
  51. memuron/persistence/identity_store.py +918 -0
  52. memuron/persistence/store_helpers.py +16 -0
  53. memuron/search/__init__.py +1 -0
  54. memuron/search/fulltext.py +110 -0
  55. memuron/search/hybrid.py +284 -0
  56. memuron/search/pgvector.py +252 -0
  57. memuron/security/__init__.py +1 -0
  58. memuron/security/auth.py +143 -0
  59. memuron/security/auth_provider.py +119 -0
  60. memuron/security/authorization.py +53 -0
  61. memuron/security/clerk_scopes.py +94 -0
  62. memuron/security/clerk_webhooks.py +61 -0
  63. memuron/security/jwt_tokens.py +53 -0
  64. memuron/security/passwords.py +38 -0
  65. memuron/security/tenant.py +58 -0
  66. memuron/spaces/__init__.py +1 -0
  67. memuron/spaces/model.py +35 -0
  68. memuron/spaces/service.py +155 -0
  69. memuron/sync/__init__.py +25 -0
  70. memuron/sync/folder.py +828 -0
  71. memuron-0.1.1.dist-info/METADATA +242 -0
  72. memuron-0.1.1.dist-info/RECORD +74 -0
  73. memuron-0.1.1.dist-info/WHEEL +4 -0
  74. memuron-0.1.1.dist-info/entry_points.txt +4 -0
@@ -0,0 +1,143 @@
1
+ """Authentication for Memuron and mounted engine routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import secrets
6
+
7
+ from fastapi import HTTPException, Request, Security
8
+ from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer
9
+ from artha_engine.runtime.auth import AuthContext
10
+
11
+ from memuron.application.config import settings
12
+ from memuron.security.jwt_tokens import TokenError, decode_access_token
13
+ from memuron.security.tenant import role_scopes
14
+
15
+ _bearer = HTTPBearer(auto_error=False)
16
+ _api_key_header = APIKeyHeader(name="X-Memuron-Api-Key", auto_error=False)
17
+
18
+
19
+ def extract_bearer_token(
20
+ request: Request,
21
+ bearer: HTTPAuthorizationCredentials | None = None,
22
+ header_key: str | None = None,
23
+ ) -> str | None:
24
+ if bearer and bearer.credentials:
25
+ return bearer.credentials
26
+ auth_header = request.headers.get("authorization")
27
+ if auth_header and auth_header.lower().startswith("bearer "):
28
+ return auth_header[7:].strip()
29
+ if header_key:
30
+ return header_key
31
+ return request.headers.get("x-memuron-api-key")
32
+
33
+
34
+ def verify_request_api_key(request: Request) -> bool:
35
+ configured = settings.api_key
36
+ if not configured:
37
+ return True
38
+ provided = extract_bearer_token(request)
39
+ if not provided:
40
+ return False
41
+ return secrets.compare_digest(provided, configured)
42
+
43
+
44
+ def _csv_header(value: str | None) -> list[str]:
45
+ if not value:
46
+ return []
47
+ return [part.strip() for part in value.split(",") if part.strip()]
48
+
49
+
50
+ def _auth_context_from_request(request: Request, *, authenticated: bool) -> AuthContext:
51
+ actor_id = request.headers.get("x-memuron-actor-id")
52
+ tenant_id = request.headers.get("x-memuron-tenant-id")
53
+ scopes = _csv_header(request.headers.get("x-memuron-scopes"))
54
+ if actor_id is None:
55
+ actor_id = "api_key" if authenticated else "anonymous"
56
+ return AuthContext(
57
+ actor_id=actor_id,
58
+ tenant_id=tenant_id,
59
+ scopes=scopes,
60
+ claims={
61
+ "auth_scheme": "api_key" if authenticated else "none",
62
+ "service": "memuron",
63
+ },
64
+ )
65
+
66
+
67
+ def _auth_context_from_jwt(payload: dict[str, object]) -> AuthContext:
68
+ role = str(payload.get("role") or "")
69
+ return AuthContext(
70
+ actor_id=str(payload["sub"]),
71
+ tenant_id=str(payload["org_id"]) if payload.get("org_id") else None,
72
+ scopes=role_scopes(role),
73
+ claims={
74
+ "auth_scheme": "jwt",
75
+ "service": "memuron",
76
+ "email": str(payload.get("email") or ""),
77
+ "org_slug": str(payload.get("org_slug") or ""),
78
+ "role": role,
79
+ },
80
+ )
81
+
82
+
83
+ def _is_public_path(path: str) -> bool:
84
+ normalized = path.rstrip("/")
85
+ if normalized.endswith("/health"):
86
+ return True
87
+ if normalized.endswith("/auth/register") or normalized.endswith("/auth/login"):
88
+ return True
89
+ return False
90
+
91
+
92
+ def _looks_like_jwt(token: str) -> bool:
93
+ return token.count(".") == 2
94
+
95
+
96
+ def require_auth_context(
97
+ request: Request,
98
+ bearer: HTTPAuthorizationCredentials | None = Security(_bearer),
99
+ header_key: str | None = Security(_api_key_header),
100
+ ) -> AuthContext:
101
+ if _is_public_path(request.url.path):
102
+ return AuthContext(actor_id="public", scopes=["health:read"])
103
+
104
+ token = extract_bearer_token(request, bearer, header_key)
105
+ if token and _looks_like_jwt(token):
106
+ try:
107
+ payload = decode_access_token(token)
108
+ return _auth_context_from_jwt(payload)
109
+ except TokenError as exc:
110
+ raise HTTPException(status_code=401, detail="Invalid or expired token") from exc
111
+
112
+ if settings.api_key:
113
+ if token and secrets.compare_digest(token, settings.api_key):
114
+ return _auth_context_from_request(request, authenticated=True)
115
+ raise HTTPException(status_code=401, detail="Invalid or missing credentials")
116
+
117
+ if settings.auth_required:
118
+ raise HTTPException(status_code=401, detail="Authentication required")
119
+
120
+ return _auth_context_from_request(request, authenticated=False)
121
+
122
+
123
+ def require_api_key(
124
+ request: Request,
125
+ bearer: HTTPAuthorizationCredentials | None = Security(_bearer),
126
+ header_key: str | None = Security(_api_key_header),
127
+ ) -> None:
128
+ require_auth_context(request, bearer, header_key)
129
+
130
+
131
+ def verify_engine_request(request: Request) -> bool:
132
+ if not (settings.auth_required or settings.api_key):
133
+ return True
134
+ token = extract_bearer_token(request)
135
+ if token and _looks_like_jwt(token):
136
+ try:
137
+ decode_access_token(token)
138
+ return True
139
+ except TokenError:
140
+ return False
141
+ if settings.api_key:
142
+ return verify_request_api_key(request)
143
+ return not settings.auth_required
@@ -0,0 +1,119 @@
1
+ """Memuron authentication providers (Clerk + API key for agents)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import secrets
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ from artha_engine.app.auth import (
11
+ AnonymousAuthProvider,
12
+ AuthenticationError,
13
+ AuthConfigurationError,
14
+ AuthProvider,
15
+ ClerkAuthProvider,
16
+ )
17
+ from artha_engine.app.managed_api_keys import (
18
+ CompositeAuthProvider,
19
+ ManagedApiKeyAuthProvider,
20
+ )
21
+ from artha_engine.runtime.auth import AuthContext
22
+ from artha_engine.store.api_key_store import ApiKeyStore
23
+
24
+ from memuron.security.clerk_scopes import enrich_auth_context
25
+ from memuron.application.config import settings
26
+
27
+
28
+ def _bearer_token(request: Any) -> str | None:
29
+ header = request.headers.get("authorization")
30
+ if header and header.lower().startswith("bearer "):
31
+ return header[7:].strip()
32
+ return None
33
+
34
+
35
+ def _csv_header(value: str | None) -> list[str]:
36
+ return [part.strip() for part in (value or "").split(",") if part.strip()]
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class MemuronApiKeyAuthProvider:
41
+ """API key auth with Memuron and Artha header conventions."""
42
+
43
+ env_var: str = "MEMURON_API_KEY"
44
+ actor_id: str = "api_key"
45
+ scopes: tuple[str, ...] = ("*",)
46
+
47
+ async def authenticate_http(self, request: Any) -> AuthContext:
48
+ expected = os.environ.get(self.env_var, "").strip() or settings.api_key
49
+ if not expected:
50
+ if not settings.auth_required:
51
+ return AuthContext(actor_id="anonymous", scopes=["*"])
52
+ raise AuthConfigurationError(
53
+ f"Set {self.env_var} before serving protected Memuron actions"
54
+ )
55
+ provided = (
56
+ _bearer_token(request)
57
+ or request.headers.get("x-memuron-api-key")
58
+ or request.headers.get("x-artha-api-key")
59
+ )
60
+ if not provided or not secrets.compare_digest(provided, expected):
61
+ raise AuthenticationError("Invalid or missing API key")
62
+ actor_id = (
63
+ request.headers.get("x-memuron-actor-id")
64
+ or request.headers.get("x-artha-actor-id")
65
+ or self.actor_id
66
+ )
67
+ tenant_id = request.headers.get("x-memuron-tenant-id") or request.headers.get(
68
+ "x-artha-tenant-id"
69
+ )
70
+ header_scopes = _csv_header(request.headers.get("x-memuron-scopes")) or _csv_header(
71
+ request.headers.get("x-artha-scopes")
72
+ )
73
+ return AuthContext(
74
+ actor_id=actor_id,
75
+ tenant_id=tenant_id,
76
+ scopes=header_scopes or list(self.scopes),
77
+ claims={"auth_scheme": "api_key", "service": "memuron"},
78
+ )
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class MemuronClerkAuthProvider:
83
+ inner: ClerkAuthProvider
84
+
85
+ async def authenticate_http(self, request: Any) -> AuthContext:
86
+ auth = await self.inner.authenticate_http(request)
87
+ return enrich_auth_context(auth)
88
+
89
+
90
+ def create_memuron_auth_provider(
91
+ *,
92
+ api_key_store: ApiKeyStore | None = None,
93
+ ) -> AuthProvider:
94
+ provider = os.environ.get("ARTHA_AUTH_PROVIDER", "clerk_api_key").strip().lower()
95
+ if provider in {"none", "anonymous"} or not settings.auth_required:
96
+ return AnonymousAuthProvider()
97
+ if provider in {"api-key", "api_key"}:
98
+ if api_key_store is not None:
99
+ return ManagedApiKeyAuthProvider(
100
+ store=api_key_store,
101
+ bootstrap_env_var="MEMURON_API_KEY",
102
+ alternate_header_names=("x-memuron-api-key",),
103
+ )
104
+ return MemuronApiKeyAuthProvider()
105
+ clerk = MemuronClerkAuthProvider(ClerkAuthProvider())
106
+ if provider == "clerk":
107
+ return clerk
108
+ if provider in {"composite", "clerk_api_key"}:
109
+ fallback: AuthProvider
110
+ if api_key_store is not None:
111
+ fallback = ManagedApiKeyAuthProvider(
112
+ store=api_key_store,
113
+ bootstrap_env_var="MEMURON_API_KEY",
114
+ alternate_header_names=("x-memuron-api-key",),
115
+ )
116
+ else:
117
+ fallback = MemuronApiKeyAuthProvider()
118
+ return CompositeAuthProvider(primary=clerk, fallback=fallback)
119
+ raise AuthConfigurationError(f"Unknown ARTHA_AUTH_PROVIDER: {provider}")
@@ -0,0 +1,53 @@
1
+ """Memuron authorization policy for Artha actions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from artha_engine.app.actions import ActionDefinition
8
+ from artha_engine.app.authorization import AuthorizationDenied, ScopeAuthorizationPolicy
9
+ from artha_engine.app.context import ActionContext
10
+
11
+ ORG_SCOPED_PREFIXES = ("space.", "memory.")
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class MemuronAuthorizationPolicy:
16
+ """Scope checks plus org context for tenant-scoped actions."""
17
+
18
+ scopes: ScopeAuthorizationPolicy | None = None
19
+
20
+ def __post_init__(self) -> None:
21
+ object.__setattr__(self, "scopes", self.scopes or ScopeAuthorizationPolicy())
22
+
23
+ def authorize(self, action: ActionDefinition, context: ActionContext) -> None:
24
+ self.scopes.authorize(action, context)
25
+ if action.public:
26
+ return
27
+ if not _requires_org_context(action.name):
28
+ return
29
+ if context.auth.tenant_id:
30
+ return
31
+ raise AuthorizationDenied(
32
+ f"Action {action.name} requires an active organization (Clerk org_id)"
33
+ )
34
+
35
+
36
+ def _requires_org_context(action_name: str) -> bool:
37
+ if action_name.startswith(ORG_SCOPED_PREFIXES):
38
+ return True
39
+ if action_name in {
40
+ "node.create",
41
+ "node.link",
42
+ "collection.create",
43
+ "collection.place",
44
+ "collection.members",
45
+ "document.ingest",
46
+ "graph.export",
47
+ "graph.hubs",
48
+ "graph.neighborhood",
49
+ "graph.path",
50
+ "graph.traverse",
51
+ }:
52
+ return True
53
+ return False
@@ -0,0 +1,94 @@
1
+ """Map Clerk session claims to Memuron action scopes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from artha_engine.runtime.auth import AuthContext
8
+
9
+ MEMURON_OWNER_SCOPES = [
10
+ "memory:read",
11
+ "memory:write",
12
+ "memory:delete",
13
+ "space:admin",
14
+ "org:admin",
15
+ "artha:admin",
16
+ "api_key:manage",
17
+ ]
18
+
19
+ MEMURON_MEMBER_SCOPES = [
20
+ "memory:read",
21
+ "memory:write",
22
+ "memory:delete",
23
+ ]
24
+
25
+
26
+ def clerk_org_claims(claims: dict[str, Any]) -> tuple[str | None, str | None]:
27
+ """Extract org id and role from Clerk JWT v1 or v2 session tokens."""
28
+ org_object = claims.get("o")
29
+ if claims.get("v") == 2 and isinstance(org_object, dict):
30
+ org_id = org_object.get("id")
31
+ raw_role = org_object.get("rol")
32
+ if raw_role:
33
+ role_text = str(raw_role).lower()
34
+ org_role = role_text if role_text.startswith("org:") else f"org:{role_text}"
35
+ else:
36
+ org_role = None
37
+ return (
38
+ str(org_id) if org_id is not None else None,
39
+ org_role,
40
+ )
41
+
42
+ org_id = claims.get("org_id")
43
+ raw_role = claims.get("org_role") or claims.get("role")
44
+ org_role = str(raw_role).lower() if raw_role else None
45
+ return (
46
+ str(org_id) if org_id is not None else None,
47
+ org_role,
48
+ )
49
+
50
+
51
+ def scopes_from_clerk_claims(claims: dict[str, Any]) -> list[str]:
52
+ _, org_role = clerk_org_claims(claims)
53
+ if not org_role:
54
+ org_role = str(claims.get("org_role") or claims.get("role") or "").lower()
55
+ if org_role in {"org:admin", "admin"}:
56
+ return list(MEMURON_OWNER_SCOPES)
57
+ if org_role in {"org:member", "basic_member", "member"}:
58
+ return list(MEMURON_MEMBER_SCOPES)
59
+ for claim_name in ("permissions", "org_permissions", "scope", "scp"):
60
+ value = claims.get(claim_name)
61
+ if isinstance(value, str):
62
+ parts = [part.strip() for part in value.replace(",", " ").split() if part.strip()]
63
+ if parts:
64
+ return parts
65
+ if isinstance(value, list) and value:
66
+ return [str(item) for item in value]
67
+ org_object = claims.get("o")
68
+ if isinstance(org_object, dict):
69
+ perms = org_object.get("per")
70
+ if isinstance(perms, str):
71
+ parts = [part.strip() for part in perms.replace(",", " ").split() if part.strip()]
72
+ if parts:
73
+ return parts
74
+ return list(MEMURON_MEMBER_SCOPES)
75
+
76
+
77
+ def enrich_auth_context(auth: AuthContext) -> AuthContext:
78
+ if auth.claims.get("auth_scheme") == "api_key":
79
+ return auth
80
+ scopes = scopes_from_clerk_claims(auth.claims)
81
+ if "*" in auth.scopes:
82
+ return auth
83
+ merged = list(auth.scopes)
84
+ for scope in scopes:
85
+ if scope not in merged:
86
+ merged.append(scope)
87
+ org_id, _ = clerk_org_claims(auth.claims)
88
+ tenant_id = auth.tenant_id or org_id
89
+ return AuthContext(
90
+ actor_id=auth.actor_id,
91
+ tenant_id=tenant_id,
92
+ scopes=merged,
93
+ claims=auth.claims,
94
+ )
@@ -0,0 +1,61 @@
1
+ """Clerk webhook handlers for org/space provisioning."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from typing import Any
8
+
9
+ from fastapi import APIRouter, HTTPException, Request
10
+
11
+ from memuron.persistence.identity_store import IdentityStore
12
+
13
+ router = APIRouter(tags=["webhooks"])
14
+
15
+
16
+ def _verify_clerk_webhook(request: Request, body: bytes) -> dict[str, Any]:
17
+ secret = os.environ.get("CLERK_WEBHOOK_SECRET", "").strip()
18
+ if not secret:
19
+ raise HTTPException(status_code=503, detail="CLERK_WEBHOOK_SECRET is not configured")
20
+ try:
21
+ from svix.webhooks import Webhook
22
+ except ImportError as exc:
23
+ raise HTTPException(
24
+ status_code=503,
25
+ detail="Install svix to verify Clerk webhooks",
26
+ ) from exc
27
+ headers = {
28
+ "svix-id": request.headers.get("svix-id", ""),
29
+ "svix-timestamp": request.headers.get("svix-timestamp", ""),
30
+ "svix-signature": request.headers.get("svix-signature", ""),
31
+ }
32
+ try:
33
+ payload = Webhook(secret).verify(body.decode("utf-8"), headers)
34
+ except Exception as exc:
35
+ raise HTTPException(status_code=400, detail="Invalid Clerk webhook signature") from exc
36
+ if isinstance(payload, str):
37
+ return json.loads(payload)
38
+ return payload
39
+
40
+
41
+ @router.post("/memuron/webhooks/clerk")
42
+ async def clerk_webhook(request: Request) -> dict[str, str]:
43
+ body = await request.body()
44
+ event = _verify_clerk_webhook(request, body)
45
+ event_type = str(event.get("type") or "")
46
+ data = event.get("data") or {}
47
+ identity: IdentityStore | None = getattr(request.app.state, "identity_store", None)
48
+ if identity is None:
49
+ raise HTTPException(status_code=503, detail="Identity store unavailable")
50
+
51
+ if event_type == "organization.created":
52
+ org_id = str(data.get("id") or "")
53
+ if org_id:
54
+ identity.ensure_org_spaces(org_id, user_id="system")
55
+ elif event_type in {"organizationMembership.created", "organizationMembership.updated"}:
56
+ org_id = str(data.get("organization_id") or data.get("organization", {}).get("id") or "")
57
+ user_id = str(data.get("public_user_data", {}).get("user_id") or data.get("user_id") or "")
58
+ if org_id and user_id:
59
+ identity.ensure_org_spaces(org_id, user_id)
60
+
61
+ return {"status": "ok"}
@@ -0,0 +1,53 @@
1
+ """JWT access tokens for Memuron users."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime, timedelta
6
+ from typing import Any
7
+
8
+ import jwt
9
+
10
+ from memuron.application.config import settings
11
+
12
+
13
+ class TokenError(Exception):
14
+ pass
15
+
16
+
17
+ def create_access_token(
18
+ *,
19
+ user_id: str,
20
+ email: str,
21
+ org_id: str | None,
22
+ org_slug: str | None,
23
+ role: str | None,
24
+ ) -> str:
25
+ now = datetime.now(UTC)
26
+ payload = {
27
+ "sub": user_id,
28
+ "email": email,
29
+ "org_id": org_id,
30
+ "org_slug": org_slug,
31
+ "role": role,
32
+ "iat": int(now.timestamp()),
33
+ "exp": int((now + timedelta(hours=settings.jwt_expire_hours)).timestamp()),
34
+ "iss": settings.jwt_issuer,
35
+ "typ": "access",
36
+ }
37
+ return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
38
+
39
+
40
+ def decode_access_token(token: str) -> dict[str, Any]:
41
+ try:
42
+ payload = jwt.decode(
43
+ token,
44
+ settings.jwt_secret,
45
+ algorithms=[settings.jwt_algorithm],
46
+ issuer=settings.jwt_issuer,
47
+ options={"require": ["exp", "sub", "iat", "iss"]},
48
+ )
49
+ except jwt.PyJWTError as exc:
50
+ raise TokenError(str(exc)) from exc
51
+ if payload.get("typ") != "access":
52
+ raise TokenError("invalid token type")
53
+ return payload
@@ -0,0 +1,38 @@
1
+ """Password hashing helpers (stdlib only)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import secrets
8
+
9
+ _PBKDF2_ITERATIONS = 600_000
10
+
11
+
12
+ def hash_password(password: str) -> str:
13
+ if len(password) < 8:
14
+ raise ValueError("password must be at least 8 characters")
15
+ salt = secrets.token_hex(16)
16
+ digest = hashlib.pbkdf2_hmac(
17
+ "sha256",
18
+ password.encode("utf-8"),
19
+ salt.encode("utf-8"),
20
+ _PBKDF2_ITERATIONS,
21
+ )
22
+ return f"pbkdf2_sha256${_PBKDF2_ITERATIONS}${salt}${digest.hex()}"
23
+
24
+
25
+ def verify_password(password: str, stored: str) -> bool:
26
+ try:
27
+ scheme, iterations, salt, digest_hex = stored.split("$", 3)
28
+ except ValueError:
29
+ return False
30
+ if scheme != "pbkdf2_sha256":
31
+ return False
32
+ computed = hashlib.pbkdf2_hmac(
33
+ "sha256",
34
+ password.encode("utf-8"),
35
+ salt.encode("utf-8"),
36
+ int(iterations),
37
+ )
38
+ return hmac.compare_digest(computed.hex(), digest_hex)
@@ -0,0 +1,58 @@
1
+ """Org tenancy and space scope helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def org_scope_token(org_id: str) -> str:
7
+ return f"org:{org_id}"
8
+
9
+
10
+ def normalize_tenant_scope(scope: list[str] | None, org_id: str | None) -> list[str]:
11
+ """Drop foreign org:* tokens and ensure the caller's org token is present."""
12
+ if not org_id:
13
+ return list(scope or [])
14
+ token = org_scope_token(org_id)
15
+ cleaned = [item for item in (scope or []) if not str(item).startswith("org:")]
16
+ return [token, *cleaned]
17
+
18
+
19
+ def merge_org_scope(scope: list[str] | None, org_id: str | None) -> list[str]:
20
+ return normalize_tenant_scope(scope, org_id)
21
+
22
+
23
+ def merge_space_scope(
24
+ scope: list[str] | None,
25
+ *,
26
+ org_id: str | None,
27
+ space_token: str | None,
28
+ ) -> list[str]:
29
+ merged = merge_org_scope(scope, org_id)
30
+ if not space_token:
31
+ return merged
32
+ if space_token not in merged:
33
+ insert_at = 1 if org_id and merged else 0
34
+ merged.insert(insert_at, space_token)
35
+ return merged
36
+
37
+
38
+ def ensure_space_token(scope: list[str], space_token: str) -> list[str]:
39
+ if space_token in scope:
40
+ return scope
41
+ return [space_token, *scope]
42
+
43
+
44
+ def tenant_scope_query(org_id: str | None, scope: str | None) -> str | None:
45
+ if not org_id:
46
+ return scope
47
+ org_pattern = org_scope_token(org_id)
48
+ if scope:
49
+ return f"{org_pattern},{scope}"
50
+ return org_pattern
51
+
52
+
53
+ def role_scopes(role: str | None) -> list[str]:
54
+ if role == "owner":
55
+ return ["memory:read", "memory:write", "memory:delete", "org:admin"]
56
+ if role == "member":
57
+ return ["memory:read", "memory:write", "memory:delete"]
58
+ return []
@@ -0,0 +1 @@
1
+ """Space token model and space-scoping services."""
@@ -0,0 +1,35 @@
1
+ """Space token helpers — compile names to scope tokens."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ SPACE_TOKEN_PREFIX = "space."
8
+
9
+
10
+ def slugify_space(value: str) -> str:
11
+ slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
12
+ return slug or "space"
13
+
14
+
15
+ def compile_space_token(slug: str) -> str:
16
+ normalized = slug.strip().lower()
17
+ if normalized.startswith(SPACE_TOKEN_PREFIX):
18
+ return normalized
19
+ return f"{SPACE_TOKEN_PREFIX}{normalized}"
20
+
21
+
22
+ def default_personal_space(*, org_id: str) -> dict[str, str]:
23
+ slug = "personal"
24
+ return {
25
+ "slug": slug,
26
+ "name": "Personal",
27
+ "token": compile_space_token(slug),
28
+ "description": "General personal notes and preferences",
29
+ "guardian_prompt": (
30
+ "General personal notes, preferences, and life context. "
31
+ "Exclude work-specific project details unless explicitly personal."
32
+ ),
33
+ "is_default": "true",
34
+ "org_id": org_id,
35
+ }