feat(org): model grants, org memory, MCP gateway (v2 phases A1-3..5)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mjalmoudy94
2026-10-14 15:51:44 +03:30
co-authored by Claude Sonnet 5.5
parent c93b25741e
commit dee2f72c46
39 changed files with 9060 additions and 95 deletions
@@ -0,0 +1,51 @@
"""MCP gateway: scoped bearer tokens for external AI agents
Revision ID: a8b9c0d1e2f3
Revises: f7a8b9c0d1e2
Create Date: 2026-10-14 12:00:00.000000
Owned by Agent 1 (PLAN-v2 §6). Only the token hash and a display prefix are
stored; the raw token is shown once at issue time.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "a8b9c0d1e2f3"
down_revision: str | None = "f7a8b9c0d1e2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"mcp_tokens",
sa.Column("id", sa.String(length=32), primary_key=True),
sa.Column(
"organization_id",
sa.String(length=32),
sa.ForeignKey("organizations.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("name", sa.String(length=120), nullable=False),
# ``unique=True`` carries the index (``ix_mcp_tokens_token_hash``), so no
# separate create_index is needed — a second one would drift.
sa.Column("token_hash", sa.String(length=128), nullable=False, unique=True),
sa.Column("prefix", sa.String(length=16), nullable=False),
sa.Column("scopes", sa.String(length=255), nullable=False, server_default=""),
sa.Column("created_by_user_id", sa.String(length=32), sa.ForeignKey("users.id")),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_mcp_tokens_organization_id", "mcp_tokens", ["organization_id"])
def downgrade() -> None:
op.drop_index("ix_mcp_tokens_organization_id", table_name="mcp_tokens")
op.drop_table("mcp_tokens")
@@ -0,0 +1,62 @@
"""owner model grants: default grant set + per-org policy
Revision ID: e6f7a8b9c0d1
Revises: d5e6f7a8b9c0
Create Date: 2026-10-14 09:00:00.000000
Owned by Agent 1 (PLAN-v2 §6). Adds the two owner-side tables of the model-grant
workstream: the default set copied into every new organization, and the
per-organization policy (employee cap, monthly budget, automation/MCP switches).
``org_model_grants`` itself is foundation-owned and already exists.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "e6f7a8b9c0d1"
down_revision: str | None = "d5e6f7a8b9c0"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"default_model_grants",
sa.Column("id", sa.String(length=32), primary_key=True),
sa.Column(
"ai_model_id",
sa.String(length=32),
sa.ForeignKey("ai_models.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("daily_credit_cap", sa.Float(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("created_by_user_id", sa.String(length=32), sa.ForeignKey("users.id")),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("ai_model_id", name="uq_default_model_grant"),
)
op.create_table(
"org_policies",
sa.Column(
"organization_id",
sa.String(length=32),
sa.ForeignKey("organizations.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column("max_employees", sa.Integer(), nullable=False, server_default="5"),
sa.Column("monthly_credit_budget", sa.Float(), nullable=True),
sa.Column("automation_enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("mcp_enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
def downgrade() -> None:
op.drop_table("org_policies")
op.drop_table("default_model_grants")
@@ -0,0 +1,69 @@
"""org memory: the organization's own long-term knowledge
Revision ID: f7a8b9c0d1e2
Revises: e6f7a8b9c0d1
Create Date: 2026-10-14 11:00:00.000000
Owned by Agent 1 (PLAN-v2 §6). ``org_memories`` holds rules, preferences,
lessons, facts and the code map, with a content hash for dedupe and a soft
delete so a removed memory stays in the audit story.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "f7a8b9c0d1e2"
down_revision: str | None = "e6f7a8b9c0d1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"org_memories",
sa.Column("id", sa.String(length=32), primary_key=True),
sa.Column(
"organization_id",
sa.String(length=32),
sa.ForeignKey("organizations.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("kind", sa.String(length=16), nullable=False),
sa.Column("key", sa.String(length=80), nullable=True),
sa.Column("content", sa.String(length=1500), nullable=False),
# The dedupe digest the domain defines (sha256 of the normalised
# content, truncated to 32 chars).
sa.Column("content_hash", sa.String(length=32), nullable=False, server_default=""),
sa.Column("source", sa.String(length=16), nullable=False, server_default="user"),
sa.Column(
"evidence_task_id",
sa.String(length=32),
sa.ForeignKey("tasks.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("pinned", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("confidence", sa.Float(), nullable=False, server_default="1"),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("hits", sa.Integer(), nullable=False, server_default="0"),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_by_user_id", sa.String(length=32), sa.ForeignKey("users.id")),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_org_memories_organization_id", "org_memories", ["organization_id"])
op.create_index(
"ix_org_memories_org_kind", "org_memories", ["organization_id", "kind"]
)
op.create_index("ix_org_memories_org_key", "org_memories", ["organization_id", "key"])
def downgrade() -> None:
op.drop_index("ix_org_memories_org_key", table_name="org_memories")
op.drop_index("ix_org_memories_org_kind", table_name="org_memories")
op.drop_index("ix_org_memories_organization_id", table_name="org_memories")
op.drop_table("org_memories")
+70 -28
View File
@@ -1,44 +1,86 @@
"""Wiring for the org-platform workstream (memory, model grants).
Foundation defaults: no memory, and every active model granted to every org (today's
behaviour). The org-platform agent replaces the bodies with the real services; the
function names and return types are FROZEN so other workstreams can call them now.
``build_memory`` is the frozen ``MemoryPort`` builder the engine calls; it now
returns the real ``MemoryService``. ``build_memory_service`` is the same object,
typed for the router (list, teach, pin, confirm, delete). ``build_model_grants``
returns the real ``ModelGrantService``; ``build_model_grant_service`` is its
typed twin for the admin router.
"""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports.memory import MemoryPort, NullMemory
from app.application.ports.model_grants import (
AllActiveModelsGrants,
GrantedModel,
ModelGrantPort,
tier_for,
from app.application.ports.memory import MemoryPort
from app.application.ports.model_grants import ModelGrantPort
from app.application.services.memory_service import MemoryService
from app.application.services.model_grant_service import ModelGrantService
from app.infrastructure.repositories.ai_control import (
AiModelRepository,
ModelCredentialRepository,
RateCardRepository,
)
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.memory import MemoryRepository
from app.infrastructure.repositories.org_grants import (
DefaultModelGrantRepository,
OrgModelGrantRepository,
OrgPolicyRepository,
)
from app.infrastructure.db.models import AiModelRow
_null_memory = NullMemory()
def build_memory(session: AsyncSession) -> MemoryPort:
return _null_memory
"""The real memory service: recall, teach, and one reflection per task.
``get_model_provider`` is resolved from ``container``'s namespace rather
than from the registry directly: that module is what the composition root
re-exports, so a deployment (or the test suite) that swaps the adapter in
one place swaps it here too.
"""
from app.application.container import get_model_provider
return build_memory_service(session, provider=get_model_provider())
def build_memory_service(
session: AsyncSession,
*,
provider=None,
) -> MemoryService:
"""The same object, typed for the router (list, teach, pin, confirm, delete)."""
if provider is None:
from app.application.container import get_model_provider
provider = get_model_provider()
return MemoryService(
session=session,
memories=MemoryRepository(session),
models=AiModelRepository(session),
credentials=ModelCredentialRepository(session),
rate_cards=RateCardRepository(session),
ledger=UsageLedgerRepository(session),
provider=provider,
)
def build_model_grants(session: AsyncSession) -> ModelGrantPort:
async def _all_active() -> list[GrantedModel]:
rows = (await session.scalars(select(AiModelRow).where(AiModelRow.is_active))).all()
return [
GrantedModel(
ai_model_id=row.id,
tier=tier_for(
capabilities=list(row.capabilities or []),
output_price_per_1m=row.output_price_per_1m,
),
capabilities=tuple(row.capabilities or []),
)
for row in rows
]
"""The real grant service: an organization sees only what the owner gave it."""
return build_model_grant_service(session)
return AllActiveModelsGrants(_all_active)
def build_model_grant_service(session: AsyncSession) -> ModelGrantService:
"""The same object, typed for the admin router (grants, defaults, policy)."""
return ModelGrantService(
session=session,
grants=OrgModelGrantRepository(session),
defaults=DefaultModelGrantRepository(session),
policies=OrgPolicyRepository(session),
)
__all__ = [
"build_memory",
"build_memory_service",
"build_model_grant_service",
"build_model_grants",
]
@@ -0,0 +1,337 @@
"""MCP tokens: issue, list, revoke, verify.
Owned by Agent 1. One table, one service, three rules that matter:
1. **A token is shown once.** Only ``sha256(token)`` and a six-character prefix
are stored, so a database leak cannot authenticate anyone; the prefix is for
the audit trail and the settings screen, and is never enough to log in.
2. **Verification is constant-time and total.** ``hmac.compare_digest`` on the
hash, and every failure mode — unknown, revoked, expired — returns the same
``None``. A caller cannot tell "wrong token" from "revoked token", because a
revoked token is not a fact about the token, it is a decision.
3. **Scopes are a floor, not a ceiling.** A token carries the scopes it was
minted with and nothing else; an empty scope list is "no scopes", never "all".
The per-organization switch lives in ``org_policies.mcp_enabled`` (Phase 3).
"""
from __future__ import annotations
import hashlib
import hmac
import secrets
from collections import deque
from dataclasses import dataclass, field
from datetime import timedelta
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.errors import NotFoundError, ValidationError
from app.domain.base import new_id, utcnow
from app.infrastructure.db.models_org import McpTokenRow
#: Every scope the gateway understands. A scope nobody granted a token is a
#: scope the gateway refuses, whatever the asks say.
MCP_SCOPES: tuple[str, ...] = (
"app:read",
"tasks:read",
"tasks:write",
"memory:read",
"memory:write",
"delivery:read",
)
#: Enough entropy that a token is unguessable, short enough to paste into a
#: client config file.
TOKEN_ENTROPY_BYTES = 24
#: How the raw token looks to a human: the prefix is what the UI shows back.
TOKEN_PREFIX = "smo_"
#: Displayed characters; ``token_hash`` is what authenticates.
PREFIX_DISPLAY_CHARS = 6
@dataclass(slots=True, kw_only=True)
class McpToken:
"""A token as the service hands it back — never the raw secret."""
id: str
organization_id: str
name: str
prefix: str
scopes: list[str]
created_by_user_id: str | None = None
expires_at: object | None = None
last_used_at: object | None = None
revoked_at: object | None = None
created_at: object | None = None
@dataclass(slots=True, kw_only=True)
class IssuedToken:
"""The one and only response that carries the raw token."""
token: str
record: McpToken
@dataclass(slots=True, kw_only=True)
class TokenIdentity:
"""What a verified token proves: which organization, and what it may do."""
organization_id: str
token_id: str
prefix: str
scopes: frozenset[str] = field(default_factory=frozenset)
def allows(self, scope: str) -> bool:
return scope in self.scopes
def as_utc(value):
"""Normalise a timestamp read from the database for comparison.
SQLite (the development and test store) hands back naive datetimes; every
expiry and revocation check compares against ``utcnow()``, so a naive value
is assumed to be UTC rather than raising in the middle of a refusal.
"""
if value is None:
return None
from datetime import UTC
return value.replace(tzinfo=UTC) if value.tzinfo is None else value
def hash_token(raw: str) -> str:
"""The only form of a token that is ever stored."""
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _mint() -> str:
return f"{TOKEN_PREFIX}{secrets.token_urlsafe(TOKEN_ENTROPY_BYTES)}"
def _scopes_of(raw: str) -> list[str]:
"""Parse a CSV scope field, keeping only known scopes and their order."""
seen: list[str] = []
for piece in (raw or "").split(","):
scope = piece.strip()
if scope and scope in MCP_SCOPES and scope not in seen:
seen.append(scope)
return seen
def validate_scopes(scopes: list[str] | None) -> list[str]:
"""Refuse an unknown scope rather than silently dropping it."""
if not scopes:
return []
unknown = [scope for scope in scopes if scope not in MCP_SCOPES]
if unknown:
raise ValidationError(f"محدودهٔ ناشناخته: {', '.join(sorted(unknown))}")
# De-duplicated, order preserved, so the UI's checkbox set round-trips.
return list(dict.fromkeys(scopes))
class TokenRateLimiter:
"""In-memory, per-token call budget for the current minute.
Deliberately process-local: the gateway is one process, and a distributed
limiter would need the Redis the deployment does not have. A restart forgives
a burst — which is the right trade for a spare process only the owner's own
agents reach.
"""
def __init__(self, *, limit_per_minute: int | None = None) -> None:
self.limit = int(limit_per_minute or settings.mcp_rate_limit_per_minute)
self._calls: dict[str, deque[float]] = {}
def allow(self, key: str) -> bool:
import time
now = time.monotonic()
window_start = now - 60.0
bucket = self._calls.setdefault(key, deque())
while bucket and bucket[0] < window_start:
bucket.popleft()
if len(bucket) >= self.limit:
return False
bucket.append(now)
return True
def reset(self) -> None:
self._calls.clear()
class McpService:
"""Token lifecycle and verification for the MCP gateway."""
def __init__(self, session: AsyncSession) -> None:
self.session = session
# -- lifecycle -----------------------------------------------------------
async def issue(
self,
*,
organization_id: str,
name: str,
scopes: list[str],
created_by_user_id: str | None = None,
ttl_days: int | None = None,
) -> IssuedToken:
clean_name = name.strip()
if not clean_name:
raise ValidationError("نام توکن نمی‌تواند خالی باشد.")
wanted = validate_scopes(scopes)
if not wanted:
raise ValidationError("حداقل یک محدودهٔ دسترسی انتخاب کنید.")
days = int(ttl_days or settings.mcp_token_ttl_days)
if days < 1:
raise ValidationError("مدت اعتبار توکن نامعتبر است.")
raw = _mint()
row = McpTokenRow(
id=new_id(),
organization_id=organization_id,
name=clean_name[:120],
token_hash=hash_token(raw),
prefix=raw[: len(TOKEN_PREFIX) + PREFIX_DISPLAY_CHARS],
scopes=",".join(wanted),
created_by_user_id=created_by_user_id,
expires_at=utcnow() + timedelta(days=days),
)
self.session.add(row)
await self.session.flush()
return IssuedToken(token=raw, record=_to_domain(row))
async def list(self, organization_id: str) -> list[McpToken]:
rows = (
(
await self.session.execute(
select(McpTokenRow)
.where(McpTokenRow.organization_id == organization_id)
.order_by(McpTokenRow.created_at.desc())
)
)
.scalars()
.all()
)
return [_to_domain(row) for row in rows]
async def revoke(self, *, organization_id: str, token_id: str) -> None:
"""Revoke a token. Revoking twice is a 404, not a silent success — the
same idempotence rule the grant endpoints follow, so a caller can tell
"already gone" from "never existed" only by looking, not by guessing."""
row = await self._row(organization_id, token_id)
if row is None or row.revoked_at is not None:
raise NotFoundError("توکن یافت نشد.")
row.revoked_at = utcnow()
await self.session.flush()
# -- verification --------------------------------------------------------
async def verify(self, raw_token: str) -> TokenIdentity | None:
"""Resolve a raw token, or ``None`` for every kind of refusal.
The lookup is by hash (the token is never stored), and the comparison is
constant-time so a token that shares a prefix with a real one cannot be
discovered by timing. An expired or revoked token is simply not an
identity any more.
"""
if not raw_token or not raw_token.startswith(TOKEN_PREFIX):
return None
digest = hash_token(raw_token)
rows = (
(
await self.session.execute(
select(McpTokenRow).where(
McpTokenRow.token_hash == digest,
McpTokenRow.revoked_at.is_(None),
)
)
)
.scalars()
.all()
)
if not rows:
return None
row = rows[0]
if not hmac.compare_digest(row.token_hash, digest):
return None
if as_utc(row.expires_at) is not None and as_utc(row.expires_at) <= utcnow():
return None
return TokenIdentity(
organization_id=row.organization_id,
token_id=row.id,
prefix=row.prefix,
scopes=frozenset(_scopes_of(row.scopes)),
)
async def creator_of(self, token_id: str) -> str | None:
"""The user who minted this token — the accountable actor for its writes.
A task created through the gateway is attributed to the administrator who
created the token, not to a ghost user: the token is a delegated key, and
delegation never erases who holds it.
"""
row = await self.session.get(McpTokenRow, token_id)
return row.created_by_user_id if row else None
async def touch(self, token_id: str) -> None:
"""Record last use. Never allowed to fail a call."""
row = await self.session.get(McpTokenRow, token_id)
if row is not None:
row.last_used_at = utcnow()
await self.session.flush()
async def count_per_org(self) -> dict[str, int]:
"""Active tokens per organization, for the owner's /admin/mcp screen."""
rows = (
await self.session.execute(
select(McpTokenRow.organization_id).where(
McpTokenRow.revoked_at.is_(None)
)
)
).scalars()
counts: dict[str, int] = {}
for org_id in rows:
counts[org_id] = counts.get(org_id, 0) + 1
return counts
async def _row(self, organization_id: str, token_id: str) -> McpTokenRow | None:
return await self.session.scalar(
select(McpTokenRow).where(
McpTokenRow.organization_id == organization_id,
McpTokenRow.id == token_id,
)
)
def _to_domain(row: McpTokenRow) -> McpToken:
return McpToken(
id=row.id,
organization_id=row.organization_id,
name=row.name,
prefix=row.prefix,
scopes=_scopes_of(row.scopes),
created_by_user_id=row.created_by_user_id,
expires_at=row.expires_at,
last_used_at=row.last_used_at,
revoked_at=row.revoked_at,
created_at=row.created_at,
)
__all__ = [
"MCP_SCOPES",
"TOKEN_PREFIX",
"IssuedToken",
"McpService",
"McpToken",
"TokenIdentity",
"TokenRateLimiter",
"hash_token",
"validate_scopes",
]
@@ -0,0 +1,535 @@
"""Org memory use cases: what an organization remembers, and how it learns.
This module is the real ``MemoryPort`` implementation (see
``container_org.build_memory``). The engine calls it three ways — build a
context for a task, record something a human taught, and react to a finished
task — and this service owns three invariants around those calls:
1. **Memory is capped.** One organization may spend at most
``MEMORY_ORG_CHAR_CAP`` characters on live memory. When a write would cross
it, the oldest unpinned *lesson* is evicted first — a lesson is disposable by
definition, a rule or a preference is not.
2. **Teaching the same thing twice teaches nothing.** A write whose content (or
whose key) already exists updates that entry instead of adding a duplicate,
which is also what lets "the port is 8001" correct "the port is 8000".
3. **A reflection can never break the engine.** ``on_task_finished`` asks one
cheap model for what the task taught. If the call fails, the error is
recorded and the function returns: the task is finished, and the org simply
learned nothing this time.
Nothing here returns a model name, a provider or an infrastructure detail — the
engine's contract is a block of text.
"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports import ChatMessage, ModelProviderPort, UsageRecord
from app.application.ports.memory import TaskOutcome
from app.application.ports.model_provider import ModelResponse
from app.core.config import settings
from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError
from app.core.redact import redact_text
from app.domain.ai_control.models import RateUnit, RoutingMode
from app.domain.base import utcnow
from app.domain.identity.models import ROLE_RANK, Role
from app.domain.memory.models import (
CONFIRMED_CONFIDENCE,
KIND_LABELS,
MAX_WRITES_PER_TASK,
REFLECTION_CONFIDENCE,
Memory,
MemoryKind,
MemorySource,
coerce_kind,
coerce_source,
content_hash,
lesson_expiry,
normalise,
relevance,
tokenise,
validate_confidence,
validate_content,
validate_key,
visible_kinds,
)
from app.infrastructure.error_log import record_error
from app.infrastructure.repositories.ai_control import (
AiModelRepository,
ModelCredentialRepository,
RateCardRepository,
)
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.memory import MemoryRepository
#: What a reflection is asked to produce. Deliberately narrow: two lessons and a
#: code-map paragraph, in a shape the parser can read without guessing.
REFLECTION_SYSTEM = (
"تو بازبین کارهای یک سازمان هستی. حداکثر دو درس آموختنی و یک پاراگراف نقشهٔ "
"کد بنویس. هر درس یک خط است و فقط یک واقعیت اجرایی را می‌گوید — نه تعریف. "
"اگر درسی نیست، فقط کلمهٔ «هیچ» را بنویس."
)
REFLECTION_TEMPLATE = """کار: {title}
وضعیت: {status}
خلاصه: {summary}
فایل‌های تغییرکرده: {files}
خطاهای دروازه: {failures}
پاسخ را دقیقاً در این قالب بنویس:
درس ۱: ...
درس ۲: ...
نقشهٔ کد: ..."""
#: The block header. Short on purpose: the block is a prompt fragment, and a
#: prompt fragment pays for every character.
CONTEXT_HEADER = "یادداشت‌های سازمان:"
class MemoryService:
"""Recalls, teaches, corrects and prunes one organization's memory."""
def __init__(
self,
*,
session: AsyncSession,
memories: MemoryRepository,
models: AiModelRepository,
credentials: ModelCredentialRepository,
rate_cards: RateCardRepository,
ledger: UsageLedgerRepository,
provider: ModelProviderPort,
) -> None:
self.session = session
self.memories = memories
self.models = models
self.credentials = credentials
self.rate_cards = rate_cards
self.ledger = ledger
self.provider = provider
# ------------------------------------------------------------------ #
# MemoryPort #
# ------------------------------------------------------------------ #
async def record(
self,
*,
organization_id: str,
kind: str,
content: str,
source: str,
evidence_task_id: str | None = None,
key: str | None = None,
created_by_user_id: str | None = None,
) -> str | None:
"""Store — or correct — one memory. Returns its id, or ``None``.
``None`` is an answer, not a failure: the write was refused because the
task had already taught its three memories. The caller (usually the
reflection path) must not treat that as an error.
"""
cleaned = validate_content(content)
# A memory lands in a future prompt. A credential pasted into one would
# be broadcast to every employee from then on, so nothing is stored
# unredacted — not even what "looks safe".
cleaned = redact_text(cleaned)
entry_kind = coerce_kind(kind)
entry_source = coerce_source(source)
entry_key = validate_key(key)
digest = content_hash(cleaned)
if evidence_task_id:
writes = await self.memories.count_for_task(organization_id, evidence_task_id)
if writes >= MAX_WRITES_PER_TASK:
return None
duplicate = await self.memories.find_duplicate(
organization_id, kind=entry_kind, key=entry_key, digest=digest
)
if duplicate is not None:
# Same thought, same organization: correct the entry in place.
# Confidence and pinned state survive, because re-reading a rule is
# not a retraction of it.
duplicate.content = cleaned
duplicate.source = entry_source
duplicate.confidence = _confidence_for(entry_source, duplicate.confidence)
duplicate.expires_at = _expiry_for(entry_kind, duplicate)
if created_by_user_id:
duplicate.created_by_user_id = duplicate.created_by_user_id or created_by_user_id
await self.memories.save(duplicate)
return duplicate.id
memory = Memory(
organization_id=organization_id,
kind=entry_kind,
key=entry_key,
content=cleaned,
source=entry_source,
evidence_task_id=evidence_task_id,
created_by_user_id=created_by_user_id,
confidence=_confidence_for(entry_source, 1.0),
)
memory.expires_at = _expiry_for(entry_kind, memory)
await self.memories.create(memory)
await self._enforce_cap(organization_id)
return memory.id
async def build_context(
self,
*,
organization_id: str,
role: str,
query: str,
budget_chars: int = 2500,
) -> str:
"""The memory block for one task, within ``budget_chars``.
Pinned rules and preferences lead: they are standing decisions, not
search results. Everything else follows by relevance to the query.
Whatever does not fit is dropped — including a pinned entry, because a
prompt over budget is worse than an incomplete one.
"""
kinds = visible_kinds(role)
if budget_chars <= 0 or not kinds:
return ""
memories = await self.memories.list_for_org(organization_id, limit=200)
live = [m for m in memories if m.kind in kinds and m.is_live()]
if not live:
return ""
query_tokens = tokenise(query)
leading = [m for m in live if m.pinned and m.kind in _LEADING_KINDS]
leading_ids = {m.id for m in leading}
rest = [m for m in live if m.id not in leading_ids]
leading.sort(key=lambda m: (_kind_order(m.kind), -(m.updated_at or utcnow()).timestamp()))
rest.sort(
key=lambda m: (
-relevance(m, query_tokens),
-(m.updated_at or utcnow()).timestamp(),
)
)
budget = int(budget_chars)
lines: list[str] = []
used = len(CONTEXT_HEADER) + 1
included: list[Memory] = []
for memory in [*leading, *rest]:
line = _format_entry(memory)
if used + len(line) + 1 > budget:
continue
used += len(line) + 1
lines.append(line)
included.append(memory)
if not lines:
return ""
await self.memories.mark_used([m.id for m in included])
return "\n".join([CONTEXT_HEADER, *lines])
async def on_task_finished(self, *, organization_id: str, task: TaskOutcome) -> None:
"""Let the organization learn from a finished task. Never raises.
One cheap completion, at most ``MAX_WRITES_PER_TASK`` memories, all
written with ``source=reflection`` and half confidence. A failure is
recorded and swallowed: the task is already finished, and the engine must
not have to know whether an LLM happened to answer.
"""
try:
answer = await self._reflect(organization_id=organization_id, task=task)
except Exception as exc: # noqa: BLE001 - reflection must never raise
await record_error(
kind="memory.reflection_failed",
message=str(exc),
exc=exc,
organization_id=organization_id,
metadata={"task_id": task.task_id, "status": task.status},
)
return
if not answer:
return
for kind, content in _parse_reflection(answer)[:MAX_WRITES_PER_TASK]:
await self.record(
organization_id=organization_id,
kind=str(kind),
content=content,
source=str(MemorySource.REFLECTION),
evidence_task_id=task.task_id,
)
# ------------------------------------------------------------------ #
# Writes the UI and the MCP gateway expose #
# ------------------------------------------------------------------ #
async def list(
self,
organization_id: str,
*,
kind: str | None = None,
search: str | None = None,
) -> list[Memory]:
parsed = coerce_kind(kind) if kind else None
return await self.memories.list_for_org(organization_id, kind=parsed, search=search)
async def teach(
self,
*,
organization_id: str,
user_id: str,
role: Role,
kind: str,
content: str,
key: str | None = None,
) -> Memory:
"""A human teaching the organization — a rule or a preference.
The role check lives here and not only in the router: this service is
also reached by the MCP gateway (Phase 5), and "someone with a token"
must hit the same wall as someone with a session.
"""
entry_kind = coerce_kind(kind)
if entry_kind not in _TEACHABLE_KINDS:
raise PermissionDeniedError("فقط «قاعده» یا «ترجیح» قابل آموزش است.")
if ROLE_RANK[role] < ROLE_RANK[Role.MEMBER]:
raise PermissionDeniedError("برای آموزش سازمان باید عضو آن باشید.")
memory_id = await self.record(
organization_id=organization_id,
kind=str(entry_kind),
content=content,
source=str(MemorySource.USER),
key=key,
created_by_user_id=user_id,
)
if memory_id is None: # pragma: no cover - only the task cap returns None
raise ValidationError("یادداشت ذخیره نشد.")
memory = await self.memories.get(organization_id, memory_id)
if memory is None: # pragma: no cover - created one call above
raise NotFoundError("یادداشت یافت نشد.")
return memory
async def get(self, organization_id: str, memory_id: str) -> Memory:
memory = await self.memories.get(organization_id, memory_id)
if memory is None or memory.deleted_at is not None:
raise NotFoundError("یادداشت یافت نشد.")
return memory
async def edit(
self, *, organization_id: str, memory_id: str, content: str, key: str | None = None
) -> Memory:
memory = await self.get(organization_id, memory_id)
memory.content = validate_content(content)
if key is not None:
memory.key = validate_key(key)
memory.confidence = validate_confidence(memory.confidence)
memory.expires_at = _expiry_for(memory.kind, memory)
await self.memories.save(memory)
return memory
async def set_pinned(
self, *, organization_id: str, memory_id: str, pinned: bool
) -> Memory:
memory = await self.get(organization_id, memory_id)
memory.pinned = bool(pinned)
# Pinning is a promise the organization keeps, so a pinned lesson stops
# expiring and becomes part of every context.
memory.expires_at = _expiry_for(memory.kind, memory)
await self.memories.save(memory)
return memory
async def confirm(self, *, organization_id: str, memory_id: str) -> Memory:
"""A reviewed lesson becomes settled knowledge: full confidence, no expiry."""
memory = await self.get(organization_id, memory_id)
if memory.kind is not MemoryKind.LESSON:
raise ValidationError("فقط یک «درس» قابل تأیید است.")
memory.confidence = CONFIRMED_CONFIDENCE
memory.pinned = True
memory.expires_at = None
await self.memories.save(memory)
return memory
async def delete(self, *, organization_id: str, memory_id: str) -> None:
if not await self.memories.soft_delete(organization_id, memory_id):
raise NotFoundError("یادداشت یافت نشد.")
async def expire_memories(self, organization_id: str | None = None) -> int:
"""Soft-delete expired lessons. Exposed for the worker hook (see report)."""
return await self.memories.expire(organization_id)
async def stats(self, organization_id: str) -> dict[str, int]:
memories = await self.memories.list_for_org(organization_id, limit=200)
return {
"total": len(memories),
"pinned": sum(1 for m in memories if m.pinned),
"chars": await self.memories.total_chars(organization_id),
}
# ------------------------------------------------------------------ #
# Internals #
# ------------------------------------------------------------------ #
async def _reflect(self, *, organization_id: str, task: TaskOutcome) -> str:
"""One cheap completion about the finished task, priced in the ledger."""
model = await self.models.pick_for_mode(RoutingMode.CHEAP)
if model is None:
# No catalogue means nothing to route to. Record it once rather than
# letting the engine wonder why nobody learned anything.
await record_error(
kind="memory.reflection_no_model",
message="no active model for the cheap routing mode",
organization_id=organization_id,
metadata={"task_id": task.task_id},
)
return ""
pool = await self.credentials.list_active_for_provider(model.provider_id)
response = await self.provider.complete(
model=model,
credential=pool[0] if pool else None,
credentials=pool or None,
messages=[
ChatMessage(
role="user",
content=REFLECTION_TEMPLATE.format(
title=task.title,
status=task.status,
summary=task.summary or "—",
files="، ".join(task.changed_files[:20]) or "—",
failures="، ".join(task.gate_failures) or "—",
),
)
],
system=REFLECTION_SYSTEM,
temperature=0.2,
max_tokens=600,
)
await self._record_usage(organization_id=organization_id, response=response)
return response.content or ""
async def _record_usage(self, *, organization_id: str, response: ModelResponse) -> None:
"""Price a reflection exactly like any other model call."""
for unit, quantity in (
(RateUnit.MODEL_INPUT_TOKENS, float(response.input_tokens or 0)),
(RateUnit.MODEL_OUTPUT_TOKENS, float(response.output_tokens or 0)),
):
card = await self.rate_cards.get_by_unit(unit)
unit_price = card.customer_price if card else 0.0
internal_cost = card.internal_cost_estimate if card else 0.0
await self.ledger.record(
usage=UsageRecord(
organization_id=organization_id,
unit=unit,
quantity=quantity,
unit_price=unit_price,
internal_cost_estimate=quantity * internal_cost,
total_price=quantity * unit_price,
metadata_json={"memory_reflection": True},
)
)
async def _enforce_cap(self, organization_id: str) -> int:
"""Trim the organization to its character budget. Returns evicted count."""
cap = int(settings.memory_org_char_cap)
evicted = 0
while await self.memories.total_chars(organization_id) > cap:
victims = await self.memories.evictable_lessons(organization_id, limit=1)
if not victims:
# Only rules and preferences are left: the organization chose to
# spend its whole budget on standing decisions, and silently
# deleting one of those is worse than being one character over.
return evicted
await self.memories.soft_delete(organization_id, victims[0].id)
evicted += 1
return evicted
# ---------------------------------------------------------------------------
# Pure helpers
# ---------------------------------------------------------------------------
#: Kinds a human may teach directly; everything else is the system's voice.
_TEACHABLE_KINDS = frozenset({MemoryKind.RULE, MemoryKind.PREFERENCE})
#: A pinned rule outranks a pinned preference: one says what to do, the other
#: says how to like it.
_LEADING_KINDS = frozenset({MemoryKind.RULE, MemoryKind.PREFERENCE})
_KIND_ORDER: dict[MemoryKind, int] = {
MemoryKind.RULE: 0,
MemoryKind.PREFERENCE: 1,
}
def _kind_order(kind: MemoryKind) -> int:
return _KIND_ORDER.get(kind, 9)
def _confidence_for(source: MemorySource, fallback: float) -> float:
"""Reflections get half confidence; a human's entry keeps its own."""
if source is MemorySource.REFLECTION:
return REFLECTION_CONFIDENCE
return validate_confidence(fallback)
def _expiry_for(kind: MemoryKind, memory: Memory):
"""Lessons expire; a rule, a preference, a fact and the code map do not."""
if kind is not MemoryKind.LESSON:
return None
return lesson_expiry(
pinned=memory.pinned, confirmed=memory.confidence >= CONFIRMED_CONFIDENCE
)
def _format_entry(memory: Memory) -> str:
label = KIND_LABELS.get(str(memory.kind), "یادداشت")
suffix = ""
if memory.kind is MemoryKind.LESSON:
suffix = f" (اعتماد {int(memory.confidence * 100)}٪)"
elif memory.source is MemorySource.REFLECTION:
suffix = " (بازتاب)"
return f"- [{label}]{suffix} {memory.content.strip()}"
def _parse_reflection(text: str) -> list[tuple[MemoryKind, str]]:
"""Read a reflection's answer into ``(kind, content)`` pairs.
Tolerant by design: the model is asked for a rigid template, but a model is
a model. A line that does not start with a known marker is ignored rather
than stored, so a chatty answer cannot smuggle in a third lesson.
"""
entries: list[tuple[MemoryKind, str]] = []
for raw_line in (text or "").splitlines():
line = normalise(raw_line).lstrip("-*• \t").strip()
if not line:
continue
lowered = line.casefold()
for marker, kind in _REFLECTION_MARKERS:
if lowered.startswith(marker):
body = line[len(marker):].strip(" :،-–")
if body:
entries.append((kind, body))
break
return entries
#: Marker → kind. Longest marker first, so «نقشهٔ کد:» cannot be swallowed by a
#: shorter prefix, and both the Persian and the English spelling is accepted.
_REFLECTION_MARKERS: tuple[tuple[str, MemoryKind], ...] = (
("نقشهٔ کد:", MemoryKind.CODE_MAP),
("نقشه کد:", MemoryKind.CODE_MAP),
("code map:", MemoryKind.CODE_MAP),
("درس ۱:", MemoryKind.LESSON),
("درس ۱", MemoryKind.LESSON),
("درس ۲:", MemoryKind.LESSON),
("درس ۲", MemoryKind.LESSON),
("درس:", MemoryKind.LESSON),
("درس", MemoryKind.LESSON),
("lesson 1:", MemoryKind.LESSON),
("lesson 1", MemoryKind.LESSON),
("lesson 2:", MemoryKind.LESSON),
("lesson 2", MemoryKind.LESSON),
("lesson:", MemoryKind.LESSON),
)
__all__ = ["CONTEXT_HEADER", "MemoryService"]
@@ -0,0 +1,456 @@
"""Owner model grants and per-organization policy.
Owned by Agent 1. This is the real ``ModelGrantPort`` implementation (see
``container_org.build_model_grants``): the engine asks "which models may this
organization use?" and gets a *neutral* answer — a tier (``fast`` /
``balanced`` / ``strong`` ≈ «سریع» / «متعادل» / «قوی») and the capability list,
never a model name. Model names stay an owner-only concept, visible in the
admin grant matrix and nowhere else.
Two independent switches have to agree before a model is usable:
* the model itself is active in the catalogue (``ai_models.is_active``), and
* the owner granted it to *this* organization (``org_model_grants.is_active``).
Either one being false means "not granted". That is what makes revoking a model
from one organization a real revocation while other organizations keep working.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports.model_grants import GrantedModel, tier_for
from app.core.config import settings
from app.core.errors import NotFoundError, ValidationError
from app.infrastructure.db.models import AiModelRow
from app.infrastructure.repositories.org_grants import (
DefaultGrantRecord,
DefaultModelGrantRepository,
OrgGrantRecord,
OrgModelGrantRepository,
OrgPolicyRecord,
OrgPolicyRepository,
)
#: The only vocabulary an organization's users ever see for a model.
TIER_LABELS: dict[str, str] = {
"fast": "سریع",
"balanced": "متعادل",
"strong": "قوی",
}
#: Policy defaults for an organization that has never been configured. Kept in
#: code (not only in the row default) so ``get_policy`` can answer for any org
#: without writing a row first.
DEFAULT_MAX_EMPLOYEES = 5
@dataclass(slots=True)
class ModelView:
"""One catalogue model as the owner sees it, plus its grant state for an org."""
ai_model_id: str
label: str
provider_id: str
capabilities: list[str] = field(default_factory=list)
tier: str = "balanced"
tier_label: str = "متعادل"
output_price_per_1m: float = 0.0
model_active: bool = True
granted: bool = False
daily_credit_cap: float | None = None
@dataclass(slots=True)
class OrgGrantView:
ai_model_id: str
label: str
tier: str
tier_label: str
capabilities: list[str]
daily_credit_cap: float | None
is_active: bool
@dataclass(slots=True)
class PolicyView:
organization_id: str
max_employees: int
monthly_credit_budget: float | None
automation_enabled: bool
mcp_enabled: bool
max_employees_limit: int
def _tier_of(row: AiModelRow) -> str:
return tier_for(
capabilities=list(row.capabilities or []),
output_price_per_1m=float(row.output_price_per_1m or 0.0),
)
def _tier_label(tier: str) -> str:
return TIER_LABELS.get(tier, TIER_LABELS["balanced"])
class ModelGrantService:
"""Grants, default grant set and org policy — all in one place."""
def __init__(
self,
*,
session: AsyncSession,
grants: OrgModelGrantRepository,
defaults: DefaultModelGrantRepository,
policies: OrgPolicyRepository,
) -> None:
self.session = session
self.grants = grants
self.defaults = defaults
self.policies = policies
# -- catalogue ----------------------------------------------------------
async def _model(self, ai_model_id: str) -> AiModelRow:
"""The catalogue row, or a 404. Never returns an inactive model's row.
Inactive means "the owner retired it": granting it would be meaningless,
so it is reported as missing rather than as a validation error.
"""
row = await self.session.scalar(
select(AiModelRow).where(
AiModelRow.id == ai_model_id, AiModelRow.is_active
)
)
if row is None:
raise NotFoundError("مدل مورد نظر یافت نشد.")
return row
async def list_models(self, *, organization_id: str) -> list[ModelView]:
"""The full grant matrix for one organization (owner view)."""
models = (
await self.session.execute(
select(AiModelRow).where(AiModelRow.is_active).order_by(AiModelRow.id)
)
).scalars().all()
active_grants = {
g.ai_model_id: g
for g in await self.grants.list_for_org(organization_id, active_only=True)
}
return [
self._view(row, active_grants.get(row.id)) for row in models
]
def _view(self, row: AiModelRow, grant: OrgGrantRecord | None) -> ModelView:
tier = _tier_of(row)
return ModelView(
ai_model_id=row.id,
label=row.display_name_for_admin or row.internal_name,
provider_id=row.provider_id,
capabilities=list(row.capabilities or []),
tier=tier,
tier_label=_tier_label(tier),
output_price_per_1m=float(row.output_price_per_1m or 0.0),
model_active=bool(row.is_active),
granted=grant is not None,
daily_credit_cap=grant.daily_credit_cap if grant else None,
)
# -- ModelGrantPort -----------------------------------------------------
async def granted_models(self, *, organization_id: str) -> list[GrantedModel]:
"""Active models with an active grant for this organization."""
grants = await self.grants.list_for_org(organization_id, active_only=True)
if not grants:
return []
by_id = {g.ai_model_id: g for g in grants}
rows = (
await self.session.execute(
select(AiModelRow).where(
AiModelRow.is_active, AiModelRow.id.in_(list(by_id))
)
)
).scalars().all()
granted = [
GrantedModel(
ai_model_id=row.id,
tier=_tier_of(row),
capabilities=tuple(row.capabilities or []),
daily_credit_cap=by_id[row.id].daily_credit_cap,
)
for row in rows
]
# Cheapest first: a caller that picks the head of the list gets the
# economical model, which is the sane default for bulk work.
granted.sort(key=lambda m: m.daily_credit_cap if m.daily_credit_cap is not None else -1)
return granted
async def is_granted(self, *, organization_id: str, ai_model_id: str) -> bool:
grant = await self.grants.get(organization_id, ai_model_id)
if grant is None or not grant.is_active:
return False
exists = await self.session.scalar(
select(AiModelRow.id).where(
AiModelRow.id == ai_model_id, AiModelRow.is_active
)
)
return exists is not None
# -- grants -------------------------------------------------------------
async def list_grants(
self, *, organization_id: str, active_only: bool = False
) -> list[OrgGrantView]:
records = await self.grants.list_for_org(organization_id, active_only=active_only)
if not records:
return []
rows = (
await self.session.execute(
select(AiModelRow).where(
AiModelRow.id.in_([r.ai_model_id for r in records])
)
)
).scalars().all()
by_id = {row.id: row for row in rows}
views: list[OrgGrantView] = []
for record in records:
row = by_id.get(record.ai_model_id)
if row is None: # deleted from the catalogue: keep it out of the UI
continue
tier = _tier_of(row)
views.append(
OrgGrantView(
ai_model_id=row.id,
label=row.display_name_for_admin or row.internal_name,
tier=tier,
tier_label=_tier_label(tier),
capabilities=list(row.capabilities or []),
daily_credit_cap=record.daily_credit_cap,
is_active=record.is_active,
)
)
return views
@staticmethod
def _validate_cap(daily_credit_cap: float | None) -> float | None:
if daily_credit_cap is None:
return None
cap = float(daily_credit_cap)
if cap < 0:
raise ValidationError("سقف اعتبار روزانه نمی‌تواند منفی باشد.")
return cap
async def grant_model(
self,
*,
organization_id: str,
ai_model_id: str,
daily_credit_cap: float | None = None,
actor_user_id: str | None = None,
) -> OrgGrantView:
await self._model(ai_model_id)
cap = self._validate_cap(daily_credit_cap)
await self.grants.upsert(
organization_id=organization_id,
ai_model_id=ai_model_id,
daily_credit_cap=cap,
is_active=True,
created_by_user_id=actor_user_id,
)
views = await self.list_grants(organization_id=organization_id, active_only=True)
return next(v for v in views if v.ai_model_id == ai_model_id)
async def revoke_model(
self, *, organization_id: str, ai_model_id: str, actor_user_id: str | None = None
) -> None:
"""Revoke by deactivating, never by deleting.
The row is kept so the history ("was granted, then revoked") survives
and re-granting is idempotent instead of racing a unique constraint.
"""
existing = await self.grants.get(organization_id, ai_model_id)
if existing is None or not existing.is_active:
raise NotFoundError("این مدل برای سازمان فعال نیست.")
await self.grants.upsert(
organization_id=organization_id,
ai_model_id=ai_model_id,
daily_credit_cap=existing.daily_credit_cap,
is_active=False,
created_by_user_id=actor_user_id or existing.created_by_user_id,
)
async def set_grant_cap(
self,
*,
organization_id: str,
ai_model_id: str,
daily_credit_cap: float | None,
actor_user_id: str | None = None,
) -> OrgGrantView:
existing = await self.grants.get(organization_id, ai_model_id)
if existing is None or not existing.is_active:
raise NotFoundError("این مدل برای سازمان فعال نیست.")
return await self.grant_model(
organization_id=organization_id,
ai_model_id=ai_model_id,
daily_credit_cap=daily_credit_cap,
actor_user_id=actor_user_id,
)
# -- default grant set --------------------------------------------------
async def list_defaults(self) -> list[ModelView]:
models = (
await self.session.execute(
select(AiModelRow).where(AiModelRow.is_active).order_by(AiModelRow.id)
)
).scalars().all()
active = {d.ai_model_id: d for d in await self.defaults.list_active()}
return [self._view(row, _as_grant(active.get(row.id))) for row in models]
async def set_default(
self,
*,
ai_model_id: str,
daily_credit_cap: float | None = None,
is_active: bool = True,
actor_user_id: str | None = None,
) -> None:
await self._model(ai_model_id)
await self.defaults.upsert(
ai_model_id=ai_model_id,
daily_credit_cap=self._validate_cap(daily_credit_cap),
is_active=is_active,
created_by_user_id=actor_user_id,
)
async def remove_default(self, *, ai_model_id: str) -> None:
if not await self.defaults.delete(ai_model_id):
raise NotFoundError("این مدل در مجموعهٔ پیش‌فرض نیست.")
async def copy_defaults_into(
self, *, organization_id: str, actor_user_id: str | None = None
) -> int:
"""Materialise the default set into a new organization. Returns the count.
Only the *active* defaults are copied: a default the owner switched off
must not silently reappear in the next organization created.
"""
copied = 0
for record in await self.defaults.list_active():
await self.grants.upsert(
organization_id=organization_id,
ai_model_id=record.ai_model_id,
daily_credit_cap=record.daily_credit_cap,
is_active=True,
created_by_user_id=actor_user_id,
)
copied += 1
return copied
# -- org policy ---------------------------------------------------------
@staticmethod
def _policy_view(record: OrgPolicyRecord | None, organization_id: str) -> PolicyView:
limit = max(1, int(settings.engine_max_employees))
if record is None:
return PolicyView(
organization_id=organization_id,
max_employees=min(DEFAULT_MAX_EMPLOYEES, limit),
monthly_credit_budget=None,
automation_enabled=True,
mcp_enabled=True,
max_employees_limit=limit,
)
return PolicyView(
organization_id=organization_id,
max_employees=min(int(record.max_employees), limit),
monthly_credit_budget=record.monthly_credit_budget,
automation_enabled=bool(record.automation_enabled),
mcp_enabled=bool(record.mcp_enabled),
max_employees_limit=limit,
)
async def get_org_policy(self, organization_id: str) -> PolicyView:
"""Readable by anyone who may manage the org (Agent 2 reads this later)."""
return self._policy_view(await self.policies.get(organization_id), organization_id)
async def set_org_policy(
self,
*,
organization_id: str,
max_employees: int | None = None,
monthly_credit_budget: float | None = None,
automation_enabled: bool | None = None,
mcp_enabled: bool | None = None,
actor_user_id: str | None = None,
) -> PolicyView:
current = await self.get_org_policy(organization_id)
employees = current.max_employees if max_employees is None else int(max_employees)
if employees < 1:
raise ValidationError("حداقل تعداد پرسنل یک است.")
limit = current.max_employees_limit
if employees > limit:
raise ValidationError(
f"حداکثر تعداد پرسنل نمی‌تواند از سقف سامانه ({limit}) بیشتر باشد."
)
budget = (
current.monthly_credit_budget
if monthly_credit_budget is None
else float(monthly_credit_budget)
)
if budget is not None and budget < 0:
raise ValidationError("بودجهٔ ماهانه نمی‌تواند منفی باشد.")
automation = (
current.automation_enabled
if automation_enabled is None
else bool(automation_enabled)
)
mcp = current.mcp_enabled if mcp_enabled is None else bool(mcp_enabled)
record = await self.policies.upsert(
organization_id=organization_id,
max_employees=employees,
monthly_credit_budget=budget,
automation_enabled=automation,
mcp_enabled=mcp,
)
return self._policy_view(record, organization_id)
async def set_mcp_enabled(
self, *, organization_id: str, enabled: bool, actor_user_id: str | None = None
) -> PolicyView:
"""Owner switch for the MCP gateway (Phase 5)."""
return await self.set_org_policy(
organization_id=organization_id, mcp_enabled=enabled, actor_user_id=actor_user_id
)
async def mcp_enabled(self, organization_id: str) -> bool:
return (await self.get_org_policy(organization_id)).mcp_enabled
def _as_grant(record: DefaultGrantRecord | None) -> OrgGrantRecord | None:
"""Default rows reuse the org-grant view; only the shape matters here."""
if record is None:
return None
return OrgGrantRecord(
id=record.id,
organization_id="",
ai_model_id=record.ai_model_id,
daily_credit_cap=record.daily_credit_cap,
is_active=record.is_active,
created_by_user_id=record.created_by_user_id,
)
__all__ = [
"DEFAULT_MAX_EMPLOYEES",
"ModelGrantService",
"ModelView",
"OrgGrantView",
"PolicyView",
"TIER_LABELS",
]
@@ -243,6 +243,9 @@ class OrgSetupService:
if payload.deploy_target_kind:
deploy_target_id = await self._setup_deploy_target(org.id, project_row.id, payload)
# -- default model grants (owner-provided, Phase 3) -------------------
await self._copy_default_grants(org.id, user.id)
await self.session.flush()
# -- commit so hooks see a consistent world --------------------------
@@ -377,6 +380,30 @@ class OrgSetupService:
row = await self.deploys.upsert(values=values)
return str(row.id)
async def _copy_default_grants(self, organization_id: str, user_id: str) -> int:
"""Copy the platform owner's default grant set into the new organization.
Runs inside the creation transaction on purpose: an organization that
exists without its grants would be an organization whose AI staff cannot
think. An empty default set is legitimate (the owner granted nothing).
"""
from app.application.services.model_grant_service import ModelGrantService
from app.infrastructure.repositories.org_grants import (
DefaultModelGrantRepository,
OrgModelGrantRepository,
OrgPolicyRepository,
)
service = ModelGrantService(
session=self.session,
grants=OrgModelGrantRepository(self.session),
defaults=DefaultModelGrantRepository(self.session),
policies=OrgPolicyRepository(self.session),
)
return await service.copy_defaults_into(
organization_id=organization_id, actor_user_id=user_id
)
async def _call_hooks(self, *, organization_id: str, project_id: str) -> None:
if self.workforce_bootstrap:
try:
+7
View File
@@ -130,7 +130,14 @@ class Settings(BaseSettings):
# >>> agent-1 (org platform): add settings ONLY between these markers
mcp_gateway_host: str = "127.0.0.1"
mcp_gateway_port: int = 8765
#: The owner's kill switch for the whole gateway (Phase 5). The per-org
#: switch lives in ``org_policies.mcp_enabled``.
mcp_gateway_enabled: bool = True
mcp_token_ttl_days: int = 90
#: Calls one token may make per minute. Enforced in memory, per gateway
#: process — a restart forgives, which is the right trade for a spare
#: gateway process that only the owner's own agents talk to.
mcp_rate_limit_per_minute: int = 60
memory_org_char_cap: int = 60000
platform_invite_ttl_hours: int = 72
# <<< agent-1
@@ -0,0 +1,44 @@
"""Audit action names introduced by the org-platform workstream.
Kept in the identity domain instead of the shared ``app.domain.audit`` module so
the parallel workstreams never edit the same file (``app.domain.audit`` is
foundation-owned). Values are free-form strings — ``AuditAction`` accepts any.
"""
from __future__ import annotations
# --- platform invites (owner creates a new manager account) ----------------
PLATFORM_INVITE_CREATE = "platform_invite.create"
PLATFORM_INVITE_REVOKE = "platform_invite.revoke"
PLATFORM_INVITE_ACCEPT = "platform_invite.accept"
# --- org sharing -----------------------------------------------------------
ORG_TRANSFER_OWNERSHIP = "organization.transfer_ownership"
ORG_LEAVE = "organization.leave"
ORG_INVITE_REVOKE = "member.invite_revoke"
# --- wizard ----------------------------------------------------------------
ORG_CREATED_VIA_WIZARD = "organization.created_via_wizard"
APP_PROFILE_UPDATE = "app_profile.update"
APP_GIT_ROTATE = "app_git.rotate"
APP_DEPLOY_TARGET_SET = "app_deploy_target.set"
# --- owner model grants and org policy -------------------------------------
MODEL_GRANT_SET = "model_grant.set"
MODEL_GRANT_REVOKE = "model_grant.revoke"
DEFAULT_MODEL_GRANT_SET = "default_model_grant.set"
DEFAULT_MODEL_GRANT_REMOVE = "default_model_grant.remove"
ORG_POLICY_UPDATE = "org_policy.update"
# --- org memory ------------------------------------------------------------
MEMORY_CREATE = "memory.create"
MEMORY_UPDATE = "memory.update"
MEMORY_PIN = "memory.pin"
MEMORY_CONFIRM = "memory.confirm"
MEMORY_DELETE = "memory.delete"
# --- MCP gateway -----------------------------------------------------------
MCP_TOKEN_CREATE = "mcp_token.create"
MCP_TOKEN_REVOKE = "mcp_token.revoke"
MCP_GLOBAL_SWITCH = "mcp.global_switch"
MCP_ORG_SWITCH = "mcp.org_switch"
+57
View File
@@ -0,0 +1,57 @@
"""Org memory domain package (owned by Agent 1)."""
from app.domain.memory.models import (
CONFIRMED_CONFIDENCE,
CONTENT_MAX_CHARS,
KEY_MAX_CHARS,
KIND_LABELS,
LESSON_TTL_DAYS,
MAX_WRITES_PER_TASK,
PREFERENCE_ROLES,
REFLECTION_CONFIDENCE,
SOURCE_LABELS,
STRUCTURAL_KINDS,
STRUCTURAL_ROLES,
Memory,
MemoryKind,
MemorySource,
coerce_kind,
coerce_source,
content_hash,
lesson_expiry,
normalise,
relevance,
tokenise,
validate_confidence,
validate_content,
validate_key,
visible_kinds,
)
__all__ = [
"CONFIRMED_CONFIDENCE",
"CONTENT_MAX_CHARS",
"KEY_MAX_CHARS",
"KIND_LABELS",
"LESSON_TTL_DAYS",
"MAX_WRITES_PER_TASK",
"Memory",
"MemoryKind",
"MemorySource",
"PREFERENCE_ROLES",
"REFLECTION_CONFIDENCE",
"SOURCE_LABELS",
"STRUCTURAL_KINDS",
"STRUCTURAL_ROLES",
"coerce_kind",
"coerce_source",
"content_hash",
"lesson_expiry",
"normalise",
"relevance",
"tokenise",
"validate_confidence",
"validate_content",
"validate_key",
"visible_kinds",
]
+315
View File
@@ -0,0 +1,315 @@
"""Org memory domain: the rules of what an organization may remember.
Pure Python — no FastAPI, no SQLAlchemy. Three concerns live here and nowhere
else:
* **The vocabulary.** ``MemoryKind`` and ``MemorySource`` are the only two
taxonomies the platform has. An org's memory is *small* on purpose: a handful
of standing rules, a few preferences, the lessons a finished task leaves
behind, and a code map of the one app the org owns.
* **The limits.** A key is 80 characters, an entry 1,500. Anything longer is a
document, and documents belong in the Knowledge Base (``knowledge.py``), which
is why the memory page shows that second tab rather than re-implementing it.
* **The shape of an entry.** ``normalise`` + ``content_hash`` turn "the same
thought typed twice" into one stored memory, which is what keeps an org's
memory from becoming a duplicate pile.
Nothing here touches the database: the repository (``repositories/memory.py``)
moves rows, and the service (``services/memory_service.py``) decides what may be
written.
"""
from __future__ import annotations
import hashlib
import re
import unicodedata
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from enum import StrEnum
from app.core.errors import ValidationError
from app.domain.base import new_id, utcnow
class MemoryKind(StrEnum):
"""What a memory *is*, which decides who may read it and how long it lives."""
#: A standing instruction from the org: "always write tests first".
RULE = "rule"
#: A taste the org expressed: "prefer Persian comments".
PREFERENCE = "preference"
#: Something a finished task taught the org. Expires, because a lesson that
#: was true once is not a law.
LESSON = "lesson"
#: A durable fact about the app or the organisation ("the API runs on 8000").
FACT = "fact"
#: A short map of the codebase, rewritten as the code changes.
CODE_MAP = "code_map"
class MemorySource(StrEnum):
"""Who wrote it. ``reflection`` means the org's own self-review did."""
USER = "user"
MANAGER = "manager"
SYSTEM = "system"
REFLECTION = "reflection"
#: Hard limits, enforced here so a router, an MCP tool and the reflection writer
#: all share one definition of "too big".
KEY_MAX_CHARS = 80
CONTENT_MAX_CHARS = 1500
#: How long a lesson stays usable. ``30`` days is long enough to outlive the
#: task that produced it and short enough that stale advice dies quietly.
LESSON_TTL_DAYS = 30
#: One task may teach at most this many memories. Without a ceiling a single
#: runaway reflection could fill the org's whole budget with one task's opinion.
MAX_WRITES_PER_TASK = 3
#: A reflection's own confidence: it observed, it did not verify.
REFLECTION_CONFIDENCE = 0.5
#: A confirmed lesson is treated as settled knowledge and stops expiring.
CONFIRMED_CONFIDENCE = 1.0
#: Kinds that describe the *code*: only the roles that read code get them.
STRUCTURAL_KINDS = frozenset({MemoryKind.CODE_MAP, MemoryKind.FACT})
#: ``programmer`` is the plan's spelling of the engine's ``developer`` role key;
#: both are accepted so a caller cannot silently lose the code map.
STRUCTURAL_ROLES = frozenset(
{"planner", "developer", "programmer", "reviewer", "tester"}
)
#: Preferences are the manager's business — the employee who interprets them.
PREFERENCE_ROLES = frozenset({"manager"})
#: One-line Persian labels for the context block and the UI.
KIND_LABELS: dict[str, str] = {
MemoryKind.RULE: "قاعده",
MemoryKind.PREFERENCE: "ترجیح",
MemoryKind.LESSON: "درس",
MemoryKind.FACT: "واقعیت",
MemoryKind.CODE_MAP: "نقشهٔ کد",
}
SOURCE_LABELS: dict[str, str] = {
MemorySource.USER: "کاربر",
MemorySource.MANAGER: "مدیر",
MemorySource.SYSTEM: "سامانه",
MemorySource.REFLECTION: "بازتاب",
}
_WHITESPACE = re.compile(r"\s+")
#: Words that carry no meaning in either language; matching against them would
#: make every memory "relevant" to every question.
_STOP_WORDS = frozenset(
{
"the", "a", "an", "and", "or", "but", "if", "then", "else", "for", "with",
"without", "from", "into", "that", "this", "these", "those", "is", "are",
"was", "were", "be", "been", "to", "of", "in", "on", "at", "by", "as",
"it", "its", "do", "does", "did", "not", "no", "so", "we", "you", "use",
"using", "should", "must", "را", "که", "از", "به", "با", "این", "آن",
"و", "در", "برای", "تا", "بر", "شود", "است", "هست", "می",
}
)
@dataclass(slots=True, kw_only=True)
class Memory:
"""One thing the organization knows. ``id`` and timestamps are domain-owned."""
organization_id: str
kind: MemoryKind
content: str
source: MemorySource = MemorySource.USER
id: str = field(default_factory=new_id)
#: Optional stable handle: the same key always addresses the same memory,
#: which is how "our port is 8000" is corrected instead of duplicated.
key: str | None = None
#: The task that produced a lesson, when it came from a reflection.
evidence_task_id: str | None = None
pinned: bool = False
confidence: float = CONFIRMED_CONFIDENCE
expires_at: datetime | None = None
hits: int = 0
last_used_at: datetime | None = None
created_by_user_id: str | None = None
created_at: datetime = field(default_factory=utcnow)
updated_at: datetime = field(default_factory=utcnow)
deleted_at: datetime | None = None
@property
def label(self) -> str:
return KIND_LABELS.get(str(self.kind), "یادداشت")
@property
def content_hash(self) -> str:
"""Always the digest of the *current* content.
A property, not a column the writer has to remember to refresh: a memory
whose text changed must dedupe as the new text, never as the old one.
"""
return content_hash(self.content)
def is_live(self, *, now: datetime | None = None) -> bool:
"""Not deleted, not expired. Pinned entries never expire."""
if self.deleted_at is not None:
return False
if self.pinned or self.expires_at is None:
return True
return self.expires_at > (now or utcnow())
def is_confirmed(self) -> bool:
return self.confidence >= CONFIRMED_CONFIDENCE
def normalise(content: str) -> str:
"""Canonical form for comparison: NFC, whitespace collapsed, trimmed.
Two spellings of the same sentence («the api runs on 8000» / «The API runs
on 8000») must dedupe, so the comparison is case-folded.
"""
text = unicodedata.normalize("NFC", content or "")
return _WHITESPACE.sub(" ", text).strip()
def content_hash(content: str) -> str:
"""A stable short digest of the normalised content, for dedupe."""
digest = hashlib.sha256(normalise(content).casefold().encode("utf-8"))
return digest.hexdigest()[:32]
def validate_key(key: str | None) -> str | None:
"""``None`` (or empty) means "no key"; otherwise a short trimmed handle."""
if key is None:
return None
trimmed = normalise(key)
if not trimmed:
return None
if len(trimmed) > KEY_MAX_CHARS:
raise ValidationError("کلید یادداشت نمی‌تواند بیش از ۸۰ نویسه باشد.")
return trimmed
def validate_content(content: str) -> str:
cleaned = normalise(content)
if not cleaned:
raise ValidationError("محتوای یادداشت نمی‌تواند خالی باشد.")
if len(cleaned) > CONTENT_MAX_CHARS:
raise ValidationError("محتوای هر یادداشت نمی‌تواند بیش از ۱۵۰۰ نویسه باشد.")
return cleaned
def validate_confidence(value: float) -> float:
try:
confidence = float(value)
except (TypeError, ValueError) as exc: # pragma: no cover - guarded by callers
raise ValidationError("میزان اطمینان نامعتبر است.") from exc
if confidence < 0 or confidence > 1:
raise ValidationError("میزان اطمینان باید بین ۰ و ۱ باشد.")
return round(confidence, 2)
def coerce_kind(kind: str) -> MemoryKind:
try:
return MemoryKind(str(kind).strip().lower())
except ValueError as exc:
raise ValidationError("نوع یادداشت نامعتبر است.") from exc
def coerce_source(source: str) -> MemorySource:
try:
return MemorySource(str(source).strip().lower())
except ValueError as exc:
raise ValidationError("منبع یادداشت نامعتبر است.") from exc
def visible_kinds(role: str) -> frozenset[MemoryKind]:
"""The kinds one employee role may be shown.
Rules and lessons are everyone's business. ``code_map`` and ``fact``
describe the code, so only the roles that read code get them; preferences
belong to the manager alone, because a preference is an instruction to
interpret work, not a fact about it.
"""
role_key = str(role or "").strip().lower()
kinds = {MemoryKind.RULE, MemoryKind.LESSON}
if role_key in STRUCTURAL_ROLES:
kinds.update(STRUCTURAL_KINDS)
if role_key in PREFERENCE_ROLES:
kinds.add(MemoryKind.PREFERENCE)
return frozenset(kinds)
def lesson_expiry(*, pinned: bool, confirmed: bool) -> datetime | None:
"""Lessons expire; a pinned or confirmed lesson is treated as settled."""
if pinned or confirmed:
return None
return utcnow() + timedelta(days=LESSON_TTL_DAYS)
def tokenise(text: str) -> frozenset[str]:
"""Lower-cased words of both scripts, stop words removed."""
words = re.findall(r"[\w\u0600-\u06FF]+", (text or "").casefold())
return frozenset(w for w in words if len(w) > 1 and w not in _STOP_WORDS)
def relevance(memory: Memory, query_tokens: frozenset[str]) -> float:
"""How well a memory answers a query: keyword overlap, then recency.
Deliberately simple and explainable. An embedding search is a Phase-X
upgrade (the Knowledge Base already has a vector port to borrow); until an
org's memory is large enough for that to matter, word overlap is the honest
answer and it cannot surprise a reader.
"""
own = tokenise(memory.content)
overlap = len(query_tokens & own) / len(query_tokens) if query_tokens and own else 0.0
age_days = _age_days(memory.last_used_at or memory.created_at)
recency = 1.0 / (1.0 + age_days / LESSON_TTL_DAYS)
pinned_bonus = 0.1 if memory.pinned else 0.0
return round(overlap * 3.0 + recency + pinned_bonus, 4)
def _age_days(when: datetime | None) -> float:
"""Days since ``when``, treating a naive timestamp as UTC.
SQLite (the development and test store) hands back naive datetimes; a
recency score is not the place to discover that. A missing timestamp means
"very old", not "brand new".
"""
if when is None:
return LESSON_TTL_DAYS * 30
if when.tzinfo is None:
when = when.replace(tzinfo=UTC)
return max(0.0, (utcnow() - when).days)
__all__ = [
"CONFIRMED_CONFIDENCE",
"CONTENT_MAX_CHARS",
"KEY_MAX_CHARS",
"KIND_LABELS",
"LESSON_TTL_DAYS",
"MAX_WRITES_PER_TASK",
"Memory",
"MemoryKind",
"MemorySource",
"PREFERENCE_ROLES",
"REFLECTION_CONFIDENCE",
"SOURCE_LABELS",
"STRUCTURAL_KINDS",
"STRUCTURAL_ROLES",
"coerce_kind",
"coerce_source",
"content_hash",
"lesson_expiry",
"normalise",
"relevance",
"tokenise",
"validate_confidence",
"validate_content",
"validate_key",
"visible_kinds",
]
+147 -2
View File
@@ -24,7 +24,17 @@ from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, Text
from sqlalchemy import (
Boolean,
DateTime,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
@@ -103,4 +113,139 @@ class OrgSetupDraftRow(Base):
)
__all__ = ["OrgSetupDraftRow", "PlatformInviteRow"]
class DefaultModelGrantRow(Base):
"""The platform owner's default grant set, copied into every new organization.
Kept in a table of its own (never a column on ``ai_models``) so the model
catalogue stays a pure, owner-managed list.
"""
__tablename__ = "default_model_grants"
__table_args__ = (UniqueConstraint("ai_model_id", name="uq_default_model_grant"),)
id: Mapped[str] = pk()
ai_model_id: Mapped[str] = mapped_column(
ForeignKey("ai_models.id", ondelete="CASCADE"), nullable=False
)
daily_credit_cap: Mapped[float | None] = mapped_column(Float, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_by_user_id: Mapped[str | None] = mapped_column(
ForeignKey("users.id"), nullable=True
)
created_at: Mapped[datetime] = created()
class OrgPolicyRow(Base):
"""Per-organization policy the owner sets: limits and automation switches."""
__tablename__ = "org_policies"
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), primary_key=True
)
max_employees: Mapped[int] = mapped_column(Integer, default=5, nullable=False)
#: ``None`` means "no explicit budget" (an empty/unlimited wallet view).
monthly_credit_budget: Mapped[float | None] = mapped_column(Float, nullable=True)
automation_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
#: MCP access for this organization (Phase 5); the owner may switch it off.
mcp_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
)
class OrgMemoryRow(Base):
"""One thing the organization remembers about itself.
A memory is *small* by design (≤ 1,500 characters): longer material is a
document and belongs in the Knowledge Base. ``content_hash`` is what makes
"teach the same thing twice" a correction instead of a duplicate, and
``key`` gives a rule a stable address so the engine can update it.
"""
__tablename__ = "org_memories"
__table_args__ = (
Index("ix_org_memories_org_kind", "organization_id", "kind"),
Index("ix_org_memories_org_key", "organization_id", "key"),
)
id: Mapped[str] = pk()
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
)
kind: Mapped[str] = mapped_column(String(16), nullable=False)
#: Optional stable handle, unique in spirit within one org.
key: Mapped[str | None] = mapped_column(String(80), nullable=True)
content: Mapped[str] = mapped_column(String(1500), nullable=False)
#: A digest of the normalised content — the dedupe key the domain defines.
content_hash: Mapped[str] = mapped_column(String(32), nullable=False, default="")
source: Mapped[str] = mapped_column(String(16), default="user", nullable=False)
evidence_task_id: Mapped[str | None] = mapped_column(
ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
)
pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
confidence: Mapped[float] = mapped_column(Float, default=1.0, nullable=False)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
hits: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
last_used_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_by_user_id: Mapped[str | None] = mapped_column(
ForeignKey("users.id"), nullable=True
)
created_at: Mapped[datetime] = created()
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
)
#: Soft delete: a memory an admin removed stays in the audit story.
deleted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
class McpTokenRow(Base):
"""A scoped token an external AI agent uses to call the MCP gateway.
The token itself is shown exactly once, at creation; only its hash and a
six-character prefix (for display) are stored. Every call is attributed to
that prefix in the audit trail, so a leaked token can be identified and
revoked long before anyone can reuse it.
"""
__tablename__ = "mcp_tokens"
id: Mapped[str] = pk()
organization_id: Mapped[str] = mapped_column(
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(120), nullable=False)
token_hash: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
#: First six characters, for display only. Never enough to authenticate.
prefix: Mapped[str] = mapped_column(String(16), nullable=False)
#: Comma-separated scopes; an empty string means "no scopes", never "all".
scopes: Mapped[str] = mapped_column(String(255), default="", nullable=False)
created_by_user_id: Mapped[str | None] = mapped_column(
ForeignKey("users.id"), nullable=True
)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
last_used_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
revoked_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = created()
__all__ = [
"DefaultModelGrantRow",
"McpTokenRow",
"OrgMemoryRow",
"OrgPolicyRow",
"OrgSetupDraftRow",
"PlatformInviteRow",
]
@@ -0,0 +1,281 @@
"""Repositories for org memory (Agent 1).
One table, three access shapes:
* reads are always scoped to one ``organization_id`` — there is no "list all
memories" call, because a memory from another tenant must be unreachable by
construction rather than by a filter someone remembers to add;
* dedupe asks the database for the entry it suspects is the same one (by hash,
by key), so "teach it twice" becomes an update;
* eviction asks for the oldest unpinned lessons, which is the only thing this
table is allowed to throw away.
"""
from __future__ import annotations
from sqlalchemy import String, cast, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.base import utcnow
from app.domain.memory.models import Memory, MemoryKind, MemorySource
from app.infrastructure.db.models_org import OrgMemoryRow
def _to_domain(row: OrgMemoryRow) -> Memory:
return Memory(
id=row.id,
organization_id=row.organization_id,
kind=MemoryKind(row.kind),
key=row.key,
content=row.content,
source=MemorySource(row.source),
evidence_task_id=row.evidence_task_id,
pinned=bool(row.pinned),
confidence=float(row.confidence or 0.0),
expires_at=row.expires_at,
hits=int(row.hits or 0),
last_used_at=row.last_used_at,
created_by_user_id=row.created_by_user_id,
created_at=row.created_at,
updated_at=row.updated_at,
deleted_at=row.deleted_at,
)
class MemoryRepository:
"""Org memory persistence. Every method is tenant-scoped or keyed by id."""
def __init__(self, session: AsyncSession) -> None:
self.session = session
# -- reads ---------------------------------------------------------------
async def list_for_org(
self,
organization_id: str,
*,
kind: MemoryKind | None = None,
search: str | None = None,
include_deleted: bool = False,
limit: int = 200,
offset: int = 0,
) -> list[Memory]:
stmt = select(OrgMemoryRow).where(
OrgMemoryRow.organization_id == organization_id
)
if not include_deleted:
stmt = stmt.where(OrgMemoryRow.deleted_at.is_(None))
if kind is not None:
stmt = stmt.where(OrgMemoryRow.kind == str(kind))
if search:
# Literal LIKE, not user-written pattern: a memory search must not
# be able to inject SQL. Parameterised by SQLAlchemy either way.
needle = f"%{search.strip()}%"
stmt = stmt.where(
or_(
OrgMemoryRow.content.like(needle),
cast(OrgMemoryRow.key, String).like(needle),
)
)
stmt = stmt.order_by(
OrgMemoryRow.pinned.desc(), OrgMemoryRow.updated_at.desc()
).limit(limit).offset(offset)
rows = (await self.session.execute(stmt)).scalars().all()
return [_to_domain(row) for row in rows]
async def get(self, organization_id: str, memory_id: str) -> Memory | None:
row = await self.session.scalar(
select(OrgMemoryRow).where(
OrgMemoryRow.organization_id == organization_id,
OrgMemoryRow.id == memory_id,
)
)
return _to_domain(row) if row else None
async def find_duplicate(
self, organization_id: str, *, kind: MemoryKind, key: str | None, digest: str
) -> Memory | None:
"""The entry this write would duplicate: same key, or same content.
Matching on the key first is what turns "our port is 8000" into a
correction of one memory instead of a second, similar memory.
"""
if key:
row = await self.session.scalar(
select(OrgMemoryRow).where(
OrgMemoryRow.organization_id == organization_id,
OrgMemoryRow.key == key,
OrgMemoryRow.deleted_at.is_(None),
)
)
if row is not None:
return _to_domain(row)
row = await self.session.scalar(
select(OrgMemoryRow).where(
OrgMemoryRow.organization_id == organization_id,
OrgMemoryRow.kind == str(kind),
OrgMemoryRow.content_hash == digest,
OrgMemoryRow.deleted_at.is_(None),
)
)
return _to_domain(row) if row else None
async def count_for_task(self, organization_id: str, evidence_task_id: str) -> int:
return int(
await self.session.scalar(
select(func.count())
.select_from(OrgMemoryRow)
.where(
OrgMemoryRow.organization_id == organization_id,
OrgMemoryRow.evidence_task_id == evidence_task_id,
OrgMemoryRow.deleted_at.is_(None),
)
)
or 0
)
async def total_chars(self, organization_id: str) -> int:
"""Characters the org currently spends on live memory."""
rows = (
await self.session.execute(
select(func.length(OrgMemoryRow.content)).where(
OrgMemoryRow.organization_id == organization_id,
OrgMemoryRow.deleted_at.is_(None),
)
)
).scalars()
return int(sum(int(value or 0) for value in rows))
# -- writes --------------------------------------------------------------
async def create(self, memory: Memory) -> Memory:
row = OrgMemoryRow(
id=memory.id,
organization_id=memory.organization_id,
kind=str(memory.kind),
key=memory.key,
content=memory.content,
content_hash=memory.content_hash,
source=str(memory.source),
evidence_task_id=memory.evidence_task_id,
pinned=memory.pinned,
confidence=memory.confidence,
expires_at=memory.expires_at,
hits=memory.hits,
last_used_at=memory.last_used_at,
created_by_user_id=memory.created_by_user_id,
created_at=memory.created_at,
updated_at=memory.updated_at,
deleted_at=memory.deleted_at,
)
self.session.add(row)
await self.session.flush()
return memory
async def save(self, memory: Memory) -> Memory:
"""Persist a mutated in-memory record, hash and timestamp included."""
row = await self.session.scalar(
select(OrgMemoryRow).where(
OrgMemoryRow.organization_id == memory.organization_id,
OrgMemoryRow.id == memory.id,
)
)
if row is None: # pragma: no cover - the service always loads first
return await self.create(memory)
row.kind = str(memory.kind)
row.key = memory.key
row.content = memory.content
row.content_hash = memory.content_hash
row.source = str(memory.source)
row.evidence_task_id = memory.evidence_task_id
row.pinned = memory.pinned
row.confidence = memory.confidence
row.expires_at = memory.expires_at
row.hits = memory.hits
row.last_used_at = memory.last_used_at
row.deleted_at = memory.deleted_at
row.updated_at = utcnow()
await self.session.flush()
return memory
async def mark_used(self, ids: list[str]) -> None:
"""Count a ``build_context`` hit. One UPDATE, never one per memory."""
if not ids:
return
await self.session.execute(
update(OrgMemoryRow)
.where(OrgMemoryRow.id.in_(ids))
.values(hits=OrgMemoryRow.hits + 1, last_used_at=utcnow())
)
async def soft_delete(self, organization_id: str, memory_id: str) -> bool:
row = await self.session.scalar(
select(OrgMemoryRow).where(
OrgMemoryRow.organization_id == organization_id,
OrgMemoryRow.id == memory_id,
OrgMemoryRow.deleted_at.is_(None),
)
)
if row is None:
return False
row.deleted_at = utcnow()
await self.session.flush()
return True
# -- maintenance ---------------------------------------------------------
async def expire(self, organization_id: str | None = None) -> int:
"""Soft-delete expired lessons. Pinned entries never expire.
Called from ``expire_memories`` — which the plan wants exposed as a
plain function because the worker that owns the schedule is not mine.
"""
stmt = select(OrgMemoryRow).where(
OrgMemoryRow.deleted_at.is_(None),
OrgMemoryRow.pinned.is_(False),
OrgMemoryRow.expires_at.is_not(None),
OrgMemoryRow.expires_at < utcnow(),
)
if organization_id:
stmt = stmt.where(OrgMemoryRow.organization_id == organization_id)
rows = (await self.session.execute(stmt)).scalars().all()
now = utcnow()
for row in rows:
row.deleted_at = now
await self.session.flush()
return len(rows)
async def evictable_lessons(
self, organization_id: str, *, limit: int
) -> list[Memory]:
"""The oldest unpinned lessons — the only entries this table may drop."""
rows = (
await self.session.execute(
select(OrgMemoryRow)
.where(
OrgMemoryRow.organization_id == organization_id,
OrgMemoryRow.deleted_at.is_(None),
OrgMemoryRow.pinned.is_(False),
OrgMemoryRow.kind == str(MemoryKind.LESSON),
)
.order_by(OrgMemoryRow.created_at.asc())
.limit(limit)
)
).scalars().all()
return [_to_domain(row) for row in rows]
async def hard_delete(self, organization_id: str, memory_id: str) -> bool:
row = await self.session.scalar(
select(OrgMemoryRow).where(
OrgMemoryRow.organization_id == organization_id,
OrgMemoryRow.id == memory_id,
)
)
if row is None:
return False
await self.session.delete(row)
await self.session.flush()
return True
__all__ = ["MemoryRepository"]
@@ -0,0 +1,237 @@
"""Repositories for owner-granted models and per-organization policy (Agent 1).
``org_model_grants`` is foundation-owned but has no repository elsewhere; these
live in a file no other workstream edits (the local shim the plan allows).
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.base import new_id, utcnow
from app.infrastructure.db.models import OrgModelGrantRow
from app.infrastructure.db.models_org import DefaultModelGrantRow, OrgPolicyRow
@dataclass(slots=True, kw_only=True)
class OrgGrantRecord:
organization_id: str
ai_model_id: str
daily_credit_cap: float | None = None
is_active: bool = True
created_by_user_id: str | None = None
id: str = ""
@dataclass(slots=True, kw_only=True)
class DefaultGrantRecord:
ai_model_id: str
daily_credit_cap: float | None = None
is_active: bool = True
created_by_user_id: str | None = None
id: str = ""
@dataclass(slots=True, kw_only=True)
class OrgPolicyRecord:
organization_id: str
max_employees: int = 5
monthly_credit_budget: float | None = None
automation_enabled: bool = True
mcp_enabled: bool = True
updated_at: datetime | None = None
class OrgModelGrantRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
@staticmethod
def _to_record(row: OrgModelGrantRow) -> OrgGrantRecord:
return OrgGrantRecord(
id=row.id,
organization_id=row.organization_id,
ai_model_id=row.ai_model_id,
daily_credit_cap=row.daily_credit_cap,
is_active=row.is_active,
created_by_user_id=row.created_by_user_id,
)
async def get(self, organization_id: str, ai_model_id: str) -> OrgGrantRecord | None:
stmt = select(OrgModelGrantRow).where(
OrgModelGrantRow.organization_id == organization_id,
OrgModelGrantRow.ai_model_id == ai_model_id,
)
row = (await self.session.execute(stmt)).scalar_one_or_none()
return self._to_record(row) if row else None
async def list_for_org(
self, organization_id: str, *, active_only: bool = False
) -> list[OrgGrantRecord]:
stmt = select(OrgModelGrantRow).where(
OrgModelGrantRow.organization_id == organization_id
)
if active_only:
stmt = stmt.where(OrgModelGrantRow.is_active)
rows = (await self.session.execute(stmt)).scalars().all()
return [self._to_record(r) for r in rows]
async def upsert(
self,
*,
organization_id: str,
ai_model_id: str,
daily_credit_cap: float | None,
is_active: bool,
created_by_user_id: str | None,
) -> OrgGrantRecord:
row = await self.session.scalar(
select(OrgModelGrantRow).where(
OrgModelGrantRow.organization_id == organization_id,
OrgModelGrantRow.ai_model_id == ai_model_id,
)
)
if row is None:
row = OrgModelGrantRow(
id=new_id(),
organization_id=organization_id,
ai_model_id=ai_model_id,
daily_credit_cap=daily_credit_cap,
is_active=is_active,
created_by_user_id=created_by_user_id,
)
self.session.add(row)
else:
row.daily_credit_cap = daily_credit_cap
row.is_active = is_active
await self.session.flush()
return self._to_record(row)
async def delete(self, organization_id: str, ai_model_id: str) -> bool:
row = await self.session.scalar(
select(OrgModelGrantRow).where(
OrgModelGrantRow.organization_id == organization_id,
OrgModelGrantRow.ai_model_id == ai_model_id,
)
)
if row is None:
return False
await self.session.delete(row)
await self.session.flush()
return True
class DefaultModelGrantRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
@staticmethod
def _to_record(row: DefaultModelGrantRow) -> DefaultGrantRecord:
return DefaultGrantRecord(
id=row.id,
ai_model_id=row.ai_model_id,
daily_credit_cap=row.daily_credit_cap,
is_active=row.is_active,
created_by_user_id=row.created_by_user_id,
)
async def list_all(self) -> list[DefaultGrantRecord]:
rows = (await self.session.execute(select(DefaultModelGrantRow))).scalars().all()
return [self._to_record(r) for r in rows]
async def list_active(self) -> list[DefaultGrantRecord]:
stmt = select(DefaultModelGrantRow).where(DefaultModelGrantRow.is_active)
return [self._to_record(r) for r in (await self.session.execute(stmt)).scalars()]
async def upsert(
self,
*,
ai_model_id: str,
daily_credit_cap: float | None,
is_active: bool,
created_by_user_id: str | None,
) -> DefaultGrantRecord:
row = await self.session.scalar(
select(DefaultModelGrantRow).where(
DefaultModelGrantRow.ai_model_id == ai_model_id
)
)
if row is None:
row = DefaultModelGrantRow(
id=new_id(),
ai_model_id=ai_model_id,
daily_credit_cap=daily_credit_cap,
is_active=is_active,
created_by_user_id=created_by_user_id,
)
self.session.add(row)
else:
row.daily_credit_cap = daily_credit_cap
row.is_active = is_active
await self.session.flush()
return self._to_record(row)
async def delete(self, ai_model_id: str) -> bool:
row = await self.session.scalar(
select(DefaultModelGrantRow).where(
DefaultModelGrantRow.ai_model_id == ai_model_id
)
)
if row is None:
return False
await self.session.delete(row)
await self.session.flush()
return True
class OrgPolicyRepository:
def __init__(self, session: AsyncSession) -> None:
self.session = session
@staticmethod
def _to_record(row: OrgPolicyRow) -> OrgPolicyRecord:
return OrgPolicyRecord(
organization_id=row.organization_id,
max_employees=row.max_employees,
monthly_credit_budget=row.monthly_credit_budget,
automation_enabled=row.automation_enabled,
mcp_enabled=row.mcp_enabled,
updated_at=row.updated_at,
)
async def get(self, organization_id: str) -> OrgPolicyRecord | None:
row = await self.session.get(OrgPolicyRow, organization_id)
return self._to_record(row) if row else None
async def upsert(
self,
*,
organization_id: str,
max_employees: int,
monthly_credit_budget: float | None,
automation_enabled: bool,
mcp_enabled: bool | None = None,
) -> OrgPolicyRecord:
row = await self.session.get(OrgPolicyRow, organization_id)
if row is None:
row = OrgPolicyRow(
organization_id=organization_id,
max_employees=max_employees,
monthly_credit_budget=monthly_credit_budget,
automation_enabled=automation_enabled,
mcp_enabled=True if mcp_enabled is None else mcp_enabled,
)
self.session.add(row)
else:
row.max_employees = max_employees
row.monthly_credit_budget = monthly_credit_budget
row.automation_enabled = automation_enabled
if mcp_enabled is not None:
row.mcp_enabled = mcp_enabled
row.updated_at = utcnow()
await self.session.flush()
return self._to_record(row)
@@ -2,10 +2,358 @@
Owned by Agent 1 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each
route its full path (no prefix is applied here besides ``/api``).
The rule this router enforces: **an organization may only use the models its
owner granted it.** The endpoints here are the only way a grant appears, so
revoking one is immediate (``ModelGrantService.is_granted`` returns false on the
next call) rather than a change that only takes effect after a cache expires.
Everything is owner-only, guarded at the router level, and every mutation is
audited. The model *names* live here and nowhere else: organization users only
ever see a neutral tier («سریع» / «متعادل» / «قوی»).
"""
from __future__ import annotations
from fastapi import APIRouter
from dataclasses import asdict
router = APIRouter(tags=["admin-grants"])
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.application.services.model_grant_service import (
ModelGrantService,
ModelView,
OrgGrantView,
PolicyView,
)
from app.core.database import get_session
from app.core.errors import ValidationError
from app.domain.identity.audit_actions import (
DEFAULT_MODEL_GRANT_REMOVE,
DEFAULT_MODEL_GRANT_SET,
MODEL_GRANT_REVOKE,
MODEL_GRANT_SET,
ORG_POLICY_UPDATE,
)
from app.domain.identity.models import User
from app.interfaces.http.deps import client_meta, require_platform_owner
router = APIRouter(
prefix="/admin",
tags=["admin-grants"],
# Deny-by-default, exactly like the rest of the console.
dependencies=[Depends(require_platform_owner)],
)
async def get_model_grant_service(
session: AsyncSession = Depends(get_session),
) -> ModelGrantService:
from app.application.container_org import build_model_grant_service
return build_model_grant_service(session)
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class ModelViewOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
ai_model_id: str
label: str
provider_id: str
capabilities: list[str]
tier: str
tier_label: str
output_price_per_1m: float
model_active: bool
granted: bool
daily_credit_cap: float | None = None
class OrgGrantOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
ai_model_id: str
label: str
tier: str
tier_label: str
capabilities: list[str]
daily_credit_cap: float | None = None
is_active: bool
class OrgGrantsOut(BaseModel):
"""The grant matrix for one organization: every active model + its state."""
organization_id: str
models: list[ModelViewOut]
class SetGrantRequest(BaseModel):
#: ``None`` means "no per-model daily cap"; ``0`` means "granted but zeroed".
daily_credit_cap: float | None = Field(default=None, ge=0)
class SetDefaultGrantRequest(BaseModel):
is_active: bool = True
daily_credit_cap: float | None = Field(default=None, ge=0)
class PolicyOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
organization_id: str
max_employees: int
monthly_credit_budget: float | None = None
automation_enabled: bool
mcp_enabled: bool
max_employees_limit: int
class UpdatePolicyRequest(BaseModel):
max_employees: int | None = Field(default=None, ge=1)
monthly_credit_budget: float | None = Field(default=None, ge=0)
automation_enabled: bool | None = None
mcp_enabled: bool | None = None
def _model_out(view: ModelView) -> ModelViewOut:
return ModelViewOut(**asdict(view))
def _grant_out(view: OrgGrantView) -> OrgGrantOut:
return OrgGrantOut(**asdict(view))
def _policy_out(view: PolicyView) -> PolicyOut:
return PolicyOut(**asdict(view))
def _require_change(*values: object) -> None:
if all(value is None for value in values):
raise ValidationError("هیچ تغییری ارسال نشده است.")
# ---------------------------------------------------------------------------
# Grants per organization
# ---------------------------------------------------------------------------
@router.get("/model-grants/{organization_id}", response_model=OrgGrantsOut)
async def list_org_grants(
organization_id: str,
service: ModelGrantService = Depends(get_model_grant_service),
) -> OrgGrantsOut:
"""Every active catalogue model, flagged with whether this org may use it."""
models = await service.list_models(organization_id=organization_id)
return OrgGrantsOut(
organization_id=organization_id, models=[_model_out(m) for m in models]
)
@router.put("/model-grants/{organization_id}/models/{ai_model_id}", response_model=OrgGrantOut)
async def set_org_grant(
organization_id: str,
ai_model_id: str,
payload: SetGrantRequest,
request: Request,
owner: User = Depends(require_platform_owner),
service: ModelGrantService = Depends(get_model_grant_service),
# FastAPI caches ``get_session`` per request, so this is the very session
# the service writes through: the audit row lands in the same transaction.
session: AsyncSession = Depends(get_session),
) -> OrgGrantOut:
"""Grant a model to an organization (idempotent) and/or set its daily cap."""
granted = await service.grant_model(
organization_id=organization_id,
ai_model_id=ai_model_id,
daily_credit_cap=payload.daily_credit_cap,
actor_user_id=owner.id,
)
ip, ua = client_meta(request)
await record_audit(
session,
action=MODEL_GRANT_SET,
organization_id=organization_id,
actor_user_id=owner.id,
target_type="ai_model",
target_id=ai_model_id,
# The model label is a name, not a secret, but keeping the audit rows
# id-only makes them stable when the owner renames a model.
metadata={"daily_credit_cap": payload.daily_credit_cap},
ip=ip,
user_agent=ua,
)
return _grant_out(granted)
@router.get("/model-grants/{organization_id}/active", response_model=list[OrgGrantOut])
async def list_active_org_grants(
organization_id: str,
service: ModelGrantService = Depends(get_model_grant_service),
) -> list[OrgGrantOut]:
"""Just the active grants — what the engine will actually be allowed to use."""
grants = await service.list_grants(organization_id=organization_id, active_only=True)
return [_grant_out(g) for g in grants]
@router.delete("/model-grants/{organization_id}/models/{ai_model_id}")
async def revoke_org_grant(
organization_id: str,
ai_model_id: str,
request: Request,
owner: User = Depends(require_platform_owner),
service: ModelGrantService = Depends(get_model_grant_service),
session: AsyncSession = Depends(get_session),
) -> dict[str, object]:
"""Revoke a model from one organization. Other organizations are unaffected."""
await service.revoke_model(
organization_id=organization_id, ai_model_id=ai_model_id, actor_user_id=owner.id
)
ip, ua = client_meta(request)
await record_audit(
session,
action=MODEL_GRANT_REVOKE,
organization_id=organization_id,
actor_user_id=owner.id,
target_type="ai_model",
target_id=ai_model_id,
ip=ip,
user_agent=ua,
)
return {"organization_id": organization_id, "ai_model_id": ai_model_id, "granted": False}
# ---------------------------------------------------------------------------
# The default set copied into every new organization
# ---------------------------------------------------------------------------
@router.get("/default-model-grants", response_model=list[ModelViewOut])
async def list_default_grants(
service: ModelGrantService = Depends(get_model_grant_service),
) -> list[ModelViewOut]:
return [_model_out(m) for m in await service.list_defaults()]
@router.put("/default-model-grants/{ai_model_id}", response_model=ModelViewOut)
async def set_default_grant(
ai_model_id: str,
payload: SetDefaultGrantRequest,
request: Request,
owner: User = Depends(require_platform_owner),
service: ModelGrantService = Depends(get_model_grant_service),
session: AsyncSession = Depends(get_session),
) -> ModelViewOut:
"""Add a model to the default set (or switch its default off without losing it)."""
await service.set_default(
ai_model_id=ai_model_id,
daily_credit_cap=payload.daily_credit_cap,
is_active=payload.is_active,
actor_user_id=owner.id,
)
ip, ua = client_meta(request)
await record_audit(
session,
action=DEFAULT_MODEL_GRANT_SET,
actor_user_id=owner.id,
target_type="ai_model",
target_id=ai_model_id,
metadata={
"is_active": payload.is_active,
"daily_credit_cap": payload.daily_credit_cap,
},
ip=ip,
user_agent=ua,
)
view = next(
(m for m in await service.list_defaults() if m.ai_model_id == ai_model_id), None
)
assert view is not None
return _model_out(view)
@router.delete("/default-model-grants/{ai_model_id}")
async def remove_default_grant(
ai_model_id: str,
request: Request,
owner: User = Depends(require_platform_owner),
service: ModelGrantService = Depends(get_model_grant_service),
session: AsyncSession = Depends(get_session),
) -> dict[str, object]:
"""Remove a model from the default set. Already-created orgs keep their grant."""
await service.remove_default(ai_model_id=ai_model_id)
ip, ua = client_meta(request)
await record_audit(
session,
action=DEFAULT_MODEL_GRANT_REMOVE,
actor_user_id=owner.id,
target_type="ai_model",
target_id=ai_model_id,
ip=ip,
user_agent=ua,
)
return {"ai_model_id": ai_model_id, "in_default_set": False}
# ---------------------------------------------------------------------------
# Org policy
# ---------------------------------------------------------------------------
@router.get("/org-policies/{organization_id}", response_model=PolicyOut)
async def get_org_policy(
organization_id: str,
service: ModelGrantService = Depends(get_model_grant_service),
) -> PolicyOut:
return _policy_out(await service.get_org_policy(organization_id))
@router.put("/org-policies/{organization_id}", response_model=PolicyOut)
async def update_org_policy(
organization_id: str,
payload: UpdatePolicyRequest,
request: Request,
owner: User = Depends(require_platform_owner),
service: ModelGrantService = Depends(get_model_grant_service),
session: AsyncSession = Depends(get_session),
) -> PolicyOut:
"""Set the employee cap, monthly budget and automation switch for one org."""
_require_change(
payload.max_employees,
payload.monthly_credit_budget,
payload.automation_enabled,
payload.mcp_enabled,
)
policy = await service.set_org_policy(
organization_id=organization_id,
max_employees=payload.max_employees,
monthly_credit_budget=payload.monthly_credit_budget,
automation_enabled=payload.automation_enabled,
mcp_enabled=payload.mcp_enabled,
actor_user_id=owner.id,
)
ip, ua = client_meta(request)
await record_audit(
session,
action=ORG_POLICY_UPDATE,
organization_id=organization_id,
actor_user_id=owner.id,
target_type="organization",
target_id=organization_id,
metadata={
"max_employees": policy.max_employees,
"monthly_credit_budget": policy.monthly_credit_budget,
"automation_enabled": policy.automation_enabled,
"mcp_enabled": policy.mcp_enabled,
},
ip=ip,
user_agent=ua,
)
return _policy_out(policy)
@@ -2,10 +2,341 @@
Owned by Agent 1 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each
route its full path (no prefix is applied here besides ``/api``).
Two audiences, one rule each:
* an **organization admin** mints, lists and revokes its own tokens, and picks the
scopes — a token can never be broader than the admin's own intent, and it can
never be issued for another organization;
* the **platform owner** holds the two kill switches: the gateway as a whole, and
one organization's access to it.
The raw token is returned exactly once, by the create call, and is never stored —
only its hash. Everything after that is the prefix.
"""
from __future__ import annotations
from fastapi import APIRouter
from datetime import datetime
from fastapi import APIRouter, Depends, Request, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.container_org import build_model_grant_service
from app.application.services.audit_service import record_audit
from app.application.services.mcp_service import (
MCP_SCOPES,
McpService,
as_utc,
validate_scopes,
)
from app.core.config import settings
from app.core.database import get_session
from app.domain.base import utcnow
from app.domain.identity.audit_actions import (
MCP_GLOBAL_SWITCH,
MCP_ORG_SWITCH,
MCP_TOKEN_CREATE,
MCP_TOKEN_REVOKE,
)
from app.domain.identity.models import Role, User
from app.interfaces.http.deps import (
OrgContext,
client_meta,
get_org_context,
require_platform_owner,
require_role_at_least,
)
router = APIRouter(tags=["mcp-tokens"])
#: Minting a token is admin+ (it grants a third party real access).
_admin_only = require_role_at_least(Role.ADMIN)
async def _service(session: AsyncSession = Depends(get_session)) -> McpService:
return McpService(session)
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class McpTokenOut(BaseModel):
id: str
organization_id: str
name: str
prefix: str
scopes: list[str]
expires_at: datetime | None = None
last_used_at: datetime | None = None
revoked_at: datetime | None = None
created_at: datetime | None = None
status: str
class McpTokenCreatedOut(McpTokenOut):
"""The only response that ever carries the raw token."""
token: str
class McpTokenIn(BaseModel):
name: str = Field(min_length=2, max_length=120)
scopes: list[str] = Field(min_length=1, max_length=len(MCP_SCOPES))
ttl_days: int | None = Field(default=None, ge=1, le=3650)
class McpSettingsOut(BaseModel):
organization_id: str
mcp_enabled: bool
gateway_enabled: bool
scopes: list[str]
token_count: int
class McpGlobalOut(BaseModel):
gateway_enabled: bool
organizations: list[McpOrgStateOut]
total_tokens: int
class McpOrgStateOut(BaseModel):
organization_id: str
name: str
slug: str
mcp_enabled: bool
token_count: int
class McpGlobalIn(BaseModel):
enabled: bool
class McpOrgSwitchIn(BaseModel):
enabled: bool
def _status(token) -> str:
"""``active`` only while the token is neither revoked nor expired."""
if token.revoked_at is not None:
return "revoked"
if as_utc(token.expires_at) is not None and as_utc(token.expires_at) <= utcnow():
return "expired"
return "active"
def _out(token) -> McpTokenOut:
return McpTokenOut(
id=token.id,
organization_id=token.organization_id,
name=token.name,
prefix=token.prefix,
scopes=token.scopes,
expires_at=token.expires_at,
last_used_at=token.last_used_at,
revoked_at=token.revoked_at,
created_at=token.created_at,
status=_status(token),
)
# ---------------------------------------------------------------------------
# Organization-scoped tokens (admin+)
# ---------------------------------------------------------------------------
@router.get(
"/organizations/{org_id}/mcp-tokens", response_model=list[McpTokenOut]
)
async def list_tokens(
context: OrgContext = Depends(get_org_context),
service: McpService = Depends(_service),
) -> list[McpTokenOut]:
"""Every token this organization ever minted, with its live status.
Deliberately includes revoked and expired rows: the history of who could
reach the organization is the part that matters after an incident.
"""
return [_out(token) for token in await service.list(context.org_id)]
@router.post(
"/organizations/{org_id}/mcp-tokens",
response_model=McpTokenCreatedOut,
status_code=status.HTTP_201_CREATED,
)
async def create_token(
payload: McpTokenIn,
request: Request,
context: OrgContext = Depends(_admin_only),
service: McpService = Depends(_service),
session: AsyncSession = Depends(get_session),
) -> McpTokenCreatedOut:
"""Mint a token. The raw value appears in this response and nowhere else."""
issued = await service.issue(
organization_id=context.org_id,
name=payload.name,
scopes=validate_scopes(payload.scopes),
created_by_user_id=context.user.id,
ttl_days=payload.ttl_days,
)
ip, ua = client_meta(request)
await record_audit(
session,
action=MCP_TOKEN_CREATE,
organization_id=context.org_id,
actor_user_id=context.user.id,
target_type="mcp_token",
target_id=issued.record.id,
metadata={"prefix": issued.record.prefix, "scopes": issued.record.scopes},
ip=ip,
user_agent=ua,
)
return McpTokenCreatedOut(**_out(issued.record).model_dump(), token=issued.token)
@router.delete("/organizations/{org_id}/mcp-tokens/{token_id}")
async def revoke_token(
token_id: str,
request: Request,
context: OrgContext = Depends(_admin_only),
service: McpService = Depends(_service),
session: AsyncSession = Depends(get_session),
) -> dict[str, object]:
await service.revoke(organization_id=context.org_id, token_id=token_id)
ip, ua = client_meta(request)
await record_audit(
session,
action=MCP_TOKEN_REVOKE,
organization_id=context.org_id,
actor_user_id=context.user.id,
target_type="mcp_token",
target_id=token_id,
ip=ip,
user_agent=ua,
)
return {"id": token_id, "revoked": True}
@router.get(
"/organizations/{org_id}/mcp-tokens/settings", response_model=McpSettingsOut
)
async def get_mcp_settings(
context: OrgContext = Depends(get_org_context),
service: McpService = Depends(_service),
) -> McpSettingsOut:
"""The switches this organization's own admins may see and the scope list."""
policy = await build_model_grant_service(service.session).get_org_policy(
context.org_id
)
tokens = await service.list(context.org_id)
return McpSettingsOut(
organization_id=context.org_id,
mcp_enabled=policy.mcp_enabled,
gateway_enabled=settings.mcp_gateway_enabled,
scopes=list(MCP_SCOPES),
token_count=sum(1 for token in tokens if _status(token) == "active"),
)
# ---------------------------------------------------------------------------
# Owner: the global switch and the per-organization switch
# ---------------------------------------------------------------------------
@router.get("/admin/mcp", response_model=McpGlobalOut)
async def get_mcp_state(
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> McpGlobalOut:
"""Every organization with its MCP state and active token count."""
from sqlalchemy import select
from app.infrastructure.db.models import OrganizationRow
service = McpService(session)
grants = build_model_grant_service(session)
orgs = (
(await session.execute(select(OrganizationRow).order_by(OrganizationRow.name)))
.scalars()
.all()
)
counts = await service.count_per_org()
states = [
McpOrgStateOut(
organization_id=org.id,
name=org.name,
slug=org.slug,
mcp_enabled=(await grants.get_org_policy(org.id)).mcp_enabled,
token_count=counts.get(org.id, 0),
)
for org in orgs
]
return McpGlobalOut(
gateway_enabled=settings.mcp_gateway_enabled,
organizations=states,
total_tokens=sum(counts.values()),
)
@router.put("/admin/mcp")
async def set_mcp_state(
payload: McpGlobalIn,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> dict[str, object]:
"""Disable (or re-enable) the gateway platform-wide.
The switch is a setting rather than a table: it must hold even before any
token exists, and a gateway that cannot reach the database should not be the
thing deciding it can serve traffic.
"""
settings.mcp_gateway_enabled = bool(payload.enabled)
ip, ua = client_meta(request)
await record_audit(
session,
action=MCP_GLOBAL_SWITCH,
actor_user_id=owner.id,
target_type="platform",
target_id="mcp",
metadata={"enabled": bool(payload.enabled)},
ip=ip,
user_agent=ua,
)
return {"gateway_enabled": settings.mcp_gateway_enabled}
@router.put("/admin/mcp/organizations/{organization_id}")
async def set_org_mcp_state(
organization_id: str,
payload: McpOrgSwitchIn,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> dict[str, object]:
"""One organization's access to the gateway, independent of its tokens."""
policy = await build_model_grant_service(session).set_mcp_enabled(
organization_id=organization_id,
enabled=bool(payload.enabled),
actor_user_id=owner.id,
)
ip, ua = client_meta(request)
await record_audit(
session,
action=MCP_ORG_SWITCH,
organization_id=organization_id,
actor_user_id=owner.id,
target_type="organization",
target_id=organization_id,
metadata={"enabled": policy.mcp_enabled},
ip=ip,
user_agent=ua,
)
return {"organization_id": organization_id, "mcp_enabled": policy.mcp_enabled}
__all__ = ["router"]
+308 -1
View File
@@ -2,10 +2,317 @@
Owned by Agent 1 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each
route its full path (no prefix is applied here besides ``/api``).
Trust ladder, matching the plan: a viewer reads, a member teaches, an admin edits
or deletes anything. The same rules are enforced inside ``MemoryService`` too,
so the MCP gateway (Phase 5) hits the same wall — a rule that only the router
knows is a rule an agent can bypass.
Everything stored here went through ``redact`` on the way in, so no endpoint can
ever return a credential: what you can read is what was safe to write.
"""
from __future__ import annotations
from fastapi import APIRouter
from fastapi import APIRouter, Depends, Query, Request, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.container_org import build_memory_service
from app.application.ports.realtime import RealtimeEvent, org_channel, publish
from app.application.services.audit_service import record_audit
from app.application.services.memory_service import MemoryService
from app.core.database import get_session
from app.domain.identity.audit_actions import (
MEMORY_CONFIRM,
MEMORY_CREATE,
MEMORY_DELETE,
MEMORY_PIN,
MEMORY_UPDATE,
)
from app.domain.identity.models import Role
from app.domain.memory.models import (
KIND_LABELS,
SOURCE_LABELS,
Memory,
MemoryKind,
)
from app.interfaces.http.deps import (
OrgContext,
client_meta,
get_org_context,
require_role_at_least,
)
router = APIRouter(tags=["memory"])
#: Anyone with a membership reads; teaching is member+; editing is admin+.
_teacher = require_role_at_least(Role.MEMBER)
_editor = require_role_at_least(Role.ADMIN)
async def _service(session: AsyncSession = Depends(get_session)) -> MemoryService:
return build_memory_service(session)
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class MemoryOut(BaseModel):
id: str
kind: str
kind_label: str
key: str | None = None
content: str
source: str
source_label: str
evidence_task_id: str | None = None
pinned: bool
confidence: float
expires_at: str | None = None
hits: int
last_used_at: str | None = None
created_by_user_id: str | None = None
created_at: str | None = None
updated_at: str | None = None
class MemoryListOut(BaseModel):
organization_id: str
items: list[MemoryOut]
total: int
pinned: int
chars: int
class MemoryCreateIn(BaseModel):
kind: str = Field(default=str(MemoryKind.RULE), max_length=16)
content: str = Field(min_length=2, max_length=1500)
key: str | None = Field(default=None, max_length=80)
class MemoryUpdateIn(BaseModel):
content: str = Field(min_length=2, max_length=1500)
key: str | None = Field(default=None, max_length=80)
class MemoryPinIn(BaseModel):
pinned: bool = True
def _out(memory: Memory) -> MemoryOut:
return MemoryOut(
id=memory.id,
kind=str(memory.kind),
kind_label=KIND_LABELS.get(str(memory.kind), "یادداشت"),
key=memory.key,
content=memory.content,
source=str(memory.source),
source_label=SOURCE_LABELS.get(str(memory.source), ""),
evidence_task_id=memory.evidence_task_id,
pinned=memory.pinned,
confidence=memory.confidence,
expires_at=memory.expires_at.isoformat() if memory.expires_at else None,
hits=memory.hits,
last_used_at=memory.last_used_at.isoformat() if memory.last_used_at else None,
created_by_user_id=memory.created_by_user_id,
created_at=memory.created_at.isoformat() if memory.created_at else None,
updated_at=memory.updated_at.isoformat() if memory.updated_at else None,
)
async def _audit(
session: AsyncSession,
request: Request,
*,
action: str,
context: OrgContext,
target_id: str,
metadata: dict | None = None,
) -> None:
ip, ua = client_meta(request)
await record_audit(
session,
action=action,
organization_id=context.org_id,
actor_user_id=context.user.id,
target_type="memory",
target_id=target_id,
metadata=metadata or {},
ip=ip,
user_agent=ua,
)
async def _announce(context: OrgContext, memory: Memory) -> None:
"""Tell the tenant's open tabs that memory changed.
``memory.changed`` is published on the org channel and nowhere else: a
memory belongs to one organization, and the realtime authorisation for
``org:<id>`` is membership — exactly the same rule the endpoint enforced.
A failed publish is a lost refresh, never a failed write.
"""
await publish(
RealtimeEvent(
type="memory.changed",
channels=(org_channel(context.org_id),),
organization_id=context.org_id,
data={
"memory_id": memory.id,
"kind": str(memory.kind),
"pinned": memory.pinned,
},
)
)
# ---------------------------------------------------------------------------
# Read
# ---------------------------------------------------------------------------
@router.get("/organizations/{org_id}/memory", response_model=MemoryListOut)
async def list_memory(
kind: str | None = Query(default=None, max_length=16),
q: str | None = Query(default="", max_length=120),
context: OrgContext = Depends(get_org_context),
service: MemoryService = Depends(_service),
) -> MemoryListOut:
"""The tab "قوانین و ترجیحات" and friends; also the source list for search.
Filtering by ``kind`` is a convenience, not a security boundary: an
organization's whole memory is visible to any member by design.
"""
items = await service.list(organization_id=context.org_id, kind=kind, search=q)
stats = await service.stats(context.org_id)
return MemoryListOut(
organization_id=context.org_id,
items=[_out(m) for m in items],
total=stats["total"],
pinned=stats["pinned"],
chars=stats["chars"],
)
# ---------------------------------------------------------------------------
# Teach
# ---------------------------------------------------------------------------
@router.post(
"/organizations/{org_id}/memory",
response_model=MemoryOut,
status_code=status.HTTP_201_CREATED,
)
async def teach_memory(
payload: MemoryCreateIn,
request: Request,
context: OrgContext = Depends(_teacher),
service: MemoryService = Depends(_service),
session: AsyncSession = Depends(get_session),
) -> MemoryOut:
""""به سازمان یاد بده": a rule or a preference, stored redacted."""
memory = await service.teach(
organization_id=context.org_id,
user_id=context.user.id,
role=context.membership.role,
kind=payload.kind,
content=payload.content,
key=payload.key,
)
await _audit(
session,
request,
action=MEMORY_CREATE,
context=context,
target_id=memory.id,
metadata={"kind": str(memory.kind), "key": memory.key},
)
await _announce(context, memory)
return _out(memory)
@router.patch("/organizations/{org_id}/memory/{memory_id}", response_model=MemoryOut)
async def edit_memory(
memory_id: str,
payload: MemoryUpdateIn,
request: Request,
context: OrgContext = Depends(_editor),
service: MemoryService = Depends(_service),
session: AsyncSession = Depends(get_session),
) -> MemoryOut:
memory = await service.edit(
organization_id=context.org_id,
memory_id=memory_id,
content=payload.content,
key=payload.key,
)
await _audit(session, request, action=MEMORY_UPDATE, context=context, target_id=memory_id)
await _announce(context, memory)
return _out(memory)
@router.post("/organizations/{org_id}/memory/{memory_id}/pin", response_model=MemoryOut)
async def pin_memory(
memory_id: str,
payload: MemoryPinIn,
request: Request,
context: OrgContext = Depends(_editor),
service: MemoryService = Depends(_service),
session: AsyncSession = Depends(get_session),
) -> MemoryOut:
"""Pin → always in context and never evicted; unpin → back to search."""
memory = await service.set_pinned(
organization_id=context.org_id, memory_id=memory_id, pinned=payload.pinned
)
await _audit(
session,
request,
action=MEMORY_PIN,
context=context,
target_id=memory_id,
metadata={"pinned": payload.pinned},
)
await _announce(context, memory)
return _out(memory)
@router.post("/organizations/{org_id}/memory/{memory_id}/confirm", response_model=MemoryOut)
async def confirm_memory(
memory_id: str,
request: Request,
context: OrgContext = Depends(_editor),
service: MemoryService = Depends(_service),
session: AsyncSession = Depends(get_session),
) -> MemoryOut:
""""درس تأیید شد": the lesson becomes settled knowledge and stops expiring."""
memory = await service.confirm(organization_id=context.org_id, memory_id=memory_id)
await _audit(
session, request, action=MEMORY_CONFIRM, context=context, target_id=memory_id
)
await _announce(context, memory)
return _out(memory)
@router.delete("/organizations/{org_id}/memory/{memory_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_memory(
memory_id: str,
request: Request,
context: OrgContext = Depends(_editor),
service: MemoryService = Depends(_service),
session: AsyncSession = Depends(get_session),
) -> None:
await service.delete(organization_id=context.org_id, memory_id=memory_id)
await _audit(
session, request, action=MEMORY_DELETE, context=context, target_id=memory_id
)
await publish(
RealtimeEvent(
type="memory.changed",
channels=(org_channel(context.org_id),),
organization_id=context.org_id,
data={"memory_id": memory_id, "deleted": True},
)
)
+21 -1
View File
@@ -1 +1,21 @@
"""MCP gateway (owned by Agent 1)."""
"""The MCP gateway package (owned by Agent 1, PLAN-v2 §6).
``gateway`` holds the tools and their rules; ``server`` binds them to the
official SDK. ``python -m app.interfaces.mcp.server`` starts the gateway.
"""
from app.interfaces.mcp.gateway import (
TOOL_SPECS,
McpCallResult,
McpError,
McpGateway,
build_gateway,
)
__all__ = [
"TOOL_SPECS",
"McpCallResult",
"McpError",
"McpGateway",
"build_gateway",
]
+619
View File
@@ -0,0 +1,619 @@
"""The MCP tools themselves — framework-free, so they can be called directly.
``app.interfaces.mcp.server`` adapts these to the official SDK; this module
holds the actual work and is what the tests exercise. Four rules are enforced
here, once, for every tool:
1. **The organization comes from the token, never from the arguments.** A tool
body receives an authenticated ``TokenIdentity`` and uses its
``organization_id``; an ``organization_id`` in the arguments is ignored, so an
agent cannot ask about a tenant it was not issued for.
2. **A scope is checked before the work starts.** Not after.
3. **Every call is audited** (``mcp.tool_call`` with the token prefix, the tool,
and whether it succeeded), rate-limited, and its output redacted.
4. **An error is an answer, not a crash**: the agent gets a plain message, and
the exception never reaches the transport.
"""
from __future__ import annotations
import json
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from app.application.container_org import (
build_memory_service,
build_model_grant_service,
)
from app.application.services.mcp_service import McpService, TokenIdentity
from app.core.config import settings
from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError
from app.core.redact import redact
from app.domain.memory.models import MemoryKind
from app.infrastructure.error_log import record_error
@dataclass(frozen=True, slots=True)
class ToolSpec:
"""One tool: its name, the scope it needs, and its JSON schema."""
name: str
scope: str
description: str
parameters: dict[str, Any]
TOOL_SPECS: tuple[ToolSpec, ...] = (
ToolSpec(
name="app_get_profile",
scope="app:read",
description="پروفایل اپلیکیشن این سازمان: نام، نوع، زبان، فریم‌ورک، "
"دستورهای ساخت و تست، پورت و مسیر سلامت.",
parameters={
"type": "object",
"properties": {},
"additionalProperties": False,
},
),
ToolSpec(
name="tasks_list",
scope="tasks:read",
description="فهرست کارهای این سازمان، به ترتیب جدیدترین.",
parameters={
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "وضعیت اختیاری برای فیلتر (new، running، done، …)",
},
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 25},
},
"additionalProperties": False,
},
),
ToolSpec(
name="tasks_get",
scope="tasks:read",
description="یک کار بر اساس شناسهٔ آن، در همین سازمان.",
parameters={
"type": "object",
"properties": {
"task_id": {"type": "string", "description": "شناسهٔ کار"},
},
"required": ["task_id"],
"additionalProperties": False,
},
),
ToolSpec(
name="tasks_create",
scope="tasks:write",
description="ثبت یک کار تازه برای مدیر سازمان؛ مدیر خودش آن را تقسیم "
"و به کارمندان می‌سپارد.",
parameters={
"type": "object",
"properties": {
"title": {"type": "string", "description": "عنوان کوتاه کار"},
"description": {"type": "string", "description": "شرح کامل درخواست"},
},
"required": ["title"],
"additionalProperties": False,
},
),
ToolSpec(
name="memory_search",
scope="memory:read",
description="جست‌وجو در حافظهٔ سازمان: قوانین، ترجیحات، درس‌ها و نقشهٔ کد.",
parameters={
"type": "object",
"properties": {
"query": {"type": "string", "description": "واژه‌های جست‌وجو"},
"kind": {
"type": "string",
"description": "نوع اختیاری: rule، preference، lesson، fact، code_map",
},
"limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 10},
},
"additionalProperties": False,
},
),
ToolSpec(
name="memory_write",
scope="memory:write",
description="یادآوری یک واقعیت یا ترجیح به سازمان. گذرواژه و کلید "
"هرگز ذخیره نمی‌شود.",
parameters={
"type": "object",
"properties": {
"kind": {
"type": "string",
"enum": ["fact", "preference"],
"description": "فقط «fact» یا «preference»",
},
"content": {"type": "string", "description": "یک جملهٔ کوتاه"},
},
"required": ["kind", "content"],
"additionalProperties": False,
},
),
ToolSpec(
name="knowledge_search",
scope="memory:read",
description="جست‌وجو در منابع دانش سازمان (اسناد بلند).",
parameters={
"type": "object",
"properties": {
"query": {"type": "string", "description": "واژه‌های جست‌وجو"},
"limit": {"type": "integer", "minimum": 1, "maximum": 20, "default": 5},
},
"required": ["query"],
"additionalProperties": False,
},
),
ToolSpec(
name="pipeline_status",
scope="delivery:read",
description="وضعیت خط تحویل (استقرار و دمو). در این نسخه در دسترس نیست.",
parameters={
"type": "object",
"properties": {},
"additionalProperties": False,
},
),
)
#: The only kinds an external agent may write. A rule is the organization's own
#: to set through the UI, and a lesson is the engine's to learn.
MCP_WRITABLE_KINDS = frozenset({MemoryKind.FACT, MemoryKind.PREFERENCE})
class McpError(Exception):
"""A refusal the agent can read. Never carries a secret."""
def __init__(self, message: str, *, code: str = "mcp_error") -> None:
super().__init__(message)
self.code = code
@dataclass(slots=True)
class McpCallResult:
"""What one tool call produced: a redacted payload, or a readable refusal."""
ok: bool
payload: dict[str, Any]
error: str | None = None
code: str = "mcp_error"
def as_dict(self) -> dict[str, Any]:
if self.ok:
return self.payload
return {
"error": {"code": self.code, "message": self.error or "عملیات ناموفق بود."}
}
def to_json(self) -> str:
"""The JSON text an agent receives. Always redacted, always short."""
return json.dumps(redact(self.as_dict()), ensure_ascii=False, default=str)
class McpGateway:
"""Executes the tools for one authenticated token.
``session_factory`` is injectable so the tests can point the gateway at their
in-memory database; production passes nothing and gets the application's own
factory, exactly like the workers.
"""
def __init__(self, *, session_factory=None, rate_limiter=None) -> None:
from app.application.services.mcp_service import TokenRateLimiter
self._session_factory = session_factory
self._limiter = rate_limiter or TokenRateLimiter()
# -- public API ----------------------------------------------------------
async def call(
self,
*,
tool: str,
arguments: dict[str, Any] | None = None,
token: str,
identity: TokenIdentity | None = None,
) -> McpCallResult:
"""Run one tool for one token. Every failure mode is an answer."""
spec = _SPECS_BY_NAME.get(tool)
if spec is None:
return McpCallResult(ok=False, payload={}, error=f"ابزار ناشناخته: {tool}")
args = arguments or {}
if not settings.mcp_gateway_enabled:
# The owner's kill switch. Nothing is attributable yet, so this lands
# in the error ledger rather than in one organization's audit trail.
await _note("mcp.gateway_disabled", f"tool={spec.name}")
return self._fail(
"دسترسی MCP از سوی مالک سامانه غیرفعال شده است.",
code="mcp_disabled",
)
factory = self._session_factory or _default_factory()
async with factory() as session:
service = McpService(session)
identity = identity or await service.verify(token)
if identity is None:
# A token that does not exist, was revoked, or expired is one
# answer — and it is unattributable, so it goes to the error
# ledger where a human can see the pattern.
await _note("mcp.invalid_token", f"tool={spec.name}")
return self._fail(
"توکن نامعتبر یا منقضی است.", code="invalid_token"
)
async def refuse(message: str, code: str) -> McpCallResult:
"""Every refusal from here on is audited against this organization.
The audit row is committed inside the refusal: a denial nobody can
see afterwards is not an audit trail, it is a hope.
"""
await self._audit(
session, identity=identity, spec=spec, ok=False, error=message
)
await session.commit()
return McpCallResult(ok=False, payload={}, error=message, code=code)
if not identity.allows(spec.scope):
return await refuse(
"این توکن به این ابزار دسترسی ندارد.", "scope_denied"
)
policy = await build_model_grant_service(session).get_org_policy(
identity.organization_id
)
if not policy.mcp_enabled:
return await refuse(
"دسترسی MCP این سازمان غیرفعال است.", "org_disabled"
)
if not self._limiter.allow(identity.token_id):
return await refuse(
"تعداد درخواست‌ها بیش از حد مجاز است؛ کمی بعد تلاش کنید.",
"rate_limited",
)
handler = _HANDLERS[spec.name]
try:
payload = await handler(self, session=session, identity=identity, **args)
except (NotFoundError, PermissionDeniedError, ValidationError, McpError) as exc:
message = str(getattr(exc, "message", exc))
await self._audit(
session, identity=identity, spec=spec, ok=False, error=message
)
await session.commit()
return McpCallResult(ok=False, payload={}, error=message, code="tool_failed")
except TypeError:
# An argument shape the tool did not expect: the agent's fault,
# not the platform's.
return await refuse(
"آرگومان‌های ابزار نامعتبر است.", "bad_arguments"
)
except Exception as exc: # noqa: BLE001 - an outage is an answer too
# Anything else is infrastructure, and its message may contain a
# DSN or a heap address. The agent gets a plain sentence; the
# operator gets the real error in the error console.
await record_error(
kind="mcp.tool_crashed",
message=str(exc),
exc=exc,
organization_id=identity.organization_id,
metadata={"tool": spec.name, "prefix": identity.prefix},
)
await self._audit(
session, identity=identity, spec=spec, ok=False, error="internal"
)
await session.commit()
return self._fail(
"خطای داخلی سامانه؛ این خطا ثبت شد.", code="internal_error"
)
await service.touch(identity.token_id)
await self._audit(session, identity=identity, spec=spec, ok=True)
await session.commit()
return McpCallResult(ok=True, payload=payload)
# -- helpers -------------------------------------------------------------
@staticmethod
def _fail(message: str, *, code: str) -> McpCallResult:
"""A refusal is a normal answer: the agent gets a message it can act on."""
return McpCallResult(ok=False, payload={}, error=message, code=code)
async def _audit(
self,
session,
*,
identity: TokenIdentity,
spec: ToolSpec,
ok: bool,
error: str = "",
) -> None:
"""One audit row per call. Never allowed to fail the call itself."""
from app.application.services.audit_service import record_audit
try:
await record_audit(
session,
action="mcp.tool_call",
organization_id=identity.organization_id,
actor_user_id=None,
target_type="mcp_tool",
target_id=spec.name,
metadata={
"prefix": identity.prefix,
"scope": spec.scope,
"ok": ok,
"error": error[:200],
},
ip="mcp",
user_agent=f"mcp/{identity.prefix}",
)
except Exception: # noqa: BLE001 - auditing must not fail a tool call
return
# ---------------------------------------------------------------------------
# Tool bodies
# ---------------------------------------------------------------------------
async def _app_profile(
gateway: McpGateway, *, session, identity: TokenIdentity, **_ignored: Any
) -> dict[str, Any]:
from app.infrastructure.repositories.app_profile import AppProfileRepository
from app.infrastructure.repositories.projects import ProjectRepository
profile = await AppProfileRepository(session).get_for_org(identity.organization_id)
if profile is None:
raise NotFoundError("اپلیکیشنی برای این سازمان یافت نشد.")
project = await ProjectRepository(session).get(profile.project_id)
return {
"project_name": project.name if project else "",
"project_slug": project.slug if project else "",
"app_type": profile.app_type,
"language": profile.language,
"framework": profile.framework,
"runtime_version": profile.runtime_version,
"package_manager": profile.package_manager,
"setup_command": profile.setup_command,
"build_command": profile.build_command,
"test_command": profile.test_command,
"start_command": profile.start_command,
"app_port": profile.app_port,
"healthcheck_path": profile.healthcheck_path,
"default_branch": profile.default_branch,
"git_source": profile.git_source,
"repo_url": profile.external_repo_url,
}
async def _tasks_list(
gateway: McpGateway,
*,
session,
identity: TokenIdentity,
status: str | None = None,
limit: int = 25,
**_ignored: Any,
) -> dict[str, Any]:
from app.application.container import build_workforce_service
from app.domain.workforce.models import TaskStatus
service = build_workforce_service(session)
parsed: TaskStatus | None = None
if status:
try:
parsed = TaskStatus(str(status).strip().lower())
except ValueError as exc:
raise ValidationError("وضعیت کار نامعتبر است.") from exc
tasks = await service.list_tasks(
identity.organization_id, status=parsed, limit=max(1, min(int(limit), 100))
)
return {
"tasks": [
{
"id": task.id,
"title": task.title,
"status": str(task.status),
"created_at": task.created_at.isoformat() if task.created_at else None,
"assigned_employee_id": task.assigned_employee_id,
}
for task in tasks
]
}
async def _tasks_get(
gateway: McpGateway, *, session, identity: TokenIdentity, task_id: str, **_ignored: Any
) -> dict[str, Any]:
from app.application.container import build_workforce_service
if not task_id:
raise ValidationError("شناسهٔ کار لازم است.")
service = build_workforce_service(session)
task = await service.get_task_or_404(
organization_id=identity.organization_id, task_id=task_id
)
return {
"task": {
"id": task.id,
"title": task.title,
"description": task.description,
"status": str(task.status),
"created_at": task.created_at.isoformat() if task.created_at else None,
"project_id": task.project_id,
}
}
async def _tasks_create(
gateway: McpGateway,
*,
session,
identity: TokenIdentity,
title: str,
description: str = "",
**_ignored: Any,
) -> dict[str, Any]:
"""A user order to the manager: the current public signature, untouched."""
from app.application.container import build_workforce_service
if not title or not title.strip():
raise ValidationError("عنوان کار لازم است.")
creator = await McpService(session).creator_of(identity.token_id)
service = build_workforce_service(session)
task = await service.create_task(
organization_id=identity.organization_id,
created_by_user_id=creator or "",
title=title,
description=description,
)
return {"task": {"id": task.id, "title": task.title, "status": str(task.status)}}
async def _memory_search(
gateway: McpGateway,
*,
session,
identity: TokenIdentity,
query: str = "",
kind: str | None = None,
limit: int = 10,
**_ignored: Any,
) -> dict[str, Any]:
service = build_memory_service(session)
items = await service.list(
identity.organization_id, kind=kind, search=query or None
)
return {
"memories": [
{
"id": memory.id,
"kind": str(memory.kind),
"key": memory.key,
"content": memory.content,
"pinned": memory.pinned,
"confidence": memory.confidence,
}
for memory in items[: max(1, min(int(limit), 50))]
]
}
async def _memory_write(
gateway: McpGateway,
*,
session,
identity: TokenIdentity,
kind: str,
content: str,
**_ignored: Any,
) -> dict[str, Any]:
from app.domain.memory.models import coerce_kind
entry_kind = coerce_kind(kind)
if entry_kind not in MCP_WRITABLE_KINDS:
raise McpError("با توکن فقط «fact» یا «preference» قابل نوشتن است.")
service = build_memory_service(session)
memory_id = await service.record(
organization_id=identity.organization_id,
kind=str(entry_kind),
content=content,
source="user",
)
if memory_id is None: # pragma: no cover - the task cap cannot apply here
raise McpError("یادداشت ذخیره نشد.")
return {"memory": {"id": memory_id, "kind": str(entry_kind)}}
async def _knowledge_search(
gateway: McpGateway,
*,
session,
identity: TokenIdentity,
query: str,
limit: int = 5,
**_ignored: Any,
) -> dict[str, Any]:
from app.application.container import build_knowledge_service
if not query:
raise ValidationError("عبارت جست‌وجو لازم است.")
service = build_knowledge_service(session)
hits = await service.search(
organization_id=identity.organization_id,
query=query,
limit=max(1, min(int(limit), 20)),
)
return {
"results": [
{
"content": hit.get("content", ""),
"source_name": hit.get("source_name", ""),
"document_title": hit.get("document_title", ""),
}
for hit in hits
]
}
async def _pipeline_status(
gateway: McpGateway, *, session, identity: TokenIdentity, **_ignored: Any
) -> dict[str, Any]:
"""Delivery is another workstream's table; the tool exists and says so."""
return {"available": False, "message": "خط تحویل در این نسخه در دسترس نیست."}
_HANDLERS: dict[str, Callable[..., Awaitable[dict[str, Any]]]] = {
"app_get_profile": _app_profile,
"tasks_list": _tasks_list,
"tasks_get": _tasks_get,
"tasks_create": _tasks_create,
"memory_search": _memory_search,
"memory_write": _memory_write,
"knowledge_search": _knowledge_search,
"pipeline_status": _pipeline_status,
}
_SPECS_BY_NAME: dict[str, ToolSpec] = {spec.name: spec for spec in TOOL_SPECS}
def _default_factory():
from app.core.database import SessionFactory
return SessionFactory
async def _note(kind: str, detail: str) -> None:
"""Record an unattributable attempt in the error ledger, best-effort.
A bad token and a disabled gateway happen before the caller is known, so they
belong to nobody's audit trail — but they are exactly the patterns an operator
needs to see, which is what the error console is for.
"""
from app.infrastructure.error_log import record_error
try:
await record_error(kind=kind, message=detail, source="mcp")
except Exception: # noqa: BLE001 - telemetry must never fail a call
return
def build_gateway(*, session_factory=None) -> McpGateway:
"""The gateway the server and the tests both use.
Each gateway owns its own in-memory rate limiter: one process, one budget.
"""
return McpGateway(session_factory=session_factory)
__all__ = [
"MCP_WRITABLE_KINDS",
"TOOL_SPECS",
"McpCallResult",
"McpError",
"McpGateway",
"build_gateway",
]
+170 -6
View File
@@ -1,14 +1,178 @@
"""MCP gateway entry point (owned by Agent 1). Foundation stub."""
"""The MCP gateway server: the official SDK, bound to our tools.
This module is the *adapter*, not the logic. The tools live in
``app.interfaces.mcp.gateway`` (framework-free and directly testable); what
happens here is transport only:
* a ``MCPServer`` (streamable HTTP) with the eight tools registered, each with
the argument schema the gateway validates;
* every call resolves the caller's token from the ``Authorization: Bearer``
header — never from the arguments — and hands it to the gateway;
* a refusal raises ``ToolError``, which the transport reports as an ``isError``
tool result: an agent sees "this token does not have that scope" instead of a
stack trace;
* ``main()`` binds ``MCP_GATEWAY_HOST:MCP_GATEWAY_PORT`` — what ``pnpm mcp:dev``
runs. Putting Caddy in front of it is the owner's job (see
``services/mcp-gateway/README.md``).
"""
from __future__ import annotations
from typing import Any
def main() -> None:
raise SystemExit(
"MCP gateway is not implemented yet "
"(see docs/plan/TODO-agent-1-org-platform.md, Phase 5)"
from mcp.server.mcpserver.context import Context
from mcp.server.mcpserver.exceptions import ToolError
from app.core.config import settings
from app.core.redact import redact
from app.interfaces.mcp.gateway import McpCallResult, McpGateway, build_gateway
#: How a client authenticates. One header, one token, no query parameter: a
#: token in a URL ends up in proxy logs and browser history.
BEARER_PREFIX = "bearer "
def _bearer_token(ctx: Any) -> str:
"""The bearer token of this request, or an empty string.
``ctx.headers`` raises when there is no request at all (a stdio transport, or
a direct call in a test), so the read is defensive by design: no request
means no token, which means no tool runs.
"""
try:
headers = getattr(ctx, "headers", None) if ctx is not None else None
except ValueError: # pragma: no cover - "Context not available"
return ""
if not headers:
return ""
raw = headers.get("authorization") or headers.get("Authorization") or ""
if isinstance(raw, bytes): # pragma: no cover - transports hand us str
raw = raw.decode("utf-8", "ignore")
if raw.lower().startswith(BEARER_PREFIX):
return raw[len(BEARER_PREFIX) :].strip()
return raw.strip()
def _build_server(gateway: McpGateway) -> Any:
"""Build the SDK server with our eight tools registered."""
from mcp.server.mcpserver import MCPServer
server: Any = MCPServer(
name="smart-org-gateway",
version="1.0.0",
instructions=(
"دسترسی به یک سازمان در سامانه سازمان هوشمند. سازمان از توکن خوانده "
"می‌شود، نه از آرگومان‌ها؛ توکنی که محدودهٔ لازم را نداشته باشد رد "
"می‌شود."
),
)
async def _dispatch(tool: str, ctx: Any, **arguments: Any) -> dict[str, Any]:
result: McpCallResult = await gateway.call(
tool=tool, arguments=arguments, token=_bearer_token(ctx)
)
if not result.ok:
raise ToolError(result.error or "عملیات ناموفق بود.")
# Redacted on the way out as well as on the way in: a tool may return a
# stored value that predates a redaction rule, and the agent is outside
# the platform's trust boundary.
payload: dict[str, Any] = redact(result.payload)
return payload
async def app_get_profile(ctx: Context | None = None) -> dict[str, Any]:
"""پروفایل اپلیکیشن سازمان: نام، نوع، زبان، فریم‌ورک، دستورها، پورت."""
return await _dispatch("app_get_profile", ctx)
async def tasks_list(
status: str | None = None, limit: int = 25, ctx: Context | None = None
) -> dict[str, Any]:
"""فهرست کارهای سازمان، به ترتیب جدیدترین."""
return await _dispatch("tasks_list", ctx, status=status, limit=limit)
async def tasks_get(task_id: str, ctx: Context | None = None) -> dict[str, Any]:
"""یک کار بر اساس شناسهٔ آن."""
return await _dispatch("tasks_get", ctx, task_id=task_id)
async def tasks_create(
title: str, description: str = "", ctx: Context | None = None
) -> dict[str, Any]:
"""ثبت یک کار تازه برای مدیر سازمان."""
return await _dispatch("tasks_create", ctx, title=title, description=description)
async def memory_search(
query: str = "", kind: str | None = None, limit: int = 10,
ctx: Context | None = None,
) -> dict[str, Any]:
"""جست‌وجو در حافظهٔ سازمان: قوانین، ترجیحات، درس‌ها و نقشهٔ کد."""
return await _dispatch(
"memory_search", ctx, query=query, kind=kind, limit=limit
)
async def memory_write(
kind: str, content: str, ctx: Context | None = None
) -> dict[str, Any]:
"""یادآوری یک واقعیت یا ترجیح به سازمان."""
return await _dispatch("memory_write", ctx, kind=kind, content=content)
async def knowledge_search(
query: str, limit: int = 5, ctx: Context | None = None
) -> dict[str, Any]:
"""جست‌وجو در منابع دانش سازمان."""
return await _dispatch("knowledge_search", ctx, query=query, limit=limit)
async def pipeline_status(ctx: Context | None = None) -> dict[str, Any]:
"""وضعیت خط تحویل (استقرار و دمو)."""
return await _dispatch("pipeline_status", ctx)
for tool in (
app_get_profile,
tasks_list,
tasks_get,
tasks_create,
memory_search,
memory_write,
knowledge_search,
pipeline_status,
):
server.add_tool(
tool, name=tool.__name__, description=(tool.__doc__ or "").strip()
)
return server
def build_app(gateway: McpGateway | None = None) -> Any:
"""The ASGI app (streamable HTTP) for a uvicorn-style deployment."""
server = _build_server(gateway or build_gateway())
return server.streamable_http_app(
streamable_http_path="/mcp", host=settings.mcp_gateway_host
)
if __name__ == "__main__":
def main(argv: list[str] | None = None) -> None:
"""Run the gateway: ``python -m app.interfaces.mcp.server``."""
import sys
import uvicorn
host, port = settings.mcp_gateway_host, settings.mcp_gateway_port
if not settings.mcp_gateway_enabled:
# Starting anyway is right: the switch is the owner's, and the process
# answering "disabled" is more diagnosable than a missing port.
print(
"mcp gateway: MCP_GATEWAY_ENABLED=false — every call will be refused",
file=sys.stderr,
flush=True,
)
print(f"mcp gateway: http://{host}:{port}/mcp", file=sys.stderr, flush=True)
uvicorn.run(
_build_server(build_gateway()).streamable_http_app(
streamable_http_path="/mcp", host=host
),
host=host,
port=port,
log_level="info",
)
if __name__ == "__main__": # pragma: no cover - the entry point
main()
+32 -4
View File
@@ -6,6 +6,7 @@ import pytest
from sqlalchemy.exc import IntegrityError
import app.application.container as container
import app.application.container_org as container_org
from app.application.ports.delivery_hooks import DeliveryHooks, NullDeliveryHooks
from app.application.ports.deploy_target import (
DeployTargetSpec,
@@ -109,7 +110,16 @@ async def test_app_profile_repository_is_tenant_scoped(session_factory):
@pytest.mark.asyncio
async def test_default_model_grants_return_active_models_with_tiers(session_factory):
async def test_granted_models_carry_tiers_and_need_an_owner_grant(session_factory):
"""The real grant service (Phase 3) replaced the "all active models" stub.
The foundation version asserted the placeholder wiring (every active model
usable by every organization). The behaviour the plan changes on purpose is
exactly that: an organization now sees a model only after the owner granted
it. The assertions below are strictly stronger — they still pin the tier
labels, and additionally pin the two switches (the model must be active
*and* granted) plus the per-organization scope.
"""
async with session_factory() as session:
provider = AiProviderRow(id=new_id(), name="agy-test")
session.add(provider)
@@ -135,10 +145,28 @@ async def test_default_model_grants_return_active_models_with_tiers(session_fact
grants = container.build_model_grants(session)
assert isinstance(grants, ModelGrantPort)
granted = {g.ai_model_id: g.tier for g in await grants.granted_models(organization_id="o")}
org_id, _ = await _org_with_user(session, "granted")
other_id, _ = await _org_with_user(session, "untouched")
# Nothing is usable until the owner grants it.
assert await grants.granted_models(organization_id=org_id) == []
assert not await grants.is_granted(organization_id=org_id, ai_model_id="m-fast")
# ``container.py`` is locked and re-exports only ``build_model_grants``;
# the typed builder lives in the workstream's own container.
service = container_org.build_model_grant_service(session)
await service.grant_model(organization_id=org_id, ai_model_id="m-strong")
await service.grant_model(organization_id=org_id, ai_model_id="m-fast")
granted = {
g.ai_model_id: g.tier
for g in await grants.granted_models(organization_id=org_id)
}
assert granted == {"m-strong": "strong", "m-fast": "fast"}
assert await grants.is_granted(organization_id="o", ai_model_id="m-fast")
assert not await grants.is_granted(organization_id="o", ai_model_id="m-off")
assert await grants.is_granted(organization_id=org_id, ai_model_id="m-fast")
# Inactive in the catalogue: never granted, whatever the grant table says.
assert not await grants.is_granted(organization_id=org_id, ai_model_id="m-off")
# Another organization is unaffected by this one's grants.
assert await grants.granted_models(organization_id=other_id) == []
def test_tier_for_never_uses_names():
+532
View File
@@ -0,0 +1,532 @@
"""MCP gateway: scoped tokens, tool calls, and what they may never do.
The invariants under test:
* a token from organization A can never read organization B — not by asking, not
by passing an ``organization_id``, not by guessing an id;
* a scope is a floor: a token minted ``tasks:read`` cannot create a task, and a
token minted ``memory:write`` cannot write a *rule* (only ``fact`` and
``preference``);
* a revoked or expired token is indistinguishable from an unknown one;
* the rate limit refuses the 61st call in a minute;
* every call leaves an audit row carrying the token prefix and the tool;
* no secret appears in any tool output — the reflection redacts, and a token
never is part of a payload.
The tests drive the gateway directly (``McpGateway.call``), which is the layer
the SDK server is a thin adapter around: the transport is the SDK's problem, the
rules are ours.
"""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from app.application.services.mcp_service import (
PREFIX_DISPLAY_CHARS,
TOKEN_PREFIX,
McpService,
TokenRateLimiter,
)
from app.core.config import settings
from app.core.errors import ValidationError
from app.domain.base import new_id, utcnow
from app.domain.identity.models import PlatformRole, Role
from app.infrastructure.db.models import (
AuditEventRow,
MembershipRow,
OrganizationRow,
UserRow,
)
from app.infrastructure.db.models_org import McpTokenRow
from app.interfaces.mcp.gateway import McpGateway
from tests.conftest import auth_header, signup
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _org(session_factory, slug: str, role: Role = Role.OWNER) -> tuple[str, str]:
async with session_factory() as session:
user = UserRow(
id=new_id(),
email=f"{slug}@example.com",
password_hash="x",
name=slug,
platform_role=PlatformRole.MANAGER.value,
)
session.add(user)
await session.flush()
org = OrganizationRow(id=new_id(), name=slug, slug=slug, owner_user_id=user.id)
session.add(org)
await session.flush()
session.add(
MembershipRow(organization_id=org.id, user_id=user.id, role=role.value)
)
await session.commit()
return org.id, user.id
async def _gateway(session_factory, *, limit: int | None = None) -> McpGateway:
return McpGateway(
session_factory=session_factory,
rate_limiter=TokenRateLimiter(limit_per_minute=limit),
)
async def _token(
session_factory, gateway: McpGateway, org_id: str, *, scopes: list[str],
user_id: str | None = None,
) -> str:
async with session_factory() as session:
issued = await McpService(session).issue(
organization_id=org_id, name="agent", scopes=scopes,
created_by_user_id=user_id,
)
await session.commit()
return issued.token
async def _call(gateway: McpGateway, token: str, tool: str, **arguments):
return await gateway.call(tool=tool, arguments=arguments, token=token)
# ---------------------------------------------------------------------------
# Token lifecycle
# ---------------------------------------------------------------------------
async def test_a_token_is_shown_once_and_stored_only_as_a_hash(session_factory):
org_id, user_id = await _org(session_factory, "issue")
async with session_factory() as session:
issued = await McpService(session).issue(
organization_id=org_id,
name="agent-1",
scopes=["tasks:read", "tasks:write"],
created_by_user_id=user_id,
ttl_days=30,
)
await session.commit()
raw = issued.token
rows = (await session.execute(select(McpTokenRow))).scalars().all()
assert len(rows) == 1
# Only the hash and the display prefix exist; the token itself is gone.
assert all(raw not in (row.token_hash or "") for row in rows)
assert all(row.prefix == raw[: len(TOKEN_PREFIX) + PREFIX_DISPLAY_CHARS] for row in rows)
assert issued.record.scopes == ["tasks:read", "tasks:write"]
async def test_unknown_revoked_and_expired_are_all_the_same_answer(session_factory):
org_id, _ = await _org(session_factory, "verify")
gateway = await _gateway(session_factory)
raw = await _token(session_factory, gateway, org_id, scopes=["tasks:read"])
async with session_factory() as session:
service = McpService(session)
identity = await service.verify(raw)
assert identity is not None and identity.organization_id == org_id
# A token for another org id is not this org's business.
assert (await service.verify("smo_" + "x" * 24)) is None
row = (await session.execute(select(McpTokenRow))).scalar_one()
row.revoked_at = utcnow()
await session.commit()
assert await service.verify(raw) is None
row.revoked_at = None
from datetime import UTC, datetime, timedelta
row.expires_at = datetime.now(UTC) - timedelta(minutes=1)
await session.commit()
assert await service.verify(raw) is None
async def test_unknown_scopes_are_refused_at_mint_time(session_factory):
org_id, _ = await _org(session_factory, "scopes")
async with session_factory() as session:
with pytest.raises(ValidationError):
await McpService(session).issue(
organization_id=org_id, name="x", scopes=["root:everything"]
)
with pytest.raises(ValidationError):
await McpService(session).issue(organization_id=org_id, name="x", scopes=[])
# ---------------------------------------------------------------------------
# Tenant isolation and scope enforcement
# ---------------------------------------------------------------------------
async def test_one_organizations_token_cannot_read_anothers_app(session_factory, seeded):
org_a, _ = await _org(session_factory, "mcp-a")
org_b, user_b = await _org(session_factory, "mcp-b")
gateway = await _gateway(session_factory)
token_a = await _token(
session_factory, gateway, org_a, scopes=["app:read", "tasks:read"]
)
# org A has no project yet, so the honest answer is a refusal, not org B's
# data. The important part is what it is *not*: never org B's app.
result = await _call(gateway, token_a, "app_get_profile")
assert result.ok is False
# Even with the id handed to it, the tool refuses: identity comes from the token.
result = await _call(gateway, token_a, "tasks_list")
assert result.ok is True
assert result.payload == {"tasks": []}
# A token minted for B does not exist for A either.
other = await _token(session_factory, gateway, org_b, scopes=["app:read"], user_id=user_b)
assert (await _call(gateway, other, "app_get_profile")).ok is False
async def test_scope_enforcement_is_per_tool(session_factory, seeded):
org_id, user_id = await _org(session_factory, "scope-org")
gateway = await _gateway(session_factory)
reader = await _token(
session_factory, gateway, org_id, scopes=["tasks:read"], user_id=user_id
)
# Reading is allowed.
listed = await _call(gateway, reader, "tasks_list")
assert listed.ok is True
# Writing is not: the scope is missing, and the refusal names the reason.
created = await _call(gateway, reader, "tasks_create", title="کار تازه")
assert created.ok is False
assert created.code == "scope_denied"
assert "دسترسی" in (created.error or "")
# A refused write leaves nothing behind.
async with session_factory() as session:
from app.infrastructure.db.models import TaskRow
rows = (await session.execute(select(TaskRow))).scalars().all()
assert rows == []
async def test_memory_write_through_a_token_is_only_fact_or_preference(
session_factory, seeded
):
org_id, user_id = await _org(session_factory, "mem-write")
gateway = await _gateway(session_factory)
token = await _token(
session_factory, gateway, org_id, scopes=["memory:read", "memory:write"],
user_id=user_id,
)
ok = await _call(
gateway, token, "memory_write", kind="fact", content="پورت اپ ۸۰۰۰ است."
)
assert ok.ok is True
preference = await _call(
gateway, token, "memory_write", kind="preference", content="ترجیح: فارسی بنویس."
)
assert preference.ok is True
# A rule is not an agent's to set, and a lesson is the engine's to learn.
refused_rule = await _call(
gateway, token, "memory_write", kind="rule", content="همیشه تست بنویس."
)
assert refused_rule.ok is False
refused_lesson = await _call(
gateway, token, "memory_write", kind="lesson", content="درس تازه."
)
assert refused_lesson.ok is False
# What it wrote is searchable, and what it may not write is not there.
found = await _call(gateway, token, "memory_search", query="پورت")
assert found.ok is True
contents = [m["content"] for m in found.payload["memories"]]
assert any("۸۰۰۰" in content for content in contents)
assert not any("همیشه تست بنویس" in content for content in contents)
async def test_a_secret_written_through_a_token_never_comes_back(session_factory, seeded):
org_id, user_id = await _org(session_factory, "mem-secret")
gateway = await _gateway(session_factory)
token = await _token(
session_factory, gateway, org_id, scopes=["memory:read", "memory:write"],
user_id=user_id,
)
await _call(
gateway, token, "memory_write", kind="fact",
content="کلید CI استفاده‌شده ghp_ABCDEFGHIJKLMNOPQRSTUVWXyz123 است.",
)
found = await _call(gateway, token, "memory_search", query="کلید")
assert found.ok is True
text = found.to_json()
assert "ghp_ABCDEFGHIJKLMNOPQRSTUVWXyz123" not in text
async def test_knowledge_and_pipeline_tools(session_factory, seeded):
org_id, user_id = await _org(session_factory, "knowledge-org")
gateway = await _gateway(session_factory)
token = await _token(
session_factory, gateway, org_id, scopes=["memory:read", "delivery:read"],
user_id=user_id,
)
# No sources yet: an empty result, not an error.
empty = await _call(gateway, token, "knowledge_search", query="هرچی")
assert empty.ok is True
assert empty.payload["results"] == []
# Delivery is another workstream's table; the tool exists and says so.
status = await _call(gateway, token, "pipeline_status")
assert status.ok is True
assert status.payload["available"] is False
async def test_a_task_created_through_a_token_is_attributed_to_its_minter(
session_factory, seeded
):
org_id, user_id = await _org(session_factory, "task-org")
gateway = await _gateway(session_factory)
token = await _token(
session_factory, gateway, org_id, scopes=["tasks:write", "tasks:read"],
user_id=user_id,
)
created = await _call(
gateway, token, "tasks_create", title="پیاده‌سازی ورود", description="صفحهٔ ورود"
)
assert created.ok is True, created.error
task_id = created.payload["task"]["id"]
fetched = await _call(gateway, token, "tasks_get", task_id=task_id)
assert fetched.ok is True
assert fetched.payload["task"]["title"] == "پیاده‌سازی ورود"
# The write is attributed to the administrator who minted the token: a
# delegated key never erases who holds it.
async with session_factory() as session:
from app.infrastructure.db.models import TaskRow
row = await session.get(TaskRow, task_id)
assert row is not None
assert row.created_by_user_id == user_id
# …and a task from another organization is a 404, not data.
other_org, _ = await _org(session_factory, "task-other")
other_token = await _token(
session_factory, gateway, other_org, scopes=["tasks:read", "tasks:write"]
)
foreign = await _call(gateway, other_token, "tasks_get", task_id=task_id)
assert foreign.ok is False
async def test_a_bad_token_or_a_disabled_switch_is_refused_everywhere(session_factory, seeded):
org_id, user_id = await _org(session_factory, "off-switch")
gateway = await _gateway(session_factory)
token = await _token(
session_factory, gateway, org_id, scopes=["tasks:read"], user_id=user_id
)
assert (await _call(gateway, "smo_wrong", "tasks_list")).ok is False
assert (await _call(gateway, "", "tasks_list")).ok is False
assert (await _call(gateway, token, "no_such_tool")).ok is False
original = settings.mcp_gateway_enabled
settings.mcp_gateway_enabled = False
try:
result = await _call(gateway, token, "tasks_list")
assert result.ok is False
assert result.code == "mcp_disabled"
finally:
settings.mcp_gateway_enabled = original
# The per-organization switch is the owner's, not the token's.
async with session_factory() as session:
from app.application.container_org import build_model_grant_service
await build_model_grant_service(session).set_mcp_enabled(
organization_id=org_id, enabled=False, actor_user_id=user_id
)
await session.commit()
result = await _call(gateway, token, "tasks_list")
assert result.ok is False
assert result.code == "org_disabled"
async def test_the_rate_limit_refuses_the_sixty_first_call(session_factory, seeded):
org_id, user_id = await _org(session_factory, "rate")
gateway = await _gateway(session_factory, limit=5)
token = await _token(
session_factory, gateway, org_id, scopes=["tasks:read"], user_id=user_id
)
codes = [
(await _call(gateway, token, "tasks_list")).ok for _ in range(7)
]
assert codes == [True] * 5 + [False, False]
limited = await _call(gateway, token, "tasks_list")
assert limited.code == "rate_limited"
# ---------------------------------------------------------------------------
# Audit
# ---------------------------------------------------------------------------
async def test_every_call_leaves_an_audit_row_with_the_prefix(session_factory, seeded):
org_id, user_id = await _org(session_factory, "audit")
gateway = await _gateway(session_factory)
token = await _token(
session_factory, gateway, org_id, scopes=["tasks:read"], user_id=user_id
)
await _call(gateway, token, "tasks_list")
await _call(gateway, token, "tasks_create", title="رد شده")
async with session_factory() as session:
rows = (
(
await session.execute(
select(AuditEventRow).where(
AuditEventRow.organization_id == org_id,
AuditEventRow.action == "mcp.tool_call",
)
)
)
.scalars()
.all()
)
assert len(rows) == 2
by_tool = {row.target_id: row for row in rows}
assert set(by_tool) == {"tasks_list", "tasks_create"}
# The prefix identifies the token; the token itself never appears.
prefix = token[: len(TOKEN_PREFIX) + PREFIX_DISPLAY_CHARS]
assert all(row.metadata_json["prefix"] == prefix for row in rows)
assert all(token not in str(row.metadata_json) for row in rows)
assert by_tool["tasks_list"].metadata_json["ok"] is True
assert by_tool["tasks_create"].metadata_json["ok"] is False
assert by_tool["tasks_create"].metadata_json["error"]
# ---------------------------------------------------------------------------
# HTTP surface: tokens and the owner's switches
# ---------------------------------------------------------------------------
async def _owner_with_org(client: AsyncClient, slug: str) -> dict:
body = await signup(client, f"{slug}-owner@example.com", name="Owner")
org = await client.post(
"/api/organizations", json={"name": slug}, headers=auth_header(body["access_token"])
)
assert org.status_code == 201, org.text
return {"token": body["access_token"], "org_id": org.json()["id"]}
async def _member(client: AsyncClient, owner_token: str, org_id: str, email: str, role: str):
invite = await client.post(
f"/api/organizations/{org_id}/invites",
json={"email": email, "role": role},
headers=auth_header(owner_token),
)
assert invite.status_code == 201, invite.text
account = await signup(client, email, name=role)
accepted = await client.post(
"/api/invites/accept",
json={"token": invite.json()["invite_token"]},
headers=auth_header(account["access_token"]),
)
assert accepted.status_code == 200, accepted.text
return account["access_token"]
async def test_only_an_admin_mints_tokens_and_only_the_owner_switches_the_gateway(
client: AsyncClient,
manager_token: str,
platform_owner_token: str,
):
org = await _owner_with_org(client, "mcp-org")
viewer = await _member(client, org["token"], org["org_id"], "v@example.com", "viewer")
admin = await _member(client, org["token"], org["org_id"], "a@example.com", "admin")
# A viewer reads the list but cannot mint.
listing = await client.get(
f"/api/organizations/{org['org_id']}/mcp-tokens", headers=auth_header(viewer)
)
assert listing.status_code == 200, listing.text
assert listing.json() == []
refused = await client.post(
f"/api/organizations/{org['org_id']}/mcp-tokens",
json={"name": "nope", "scopes": ["tasks:read"]},
headers=auth_header(viewer),
)
assert refused.status_code == 403, refused.text
# The owner's switches are the owner's.
not_owner = await client.get("/api/admin/mcp", headers=auth_header(manager_token))
assert not_owner.status_code == 403
anonymous = await client.get("/api/admin/mcp")
assert anonymous.status_code == 401
# An admin mints one, sees the raw token once, and it never returns.
created = await client.post(
f"/api/organizations/{org['org_id']}/mcp-tokens",
json={"name": "agent-1", "scopes": ["tasks:read", "tasks:write"]},
headers=auth_header(admin),
)
assert created.status_code == 201, created.text
body = created.json()
assert body["token"].startswith("smo_")
assert body["prefix"] == body["token"][: len(TOKEN_PREFIX) + PREFIX_DISPLAY_CHARS]
assert body["scopes"] == ["tasks:read", "tasks:write"]
assert body["status"] == "active"
again = await client.get(
f"/api/organizations/{org['org_id']}/mcp-tokens", headers=auth_header(admin)
)
assert again.status_code == 200, again.text
assert "token" not in again.json()[0]
assert again.json()[0]["prefix"] == body["prefix"]
# An unknown scope is refused rather than silently dropped.
bad_scope = await client.post(
f"/api/organizations/{org['org_id']}/mcp-tokens",
json={"name": "bad", "scopes": ["root:all"]},
headers=auth_header(admin),
)
assert bad_scope.status_code == 422, bad_scope.text
# Revoke, then revoking again is a 404 — the same idempotence rule as grants.
revoked = await client.delete(
f"/api/organizations/{org['org_id']}/mcp-tokens/{body['id']}", headers=auth_header(admin)
)
assert revoked.status_code == 200, revoked.text
twice = await client.delete(
f"/api/organizations/{org['org_id']}/mcp-tokens/{body['id']}", headers=auth_header(admin)
)
assert twice.status_code == 404
# The owner sees the gateway state and may switch it and one organization.
state = await client.get("/api/admin/mcp", headers=auth_header(platform_owner_token))
assert state.status_code == 200, state.text
assert state.json()["gateway_enabled"] is True
assert any(
org_row["organization_id"] == org["org_id"]
for org_row in state.json()["organizations"]
)
switched = await client.put(
"/api/admin/mcp",
json={"enabled": False},
headers=auth_header(platform_owner_token),
)
assert switched.status_code == 200, switched.text
assert switched.json()["gateway_enabled"] is False
org_switch = await client.put(
f"/api/admin/mcp/organizations/{org['org_id']}",
json={"enabled": False},
headers=auth_header(platform_owner_token),
)
assert org_switch.status_code == 200, org_switch.text
assert org_switch.json()["mcp_enabled"] is False
settings_view = await client.get(
f"/api/organizations/{org['org_id']}/mcp-tokens/settings",
headers=auth_header(admin),
)
assert settings_view.json()["mcp_enabled"] is False
assert settings_view.json()["gateway_enabled"] is False
assert "tasks:read" in settings_view.json()["scopes"]
+647
View File
@@ -0,0 +1,647 @@
"""Org memory: what an organization learns about itself.
The invariants under test:
* teaching the same thing twice teaches nothing (dedupe by hash, corrections by
key);
* memory is capped and evicts the oldest *unpinned lesson* first — never a rule;
* one task may write at most three memories;
* the context block respects its budget, leads with pinned rules, and filters by
role (only code-reading roles see the code map; only the manager sees
preferences);
* a reflection that fails never raises into the engine;
* nothing secret is ever stored, and no organization ever sees another's memory.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from app.application.container_org import build_memory, build_memory_service
from app.application.ports.memory import TaskOutcome
from app.core.config import settings
from app.core.errors import NotFoundError, ValidationError
from app.domain.base import new_id, utcnow
from app.domain.identity.models import PlatformRole, Role
from app.domain.memory.models import (
CONTENT_MAX_CHARS,
KEY_MAX_CHARS,
MAX_WRITES_PER_TASK,
MemoryKind,
MemorySource,
content_hash,
lesson_expiry,
normalise,
tokenise,
validate_content,
validate_key,
visible_kinds,
)
from app.infrastructure.db.models import (
AuditEventRow,
MembershipRow,
OrganizationRow,
TaskRow,
UsageEventRow,
UserRow,
)
from app.infrastructure.db.models_org import OrgMemoryRow
from tests.conftest import auth_header, signup
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _task(session_factory, org_id: str, task_id: str) -> str:
"""A real task row: ``evidence_task_id`` is a real reference, not a string."""
async with session_factory() as session:
session.add(
TaskRow(id=task_id, organization_id=org_id, title=f"کار {task_id}")
)
await session.commit()
return task_id
async def _org(session_factory, slug: str, role: Role = Role.OWNER) -> tuple[str, str]:
"""An organization with one member, written directly to the database."""
async with session_factory() as session:
user = UserRow(
id=new_id(),
email=f"{slug}@example.com",
password_hash="x",
name=slug,
platform_role=PlatformRole.MANAGER.value,
)
session.add(user)
await session.flush()
org = OrganizationRow(id=new_id(), name=slug, slug=slug, owner_user_id=user.id)
session.add(org)
await session.flush()
session.add(
MembershipRow(organization_id=org.id, user_id=user.id, role=role.value)
)
await session.commit()
return org.id, user.id
class _ShapedProvider:
"""A provider that answers the reflection prompt in the template it asks for."""
def __init__(self, answer: str) -> None:
self.answer = answer
self.calls = 0
async def complete(self, **_kwargs):
from app.application.ports import ModelResponse
self.calls += 1
return ModelResponse(
content=self.answer,
model_internal_name="fake",
input_tokens=40,
output_tokens=12,
)
async def health(self):
from app.application.ports import ModelHealth
return ModelHealth(ok=True, detail="shaped fake")
class _BrokenProvider:
"""A provider that fails exactly like a network outage would."""
async def complete(self, **_kwargs):
raise RuntimeError("provider is unreachable: token=super-secret-token")
async def health(self):
from app.application.ports import ModelHealth
return ModelHealth(ok=False, detail="down")
@asynccontextmanager
async def _service(session_factory, provider):
"""A memory service on its own session, committed when the block exits."""
session = session_factory()
try:
service = build_memory_service(session, provider=provider)
yield service
await session.commit()
finally:
await session.close()
# ---------------------------------------------------------------------------
# Domain rules
# ---------------------------------------------------------------------------
def test_normalisation_and_dedupe_hash():
assert normalise(" the API runs \n on 8000 ") == "the API runs on 8000"
assert content_hash("The API runs on 8000") == content_hash(" the api runs on 8000 ")
assert content_hash("the API runs on 8000") != content_hash("the API runs on 8001")
def test_limits_are_enforced_in_the_domain():
assert len(validate_key("k" * KEY_MAX_CHARS)) == KEY_MAX_CHARS
with pytest.raises(ValidationError):
validate_key("k" * (KEY_MAX_CHARS + 1))
assert validate_content(" x ") == "x"
with pytest.raises(ValidationError):
validate_content("x" * (CONTENT_MAX_CHARS + 1))
def test_role_filtering_of_kinds():
# A reviewer reads the code map; support does not.
assert MemoryKind.CODE_MAP in visible_kinds("reviewer")
assert MemoryKind.CODE_MAP not in visible_kinds("support")
assert MemoryKind.CODE_MAP in visible_kinds("developer")
# Preferences belong to the manager alone.
assert MemoryKind.PREFERENCE in visible_kinds("manager")
assert MemoryKind.PREFERENCE not in visible_kinds("developer")
# Rules and lessons are everyone's.
for role in ("manager", "developer", "support", "researcher"):
assert visible_kinds(role) >= {MemoryKind.RULE, MemoryKind.LESSON}
def test_lessons_expire_unless_pinned_or_confirmed():
assert lesson_expiry(pinned=False, confirmed=False) is not None
assert lesson_expiry(pinned=True, confirmed=False) is None
assert lesson_expiry(pinned=False, confirmed=True) is None
def test_tokenise_drops_stop_words():
words = tokenise("the API and the manager را")
assert "api" in words and "manager" in words
assert "the" not in words and "را" not in words
# ---------------------------------------------------------------------------
# record: dedupe, correction, cap, task ceiling, redaction
# ---------------------------------------------------------------------------
async def test_recording_the_same_thing_twice_updates_one_memory(session_factory):
org_id, _ = await _org(session_factory, "dedupe")
async with _service(session_factory, _ShapedProvider("")) as service:
first = await service.record(
organization_id=org_id, kind="rule", content="همیشه تست بنویس",
source="user",
)
second = await service.record(
organization_id=org_id, kind="rule", content=" همیشه تست بنویس ",
source="user",
)
assert first == second
async with session_factory() as session:
rows = (
(await session.execute(select(OrgMemoryRow))).scalars().all()
)
assert len(rows) == 1
async def test_a_key_corrects_instead_of_duplicating(session_factory):
org_id, _ = await _org(session_factory, "keyed")
async with _service(session_factory, _ShapedProvider("")) as service:
await service.record(
organization_id=org_id, kind="fact", content="پورت ۸۰۰۰ است",
source="user", key="app_port",
)
memory_id = await service.record(
organization_id=org_id, kind="fact", content="پورت ۸۰۰۱ است",
source="user", key="app_port",
)
memory = await service.get(org_id, memory_id)
assert memory.content == "پورت ۸۰۰۱ است"
memories = await service.list(org_id)
assert len(memories) == 1
async def test_the_cap_evicts_the_oldest_unpinned_lesson_first(session_factory, monkeypatch):
monkeypatch.setattr(settings, "memory_org_char_cap", 200)
org_id, _ = await _org(session_factory, "capped")
await _task(session_factory, org_id, "t-pinned")
async with _service(session_factory, _ShapedProvider("")) as service:
# A pinned lesson survives; the rest are evicted oldest-first.
pinned = await service.record(
organization_id=org_id, kind="lesson", content="پ" * 40,
source="reflection", evidence_task_id="t-pinned",
)
await service.set_pinned(organization_id=org_id, memory_id=pinned, pinned=True)
for index in range(6):
await _task(session_factory, org_id, f"t-{index}")
await service.record(
organization_id=org_id, kind="lesson", content=f"درس {index}" + "ی" * 30,
source="reflection", evidence_task_id=f"t-{index}",
)
memories = await service.list(org_id)
# Budget spent, and whatever fell out is the oldest, never the pinned one.
assert (await service.stats(org_id))["chars"] <= 200
ids = {m.id for m in memories}
assert pinned in ids
# Only lessons are evictable, so a rule or a preference is never the
# victim — and the budget is honoured.
assert all(m.kind is MemoryKind.LESSON for m in memories)
async def test_one_task_may_write_at_most_three_memories(session_factory):
org_id, _ = await _org(session_factory, "three-writes")
await _task(session_factory, org_id, "task-1")
async with _service(session_factory, _ShapedProvider("")) as service:
written = [
await service.record(
organization_id=org_id, kind="lesson", content=f"درس شمارهٔ {index}",
source="reflection", evidence_task_id="task-1",
)
for index in range(MAX_WRITES_PER_TASK + 2)
]
assert written[:MAX_WRITES_PER_TASK]
# The 4th write is refused — ``None``, not an exception.
assert written[MAX_WRITES_PER_TASK] is None
assert written[MAX_WRITES_PER_TASK + 1] is None
rows = await service.list(org_id)
assert len(rows) == MAX_WRITES_PER_TASK
async def test_nothing_secret_is_ever_stored(session_factory):
org_id, _ = await _org(session_factory, "redacted")
async with _service(session_factory, _ShapedProvider("")) as service:
await service.record(
organization_id=org_id, kind="rule",
content="کلید API کل من ghp_ABCDEFGHIJKLMNOPQRSTUVWXyz123 است",
source="user",
)
memories = await service.list(org_id)
assert "ghp_ABCDEFGHIJKLMNOPQRSTUVWXyz123" not in memories[0].content
# ---------------------------------------------------------------------------
# build_context
# ---------------------------------------------------------------------------
async def test_context_leads_with_pinned_rules_and_respects_the_budget(session_factory):
org_id, _ = await _org(session_factory, "context")
async with _service(session_factory, _ShapedProvider("")) as service:
pinned_rule = await service.record(
organization_id=org_id, kind="rule", content="قاعدهٔ سنجاق‌شده", source="user"
)
await service.set_pinned(organization_id=org_id, memory_id=pinned_rule, pinned=True)
await service.record(
organization_id=org_id, kind="preference", content="ترجیح معمولی", source="user"
)
for index in range(20):
await service.record(
organization_id=org_id, kind="lesson", content=f"درس قدیمی {index}" + " ز" * 20,
source="user",
)
block = await service.build_context(
organization_id=org_id, role="manager", query="درس", budget_chars=300
)
assert block
assert len(block) <= 300
assert "قاعدهٔ سنجاق‌شده" in block.splitlines()[1]
assert block.index("قاعدهٔ سنجاق‌شده") < block.index("ترجیح معمولی")
# A budget so small that nothing fits returns nothing, not a broken block.
assert await service.build_context(
organization_id=org_id, role="manager", query="درس", budget_chars=4
) == ""
async def test_context_filters_kinds_by_role(session_factory):
org_id, _ = await _org(session_factory, "roles")
async with _service(session_factory, _ShapedProvider("")) as service:
await service.record(
organization_id=org_id, kind="code_map",
content="نقشهٔ کد: مدل‌ها در db/models.py", source="system",
)
await service.record(
organization_id=org_id, kind="preference", content="ترجیح: فارسی بنویس",
source="user",
)
developer = await service.build_context(
organization_id=org_id, role="developer", query="db", budget_chars=2000
)
manager = await service.build_context(
organization_id=org_id, role="manager", query="db", budget_chars=2000
)
support = await service.build_context(
organization_id=org_id, role="support", query="db", budget_chars=2000
)
assert "نقشهٔ کد" in developer and "ترجیح" not in developer
assert "ترجیح" in manager and "نقشهٔ کد" not in manager
assert support == ""
async def test_context_counts_a_hit(session_factory):
org_id, _ = await _org(session_factory, "hits")
async with _service(session_factory, _ShapedProvider("")) as service:
memory_id = await service.record(
organization_id=org_id, kind="rule", content="هر تست قبل از کد", source="user"
)
await service.build_context(
organization_id=org_id, role="developer", query="تست", budget_chars=500
)
memory = await service.get(org_id, memory_id)
assert memory.hits == 1
assert memory.last_used_at is not None
# ---------------------------------------------------------------------------
# Reflection
# ---------------------------------------------------------------------------
async def test_reflection_stores_lessons_and_prices_the_call(session_factory, seeded):
org_id, _ = await _org(session_factory, "reflect")
await _task(session_factory, org_id, "task-77")
provider = _ShapedProvider(
"درس ۱: تست‌ها را قبل از ادغام اجرا کن.\n"
"درس ۲: دروازهٔ lint دو بار شکست.\n"
"نقشهٔ کد: app/db/models.py منبع حقیقت است.\n"
"یک خط اضافی که نادیده گرفته می‌شود."
)
async with _service(session_factory, provider) as service:
await service.on_task_finished(
organization_id=org_id,
task=TaskOutcome(
task_id="task-77", title="ساخت صفحهٔ ورود", status="completed",
summary="تمام شد", changed_files=["a.py"], cycles=2, gate_failures=["lint"],
),
)
memories = await service.list(org_id)
kinds = [m.kind for m in memories]
assert MemoryKind.LESSON in kinds
assert MemoryKind.CODE_MAP in kinds
assert len(memories) == 3
assert all(m.source is MemorySource.REFLECTION for m in memories)
assert all(m.confidence == 0.5 for m in memories)
assert all(m.evidence_task_id == "task-77" for m in memories)
# The reflection is billed like any other model call.
async with session_factory() as session:
usage = (
(await session.execute(select(UsageEventRow))).scalars().all()
)
assert usage
assert all(row.metadata_json.get("memory_reflection") for row in usage)
async def test_a_failing_reflection_never_raises_and_records_the_error(
session_factory, redirect_error_log, seeded
):
org_id, _ = await _org(session_factory, "reflect-fail")
async with _service(session_factory, _BrokenProvider()) as service:
# No exception escapes, and the org keeps its memory untouched.
await service.on_task_finished(
organization_id=org_id,
task=TaskOutcome(task_id="task-err", title="t", status="failed"),
)
assert await service.list(org_id) == []
from app.infrastructure.db.models import ErrorEventRow
async with session_factory() as session:
rows = (await session.execute(select(ErrorEventRow))).scalars().all()
assert any(row.kind == "memory.reflection_failed" for row in rows)
# A provider's failure text is not trusted verbatim: either it is redacted,
# or it never reached the row at all.
for row in rows:
if "super-secret-token" in (row.message or ""):
assert "REDACTED" in row.message
async def test_reflection_without_a_model_is_recorded_not_raised(
session_factory, redirect_error_log
):
"""No seeded catalogue → ``pick_for_mode`` returns None → something is recorded."""
org_id, _ = await _org(session_factory, "no-model")
async with _service(session_factory, _ShapedProvider("")) as service:
await service.on_task_finished(
organization_id=org_id,
task=TaskOutcome(task_id="task-x", title="t", status="completed"),
)
assert await service.list(org_id) == []
from app.infrastructure.db.models import ErrorEventRow
async with session_factory() as session:
rows = (await session.execute(select(ErrorEventRow))).scalars().all()
assert any(row.kind == "memory.reflection_no_model" for row in rows)
async def test_expired_lessons_are_removed_by_the_expiry_job(session_factory):
org_id, _ = await _org(session_factory, "expiry")
async with _service(session_factory, _ShapedProvider("")) as service:
expired = await service.record(
organization_id=org_id, kind="lesson", content="درس منقضی", source="reflection"
)
pinned = await service.record(
organization_id=org_id, kind="lesson", content="درس سنجاق‌شده", source="reflection"
)
await service.set_pinned(organization_id=org_id, memory_id=pinned, pinned=True)
async with session_factory() as session:
row = await session.get(OrgMemoryRow, expired)
row.expires_at = utcnow().replace(year=2020) # long past
await session.commit()
assert await service.expire_memories() == 1
remaining = {m.id for m in await service.list(org_id)}
assert expired not in remaining
assert pinned in remaining
# ---------------------------------------------------------------------------
# Tenant isolation
# ---------------------------------------------------------------------------
async def test_one_organization_never_sees_anothers_memory(session_factory):
org_a, _ = await _org(session_factory, "tenant-a")
org_b, _ = await _org(session_factory, "tenant-b")
async with _service(session_factory, _ShapedProvider("")) as service:
memory_id = await service.record(
organization_id=org_a, kind="rule", content="قاعدهٔ سازمان الف", source="user"
)
assert memory_id not in {m.id for m in await service.list(org_b)}
with pytest.raises(NotFoundError):
await service.get(org_b, memory_id)
block = await service.build_context(
organization_id=org_b, role="manager", query="قاعده", budget_chars=500
)
assert block == ""
# ---------------------------------------------------------------------------
# HTTP surface
# ---------------------------------------------------------------------------
async def _owner_with_org(client: AsyncClient, slug: str) -> dict:
body = await signup(client, f"{slug}-owner@example.com", name="Owner")
org = await client.post(
"/api/organizations", json={"name": slug}, headers=auth_header(body["access_token"])
)
assert org.status_code == 201, org.text
return {"token": body["access_token"], "org_id": org.json()["id"]}
async def _member(client: AsyncClient, owner_token: str, org_id: str, email: str, role: str):
invite = await client.post(
f"/api/organizations/{org_id}/invites",
json={"email": email, "role": role},
headers=auth_header(owner_token),
)
assert invite.status_code == 201, invite.text
account = await signup(client, email, name=role)
accepted = await client.post(
"/api/invites/accept",
json={"token": invite.json()["invite_token"]},
headers=auth_header(account["access_token"]),
)
assert accepted.status_code == 200, accepted.text
return account["access_token"]
async def test_memory_http_permissions(client: AsyncClient):
org = await _owner_with_org(client, "memory-org")
viewer = await _member(client, org["token"], org["org_id"], "viewer@example.com", "viewer")
member = await _member(client, org["token"], org["org_id"], "member@example.com", "member")
admin = await _member(client, org["token"], org["org_id"], "admin@example.com", "admin")
# A viewer reads but cannot teach.
empty = await client.get(
f"/api/organizations/{org['org_id']}/memory", headers=auth_header(viewer)
)
assert empty.status_code == 200, empty.text
assert empty.json()["items"] == []
refused = await client.post(
f"/api/organizations/{org['org_id']}/memory",
json={"kind": "rule", "content": "قاعدهٔ ممنوعه"},
headers=auth_header(viewer),
)
assert refused.status_code == 403, refused.text
# A member teaches a rule; a lesson is refused (teaching is human-only).
taught = await client.post(
f"/api/organizations/{org['org_id']}/memory",
json={"kind": "rule", "content": "قاعدهٔ تازه", "key": "style"},
headers=auth_header(member),
)
assert taught.status_code == 201, taught.text
memory_id = taught.json()["id"]
assert taught.json()["kind_label"] == "قاعده"
lesson = await client.post(
f"/api/organizations/{org['org_id']}/memory",
json={"kind": "lesson", "content": "درس دست‌نویس"},
headers=auth_header(member),
)
assert lesson.status_code == 403, lesson.text
# Editing is admin+.
edited = await client.patch(
f"/api/organizations/{org['org_id']}/memory/{memory_id}",
json={"content": "قاعدهٔ ویرایش‌شده"},
headers=auth_header(admin),
)
assert edited.status_code == 200, edited.text
assert edited.json()["content"] == "قاعدهٔ ویرایش‌شده"
member_edit = await client.patch(
f"/api/organizations/{org['org_id']}/memory/{memory_id}",
json={"content": "تلاش عضو"},
headers=auth_header(member),
)
assert member_edit.status_code == 403, member_edit.text
# Pin, then confirm (only a lesson may be confirmed).
pinned = await client.post(
f"/api/organizations/{org['org_id']}/memory/{memory_id}/pin",
json={"pinned": True},
headers=auth_header(admin),
)
assert pinned.json()["pinned"] is True
not_a_lesson = await client.post(
f"/api/organizations/{org['org_id']}/memory/{memory_id}/confirm",
headers=auth_header(admin),
)
assert not_a_lesson.status_code == 422, not_a_lesson.text
# Remembering without a session is not a thing.
anonymous = await client.get(f"/api/organizations/{org['org_id']}/memory")
assert anonymous.status_code == 401
async def test_memory_audit_and_isolation(client: AsyncClient, session_factory):
org = await _owner_with_org(client, "audited-memory")
other = await _owner_with_org(client, "other-memory")
admin = await _member(client, org["token"], org["org_id"], "a2@example.com", "admin")
created = await client.post(
f"/api/organizations/{org['org_id']}/memory",
json={"kind": "rule", "content": "قاعدهٔ حسابرسی"},
headers=auth_header(admin),
)
assert created.status_code == 201, created.text
memory_id = created.json()["id"]
await client.patch(
f"/api/organizations/{org['org_id']}/memory/{memory_id}",
json={"content": "قاعدهٔ حسابرسی، ویرایش‌شده"},
headers=auth_header(admin),
)
await client.post(
f"/api/organizations/{org['org_id']}/memory/{memory_id}/pin",
json={"pinned": True},
headers=auth_header(admin),
)
await client.delete(
f"/api/organizations/{org['org_id']}/memory/{memory_id}", headers=auth_header(admin)
)
async with session_factory() as session:
actions = [
row.action
for row in (
await session.execute(
select(AuditEventRow).where(
AuditEventRow.organization_id == org["org_id"]
)
)
).scalars()
]
assert {"memory.create", "memory.update", "memory.pin", "memory.delete"} <= set(actions)
# Another organization's memory is invisible here, never another tenant's
# data: the soft-deleted row exists, and the list is still empty.
stranger = await client.get(
f"/api/organizations/{other['org_id']}/memory",
headers=auth_header(other["token"]),
)
assert stranger.status_code == 200, stranger.text
assert stranger.json()["items"] == []
async def test_the_real_port_is_wired_in_the_container(session_factory):
from app.application.ports.memory import MemoryPort, NullMemory
org_id, _ = await _org(session_factory, "wired")
async with session_factory() as session:
memory = build_memory(session)
assert isinstance(memory, MemoryPort)
assert not isinstance(memory, NullMemory)
recorded = await memory.record(
organization_id=org_id, kind="rule", content="قاعدهٔ سیم‌کشی‌شده", source="user"
)
assert recorded
block = await memory.build_context(
organization_id=org_id, role="manager", query="سیم", budget_chars=200
)
assert "قاعدهٔ سیم‌کشی‌شده" in block
+395
View File
@@ -0,0 +1,395 @@
"""Owner model grants, the default set and org policy.
The invariant under test: **an organization may only use the models the owner
granted it.** Two switches have to agree (model active + grant active), which is
what makes revocation real, and a grant belongs to exactly one organization.
Nothing here depends on the engine's code: the checks go through the HTTP
endpoints *and* through ``ModelGrantPort`` as the engine would call it, so a
break in either path fails the suite.
"""
from __future__ import annotations
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from app.application.container_org import build_model_grant_service, build_model_grants
from app.core.config import settings
from app.infrastructure.db.models import AiModelRow, AuditEventRow, OrgModelGrantRow
from app.infrastructure.db.models_org import DefaultModelGrantRow, OrgPolicyRow
from tests.conftest import auth_header
GRANTS = "/api/admin/model-grants"
DEFAULTS = "/api/admin/default-model-grants"
POLICIES = "/api/admin/org-policies"
def _payload(name: str, slug: str) -> dict:
return {
"org_name": name,
"slug": slug,
"app_name": f"app-{slug}",
"app_type": "web_app",
"language": "node",
"framework": "next",
"git_source": "platform",
}
async def _create_org(client: AsyncClient, token: str, name: str, slug: str) -> str:
response = await client.post(
"/api/setup/complete", json=_payload(name, slug), headers=auth_header(token)
)
assert response.status_code == 201, response.text
return response.json()["organization"]["id"]
async def _model_ids(session_factory) -> list[str]:
async with session_factory() as session:
rows = (
await session.execute(select(AiModelRow).order_by(AiModelRow.internal_name))
).scalars().all()
return [row.id for row in rows]
async def _audit_actions(session_factory, organization_id: str | None = None) -> list[str]:
async with session_factory() as session:
stmt = select(AuditEventRow.action)
if organization_id is not None:
stmt = stmt.where(AuditEventRow.organization_id == organization_id)
return list((await session.execute(stmt)).scalars().all())
# ---------------------------------------------------------------------------
# Authorization
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("method", "path"),
[
("get", f"{GRANTS}/org-1"),
("get", f"{GRANTS}/org-1/active"),
("put", f"{GRANTS}/org-1/models/model-1"),
("delete", f"{GRANTS}/org-1/models/model-1"),
("get", DEFAULTS),
("put", f"{DEFAULTS}/model-1"),
("delete", f"{DEFAULTS}/model-1"),
("get", f"{POLICIES}/org-1"),
("put", f"{POLICIES}/org-1"),
],
)
async def test_non_owner_is_forbidden_everywhere(
client: AsyncClient, manager_token: str, method: str, path: str
):
call = getattr(client, method)
kwargs: dict = {"headers": auth_header(manager_token)}
if method == "put":
kwargs["json"] = {"daily_credit_cap": 5, "is_active": True, "max_employees": 3}
response = await call(path, **kwargs)
assert response.status_code == 403, response.text
async def test_anonymous_is_rejected(client: AsyncClient):
assert (await client.get(f"{GRANTS}/org-1")).status_code == 401
# ---------------------------------------------------------------------------
# Grant matrix and revocation
# ---------------------------------------------------------------------------
async def test_wizard_copies_the_default_set(
client: AsyncClient,
manager_token: str,
platform_owner_token: str,
session_factory,
seeded,
):
model_id = (await _model_ids(session_factory))[0]
set_default = await client.put(
f"{DEFAULTS}/{model_id}",
json={"is_active": True, "daily_credit_cap": 12.5},
headers=auth_header(platform_owner_token),
)
assert set_default.status_code == 200, set_default.text
assert set_default.json()["granted"] is True
org_id = await _create_org(client, manager_token, "سازمان الف", "alpha-org")
matrix = await client.get(f"{GRANTS}/{org_id}", headers=auth_header(platform_owner_token))
assert matrix.status_code == 200, matrix.text
rows = {m["ai_model_id"]: m for m in matrix.json()["models"]}
assert rows[model_id]["granted"] is True
assert rows[model_id]["daily_credit_cap"] == 12.5
# Every other model is listed but not granted.
assert all(m["granted"] is False for m_id, m in rows.items() if m_id != model_id)
# The same answer through the engine's port.
async with session_factory() as session:
service = build_model_grants(session)
granted = await service.granted_models(organization_id=org_id)
assert [g.ai_model_id for g in granted] == [model_id]
assert granted[0].tier in {"fast", "balanced", "strong"}
assert granted[0].daily_credit_cap == 12.5
assert await service.is_granted(organization_id=org_id, ai_model_id=model_id)
async def test_grant_edit_and_revoke_are_immediate_and_audited(
client: AsyncClient,
manager_token: str,
platform_owner_token: str,
session_factory,
seeded,
):
model_id = (await _model_ids(session_factory))[0]
org_id = await _create_org(client, manager_token, "سازمان ب", "beta-org")
owner = auth_header(platform_owner_token)
grant = await client.put(
f"{GRANTS}/{org_id}/models/{model_id}",
json={"daily_credit_cap": 3},
headers=owner,
)
assert grant.status_code == 200, grant.text
assert grant.json()["is_active"] is True
async with session_factory() as session:
service = build_model_grant_service(session)
assert await service.is_granted(organization_id=org_id, ai_model_id=model_id)
# Raising the cap keeps it granted.
raised = await client.put(
f"{GRANTS}/{org_id}/models/{model_id}",
json={"daily_credit_cap": 40},
headers=owner,
)
assert raised.json()["daily_credit_cap"] == 40
revoked = await client.delete(f"{GRANTS}/{org_id}/models/{model_id}", headers=owner)
assert revoked.status_code == 200, revoked.text
async with session_factory() as session:
service = build_model_grant_service(session)
assert not await service.is_granted(organization_id=org_id, ai_model_id=model_id)
assert await service.granted_models(organization_id=org_id) == []
# Revocation deactivates rather than deletes, so the history survives and
# re-granting is idempotent instead of racing a unique constraint.
row = (
await session.execute(
select(OrgModelGrantRow).where(
OrgModelGrantRow.organization_id == org_id,
OrgModelGrantRow.ai_model_id == model_id,
)
)
).scalar_one()
assert row.is_active is False
active = await client.get(f"{GRANTS}/{org_id}/active", headers=owner)
assert active.json() == []
actions = await _audit_actions(session_factory, org_id)
assert "model_grant.set" in actions
assert "model_grant.revoke" in actions
# Revoking something that is not granted is a 404, not a silent success.
again = await client.delete(f"{GRANTS}/{org_id}/models/{model_id}", headers=owner)
assert again.status_code == 404
async def test_inactive_model_is_never_granted(
client: AsyncClient,
manager_token: str,
platform_owner_token: str,
session_factory,
seeded,
):
model_id = (await _model_ids(session_factory))[0]
org_id = await _create_org(client, manager_token, "سازمان ج", "gamma-org")
owner = auth_header(platform_owner_token)
granted = await client.put(f"{GRANTS}/{org_id}/models/{model_id}", json={}, headers=owner)
assert granted.status_code == 200, granted.text
# The owner retires the model from the catalogue.
async with session_factory() as session:
row = await session.get(AiModelRow, model_id)
assert row is not None
row.is_active = False
await session.commit()
async with session_factory() as session:
service = build_model_grant_service(session)
assert not await service.is_granted(organization_id=org_id, ai_model_id=model_id)
assert await service.granted_models(organization_id=org_id) == []
# Granting a retired model is refused rather than quietly stored.
refused = await client.put(f"{GRANTS}/{org_id}/models/{model_id}", json={}, headers=owner)
assert refused.status_code == 404
listing = await client.get(f"{GRANTS}/{org_id}", headers=owner)
assert all(m["ai_model_id"] != model_id for m in listing.json()["models"])
async def test_grants_are_isolated_per_organization(
client: AsyncClient,
manager_token: str,
platform_owner_token: str,
session_factory,
seeded,
):
model_id = (await _model_ids(session_factory))[0]
org_a = await _create_org(client, manager_token, "شرکت آلفا", "alpha-two")
org_b = await _create_org(client, manager_token, "شرکت بتا", "beta-two")
owner = auth_header(platform_owner_token)
granted = await client.put(
f"{GRANTS}/{org_a}/models/{model_id}", json={"daily_credit_cap": 7}, headers=owner
)
assert granted.status_code == 200, granted.text
async with session_factory() as session:
service = build_model_grant_service(session)
assert await service.is_granted(organization_id=org_a, ai_model_id=model_id)
assert not await service.is_granted(organization_id=org_b, ai_model_id=model_id)
assert await service.granted_models(organization_id=org_b) == []
matrix_b = await client.get(f"{GRANTS}/{org_b}", headers=owner)
assert all(m["granted"] is False for m in matrix_b.json()["models"])
# …and org B's grant is never visible in org A's active list either.
active_a = await client.get(f"{GRANTS}/{org_a}/active", headers=owner)
assert [g["ai_model_id"] for g in active_a.json()] == [model_id]
async def test_default_set_can_be_removed_and_deactivated(
client: AsyncClient,
manager_token: str,
platform_owner_token: str,
session_factory,
seeded,
):
model_id = (await _model_ids(session_factory))[0]
owner = auth_header(platform_owner_token)
assert (await client.put(f"{DEFAULTS}/{model_id}", json={}, headers=owner)).status_code == 200
# Switched off: still in the set for the admin UI, but not copied to new orgs.
off = await client.put(
f"{DEFAULTS}/{model_id}", json={"is_active": False}, headers=owner
)
assert off.status_code == 200, off.text
org_off = await _create_org(client, manager_token, "سازمان خاموش", "off-org")
async with session_factory() as session:
service = build_model_grant_service(session)
assert await service.granted_models(organization_id=org_off) == []
removed = await client.delete(f"{DEFAULTS}/{model_id}", headers=owner)
assert removed.status_code == 200, removed.text
assert removed.json()["in_default_set"] is False
async with session_factory() as session:
assert (
await session.execute(
select(DefaultModelGrantRow).where(
DefaultModelGrantRow.ai_model_id == model_id
)
)
).scalar_one_or_none() is None
# Already-created organizations keep the grant they were given.
async with session_factory() as session:
assert (
await session.execute(
select(OrgModelGrantRow).where(
OrgModelGrantRow.organization_id == org_off
)
)
).scalar_one_or_none() is None
actions = await _audit_actions(session_factory)
assert "default_model_grant.set" in actions
assert "default_model_grant.remove" in actions
# ---------------------------------------------------------------------------
# Org policy
# ---------------------------------------------------------------------------
async def test_policy_defaults_caps_and_audit(
client: AsyncClient,
manager_token: str,
platform_owner_token: str,
session_factory,
seeded,
):
org_id = await _create_org(client, manager_token, "سازمان د", "delta-org")
owner = auth_header(platform_owner_token)
fresh = await client.get(f"{POLICIES}/{org_id}", headers=owner)
assert fresh.status_code == 200, fresh.text
body = fresh.json()
assert body["max_employees"] == 5
assert body["automation_enabled"] is True
assert body["mcp_enabled"] is True
assert body["monthly_credit_budget"] is None
assert body["max_employees_limit"] == settings.engine_max_employees
too_many = await client.put(
f"{POLICIES}/{org_id}",
json={"max_employees": settings.engine_max_employees + 1},
headers=owner,
)
assert too_many.status_code == 422
negative = await client.put(
f"{POLICIES}/{org_id}", json={"monthly_credit_budget": -1}, headers=owner
)
assert negative.status_code == 422
empty = await client.put(f"{POLICIES}/{org_id}", json={}, headers=owner)
assert empty.status_code == 422
updated = await client.put(
f"{POLICIES}/{org_id}",
json={
"max_employees": 8,
"monthly_credit_budget": 250.5,
"automation_enabled": False,
"mcp_enabled": False,
},
headers=owner,
)
assert updated.status_code == 200, updated.text
assert updated.json()["max_employees"] == 8
assert updated.json()["automation_enabled"] is False
assert updated.json()["mcp_enabled"] is False
async with session_factory() as session:
row = await session.get(OrgPolicyRow, org_id)
assert row is not None
assert row.max_employees == 8
assert row.monthly_credit_budget == 250.5
# Agent 2 reads the policy through the same service.
service = build_model_grant_service(session)
policy = await service.get_org_policy(org_id)
assert policy.max_employees == 8
assert policy.automation_enabled is False
assert await service.mcp_enabled(org_id) is False
assert "org_policy.update" in await _audit_actions(session_factory, org_id)
async def test_policy_is_isolated_per_organization(
client: AsyncClient, manager_token: str, platform_owner_token: str, seeded
):
org_a = await _create_org(client, manager_token, "سازمان ه", "epsilon-org")
org_b = await _create_org(client, manager_token, "سازمان و", "zeta-org")
owner = auth_header(platform_owner_token)
await client.put(f"{POLICIES}/{org_a}", json={"max_employees": 2}, headers=owner)
a = await client.get(f"{POLICIES}/{org_a}", headers=owner)
b = await client.get(f"{POLICIES}/{org_b}", headers=owner)
assert a.json()["max_employees"] == 2
assert b.json()["max_employees"] == 5
+224 -9
View File
@@ -1,16 +1,231 @@
"use client";
import { PlaceholderPage } from "@/components/PlaceholderPage";
/**
* Platform owner console: the MCP gateway's two switches.
*
* An external agent can only act inside the platform through a scoped token, and
* this page is where the owner closes the door when something is wrong: the
* gateway as a whole, and each organization independently. A switch takes effect
* on the next call, because the gateway checks it per request rather than
* trusting a cached state.
*
* A revoked token keeps its place in the list — after an incident, "who could
* have done this" is the question that matters.
*/
import * as React from "react";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
ConfirmDialog,
EmptyState,
ErrorState,
LoadingState,
Stat,
ToastStack,
type ToastMessage,
} from "@agy/ui";
import { ApiError } from "@/lib/api";
import { mcpApi, type McpOrgState } from "@/lib/api-org";
function errorMessage(err: unknown, fallback: string): string {
return err instanceof ApiError ? err.message : fallback;
}
export default function AdminMcpPage() {
const [state, setState] = React.useState<{
gateway_enabled: boolean;
organizations: McpOrgState[];
total_tokens: number;
} | null>(null);
const [loading, setLoading] = React.useState(true);
const [busy, setBusy] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [pendingSwitch, setPendingSwitch] = React.useState<McpOrgState | null>(null);
const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
const toast = React.useCallback(
(title: string, tone: ToastMessage["tone"] = "success", description?: string) => {
setToasts((current) => [
...current,
{ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, title, tone, description },
]);
},
[],
);
const load = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
setState(await mcpApi.adminState());
} catch (err) {
setError(errorMessage(err, "دریافت وضعیت MCP ناموفق بود."));
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
void load();
}, [load]);
const toggleGateway = async () => {
if (!state) return;
setBusy("gateway");
try {
const next = !state.gateway_enabled;
await mcpApi.setGateway(next);
setState((current) => (current ? { ...current, gateway_enabled: next } : current));
toast(next ? "دسترسی MCP سامانه روشن شد" : "دسترسی MCP سامانه خاموش شد");
} catch (err) {
toast("تغییر وضعیت ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
} finally {
setBusy(null);
}
};
const confirmOrgSwitch = async (org: McpOrgState) => {
setBusy(`org-${org.organization_id}`);
try {
await mcpApi.setOrgEnabled(org.organization_id, !org.mcp_enabled);
setState((current) =>
current
? {
...current,
organizations: current.organizations.map((row) =>
row.organization_id === org.organization_id
? { ...row, mcp_enabled: !row.mcp_enabled }
: row,
),
}
: current,
);
toast(org.mcp_enabled ? "دسترسی این سازمان خاموش شد" : "دسترسی این سازمان روشن شد");
setPendingSwitch(null);
} catch (err) {
toast("تغییر وضعیت ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
} finally {
setBusy(null);
}
};
if (loading) return <LoadingState label="در حال دریافت وضعیت MCP…" />;
if (error || !state)
return <ErrorState message={error ?? "—"} onRetry={load} />;
const enabledOrgs = state.organizations.filter((org) => org.mcp_enabled).length;
return (
<PlaceholderPage
title="MCP"
description="کنترل دسترسی عامل‌های بیرونی به سازمان‌ها."
planned={[
"کلید سراسری",
"توکن‌های هر سازمان",
]}
/>
<div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">دسترسی MCP</h1>
<p className="text-sm text-slate-400">
عامل‌های بیرونی با توکن‌های محدود به یک سازمان کار می‌کنند. اینجا کل درگاه و
دسترسی هر سازمان را می‌توان خاموش کرد؛ توکن‌ها را مدیران خودشان در تنظیمات
سازمان می‌سازند.
</p>
</header>
<div className="grid gap-3 sm:grid-cols-3">
<Stat
label="وضعیت درگاه"
value={state.gateway_enabled ? "روشن" : "خاموش"}
tone={state.gateway_enabled ? "success" : "danger"}
/>
<Stat label="سازمان‌های دارای دسترسی" value={enabledOrgs} />
<Stat label="توکن‌های فعال" value={state.total_tokens} />
</div>
<Card>
<CardTitle>کلید اصلی</CardTitle>
<CardHint>
خاموش‌کردن درگاه، همهٔ توکن‌ها را بی‌اثر می‌کند بدون آنکه حذف شوند: وقتی دوباره
روشن شود، همان توکن‌ها از جایشان کار می‌کنند.
</CardHint>
<div className="mt-4">
<Button
variant={state.gateway_enabled ? "danger" : "secondary"}
loading={busy === "gateway"}
onClick={() => void toggleGateway()}
>
{state.gateway_enabled ? "خاموش‌کردن دسترسی MCP سامانه" : "روشن‌کردن دسترسی MCP"}
</Button>
</div>
</Card>
<Card>
<CardTitle>سازمان‌ها</CardTitle>
<CardHint>هر سازمان جداگانه قابل خاموش‌کردن است.</CardHint>
<div className="mt-4 overflow-x-auto">
{state.organizations.length === 0 ? (
<EmptyState title="سازمانی وجود ندارد" />
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-surface-border text-xs text-slate-400">
<th className="px-2 py-2 text-right font-medium">سازمان</th>
<th className="px-2 py-2 text-right font-medium">اسلاگ</th>
<th className="px-2 py-2 text-right font-medium">توکن فعال</th>
<th className="px-2 py-2 text-right font-medium">وضعیت</th>
<th className="px-2 py-2 text-right font-medium" />
</tr>
</thead>
<tbody className="anim-stagger">
{state.organizations.map((org) => (
<tr key={org.organization_id} className="border-b border-surface-border/60">
<td className="px-2 py-2 text-slate-200">{org.name}</td>
<td className="px-2 py-2 text-slate-400" dir="ltr">
{org.slug}
</td>
<td className="px-2 py-2 text-slate-400">{org.token_count}</td>
<td className="px-2 py-2">
<Badge tone={org.mcp_enabled ? "success" : "neutral"}>
{org.mcp_enabled ? "فعال" : "خاموش"}
</Badge>
</td>
<td className="px-2 py-2 text-end">
<Button
size="sm"
variant="ghost"
loading={busy === `org-${org.organization_id}`}
onClick={() =>
org.mcp_enabled
? setPendingSwitch(org)
: void confirmOrgSwitch(org)
}
>
{org.mcp_enabled ? "خاموش کن" : "روشن کن"}
</Button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</Card>
<ConfirmDialog
open={pendingSwitch !== null}
title="خاموش‌کردن دسترسی یک سازمان"
description={
pendingSwitch
? `توکن‌های «${pendingSwitch.name}» از این لحظه کار نمی‌کنند. مدیران سازمان می‌توانند توکن تازه نسازند.`
: undefined
}
confirmLabel="خاموش کن"
onConfirm={() => pendingSwitch && void confirmOrgSwitch(pendingSwitch)}
onCancel={() => setPendingSwitch(null)}
/>
<ToastStack
toasts={toasts}
onDismiss={(id) => setToasts((current) => current.filter((t) => t.id !== id))}
/>
</div>
);
}
@@ -1,17 +1,514 @@
"use client";
import { PlaceholderPage } from "@/components/PlaceholderPage";
/**
* Platform owner console: which models each organization may use.
*
* Two independent switches decide whether a model is usable, and this page is
* the only place both can be set: the model is active in the catalogue, and the
* owner granted it to *this* organization. Revoking here takes effect on the
* engine's next query — there is no cached grant set to expire.
*
* The tabs mirror the three decisions: the per-organization matrix, the default
* set copied into every newly created organization, and the organization's
* policy (employee cap, monthly budget, automation and MCP switches).
*
* Model names are visible here and nowhere else: an organization's own users
* only ever see a neutral tier.
*/
import * as React from "react";
import type { AdminOrganization } from "@agy/contracts";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
Checkbox,
EmptyState,
ErrorState,
Field,
LoadingState,
Select,
Stat,
Tabs,
ToastStack,
type ToastMessage,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import { orgApi, type ModelGrantRow, type OrgPolicyView } from "@/lib/api-org";
const TIER_TONES: Record<string, "success" | "info" | "warning"> = {
fast: "success",
balanced: "info",
strong: "warning",
};
function errorMessage(err: unknown, fallback: string): string {
return err instanceof ApiError ? err.message : fallback;
}
export default function AdminModelGrantsPage() {
const [organizations, setOrganizations] = React.useState<AdminOrganization[]>([]);
const [orgId, setOrgId] = React.useState("");
const [models, setModels] = React.useState<ModelGrantRow[]>([]);
const [defaults, setDefaults] = React.useState<ModelGrantRow[]>([]);
const [policy, setPolicy] = React.useState<OrgPolicyView | null>(null);
//: Two independent editors, so they need independent inputs: the org matrix
//: and the default set can hold different caps for the same model.
const [caps, setCaps] = React.useState<Record<string, string>>({});
const [defaultCaps, setDefaultCaps] = React.useState<Record<string, string>>({});
const [tab, setTab] = React.useState("org");
const [loading, setLoading] = React.useState(true);
const [busy, setBusy] = React.useState<string | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [orgError, setOrgError] = React.useState<string | null>(null);
const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
const [budget, setBudget] = React.useState("");
const [maxEmployees, setMaxEmployees] = React.useState("5");
const toast = React.useCallback(
(title: string, tone: ToastMessage["tone"] = "success", description?: string) => {
setToasts((current) => [
...current,
{ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, title, tone, description },
]);
},
[],
);
const loadOrgs = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
const [nextOrganizations, nextDefaults] = await Promise.all([
api.adminOrganizations(),
orgApi.listDefaultGrants(),
]);
setOrganizations(nextOrganizations);
setDefaults(nextDefaults);
setDefaultCaps(
Object.fromEntries(
nextDefaults.map((m) => [
m.ai_model_id,
m.daily_credit_cap === null ? "" : String(m.daily_credit_cap),
]),
),
);
setOrgId((current) => current || (nextOrganizations[0]?.id ?? ""));
} catch (err) {
setError(errorMessage(err, "دریافت داده‌های تخصیص مدل ناموفق بود."));
} finally {
setLoading(false);
}
}, []);
const loadOrgDetail = React.useCallback(async (id: string) => {
if (!id) {
setModels([]);
setPolicy(null);
return;
}
setOrgError(null);
try {
const [grants, nextPolicy] = await Promise.all([
orgApi.listOrgGrants(id),
orgApi.getOrgPolicy(id),
]);
setModels(grants.models);
setCaps(
Object.fromEntries(
grants.models.map((m) => [
m.ai_model_id,
m.daily_credit_cap === null ? "" : String(m.daily_credit_cap),
]),
),
);
setPolicy(nextPolicy);
setMaxEmployees(String(nextPolicy.max_employees));
setBudget(
nextPolicy.monthly_credit_budget === null
? ""
: String(nextPolicy.monthly_credit_budget),
);
} catch (err) {
setOrgError(errorMessage(err, "دریافت تخصیص‌های این سازمان ناموفق بود."));
}
}, []);
React.useEffect(() => {
void loadOrgs();
}, [loadOrgs]);
React.useEffect(() => {
void loadOrgDetail(orgId);
}, [loadOrgDetail, orgId]);
const parseCap = (raw: string): number | null => {
const trimmed = raw.trim();
if (trimmed === "") return null;
const value = Number(trimmed);
return Number.isFinite(value) && value >= 0 ? value : null;
};
const toggleGrant = async (model: ModelGrantRow, next: boolean) => {
setBusy(model.ai_model_id);
try {
if (next) {
await orgApi.setOrgGrant(orgId, model.ai_model_id, parseCap(caps[model.ai_model_id] ?? ""));
} else {
await orgApi.revokeOrgGrant(orgId, model.ai_model_id);
}
await loadOrgDetail(orgId);
toast(next ? `مدل «${model.label}» تخصیص یافت` : `دسترسی مدل «${model.label}» لغو شد`);
} catch (err) {
toast("تغییر تخصیص ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
} finally {
setBusy(null);
}
};
const saveCap = async (model: ModelGrantRow) => {
setBusy(model.ai_model_id);
try {
await orgApi.setOrgGrant(orgId, model.ai_model_id, parseCap(caps[model.ai_model_id] ?? ""));
await loadOrgDetail(orgId);
toast("سقف اعتبار روزانه ذخیره شد");
} catch (err) {
toast("ذخیرهٔ سقف ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
} finally {
setBusy(null);
}
};
const toggleDefault = async (model: ModelGrantRow, next: boolean) => {
setBusy(model.ai_model_id);
try {
await orgApi.setDefaultGrant(model.ai_model_id, {
is_active: next,
daily_credit_cap: parseCap(defaultCaps[model.ai_model_id] ?? ""),
});
const nextDefaults = await orgApi.listDefaultGrants();
setDefaults(nextDefaults);
setDefaultCaps(
Object.fromEntries(
nextDefaults.map((m) => [
m.ai_model_id,
m.daily_credit_cap === null ? "" : String(m.daily_credit_cap),
]),
),
);
toast(next ? "به مجموعهٔ پیش‌فرض افزوده شد" : "از مجموعهٔ پیش‌فرض حذف شد");
} catch (err) {
toast("تغییر مجموعهٔ پیش‌فرض ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
} finally {
setBusy(null);
}
};
const savePolicy = async (event: React.FormEvent) => {
event.preventDefault();
if (!policy) return;
setBusy("policy");
try {
const updated = await orgApi.updateOrgPolicy(orgId, {
max_employees: Number(maxEmployees),
monthly_credit_budget: budget.trim() === "" ? null : Number(budget),
automation_enabled: policy.automation_enabled,
mcp_enabled: policy.mcp_enabled,
});
setPolicy(updated);
toast("سیاست سازمان ذخیره شد");
} catch (err) {
toast("ذخیرهٔ سیاست ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
} finally {
setBusy(null);
}
};
const setPolicyFlag = (key: "automation_enabled" | "mcp_enabled", value: boolean) => {
setPolicy((current) => (current ? { ...current, [key]: value } : current));
};
if (loading) return <LoadingState label="در حال دریافت تخصیص مدل‌ها…" />;
if (error) return <ErrorState message={error} onRetry={loadOrgs} />;
const grantedCount = models.filter((m) => m.granted).length;
const defaultCount = defaults.filter((m) => m.granted).length;
const activeDefaults = defaults.filter((m) => m.granted);
return (
<PlaceholderPage
title="مدل‌های سازمان‌ها"
description="تخصیص مدل‌ها به هر سازمان و سیاست‌های سازمان."
planned={[
"ماتریس تخصیص مدل",
"سقف اعتبار روزانه",
"مجموعهٔ پیش‌فرض سازمان‌های جدید",
]}
/>
<div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">مدل‌های سازمان‌ها</h1>
<p className="text-sm text-slate-400">
هر سازمان فقط می‌تواند از مدل‌هایی استفاده کند که مالک سامانه تخصیص داده است. کاربران
سازمان نام مدل را نمی‌بینند؛ فقط سطح آن («سریع»، «متعادل»، «قوی») را می‌بینند.
</p>
</header>
<div className="grid gap-3 sm:grid-cols-3">
<Stat label="سازمان‌ها" value={organizations.length} />
<Stat label="مدل‌های تخصیص‌یافته (سازمان جاری)" value={grantedCount} />
<Stat label="مدل‌های پیش‌فرض" value={defaultCount} />
</div>
<Tabs
items={[
{ key: "org", label: "تخصیص سازمان‌ها" },
{ key: "defaults", label: "مجموعهٔ پیش‌فرض" },
{ key: "policy", label: "سیاست سازمان" },
]}
active={tab}
onChange={setTab}
/>
{tab !== "defaults" && (
<Card>
<CardTitle>انتخاب سازمان</CardTitle>
<div className="mt-3 max-w-sm">
<Select
label="سازمان"
value={orgId}
onChange={(e) => setOrgId(e.target.value)}
options={
organizations.length === 0
? [{ value: "", label: "سازمانی وجود ندارد" }]
: organizations.map((org) => ({
value: org.id,
label: `${org.name} (${org.slug})`,
}))
}
/>
</div>
</Card>
)}
{tab === "org" && (
<>
{orgError && <ErrorState message={orgError} onRetry={() => void loadOrgDetail(orgId)} />}
{!orgError && !orgId && (
<EmptyState
title="سازمانی وجود ندارد"
description="پس از ساخت نخستین سازمان توسط یک مدیر، تخصیص مدل‌ها از همین‌جا انجام می‌شود."
/>
)}
{!orgError && orgId && models.length === 0 && (
<EmptyState
title="مدلی در کاتالوگ نیست"
description="ابتدا از بخش «مدل‌ها» مدل‌های فعال را تعریف کنید."
/>
)}
{!orgError && orgId && models.length > 0 && (
<Card>
<CardTitle>ماتریس تخصیص</CardTitle>
<div className="mt-1">
<CardHint>
تیک هر مدل یعنی این سازمان می‌تواند از آن استفاده کند؛ لغو تیک بلافاصله اعمال
می‌شود. «سقف روزانه» اختیاری است و بر حسب اعتبار محاسبه می‌شود.
</CardHint>
</div>
<div className="mt-4 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-surface-border text-xs text-slate-400">
<th className="px-2 py-2 text-right font-medium">مدل</th>
<th className="px-2 py-2 text-right font-medium">سطح</th>
<th className="px-2 py-2 text-right font-medium">قابلیت‌ها</th>
<th className="px-2 py-2 text-right font-medium">تخصیص</th>
<th className="px-2 py-2 text-right font-medium">سقف روزانه</th>
</tr>
</thead>
<tbody className="anim-stagger">
{models.map((model) => (
<tr key={model.ai_model_id} className="border-b border-surface-border/60">
<td className="px-2 py-2 text-slate-200">{model.label}</td>
<td className="px-2 py-2">
<Badge tone={TIER_TONES[model.tier] ?? "neutral"}>
{model.tier_label}
</Badge>
</td>
<td className="px-2 py-2 text-xs text-slate-400">
{model.capabilities.join("، ") || "—"}
</td>
<td className="px-2 py-2">
<Checkbox
label="فعال"
checked={model.granted}
disabled={busy === model.ai_model_id}
onChange={(e) => void toggleGrant(model, e.target.checked)}
/>
</td>
<td className="px-2 py-2">
<div className="flex items-center gap-2">
<input
type="number"
min={0}
step="0.5"
aria-label={`سقف روزانهٔ ${model.label}`}
value={caps[model.ai_model_id] ?? ""}
onChange={(e) =>
setCaps((current) => ({
...current,
[model.ai_model_id]: e.target.value,
}))
}
className="w-24 rounded-lg border border-surface-border bg-surface px-2 py-1 text-xs text-slate-100"
/>
<Button
size="sm"
variant="secondary"
disabled={!model.granted || busy === model.ai_model_id}
onClick={() => void saveCap(model)}
>
ذخیره
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
</>
)}
{tab === "defaults" && (
<Card>
<CardTitle>مجموعهٔ پیش‌فرض سازمان‌های جدید</CardTitle>
<div className="mt-1">
<CardHint>
این مجموعه هنگام ساخت هر سازمان جدید به‌صورت خودکار کپی می‌شود. خاموش‌کردن یک مدل
آن را در فهرست نگه می‌دارد اما به سازمان‌های بعدی کپی نمی‌شود؛ سازمان‌های قبلی
دسترسی خود را از دست نمی‌دهند.
</CardHint>
</div>
<div className="mt-4 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-surface-border text-xs text-slate-400">
<th className="px-2 py-2 text-right font-medium">مدل</th>
<th className="px-2 py-2 text-right font-medium">سطح</th>
<th className="px-2 py-2 text-right font-medium">در مجموعه</th>
<th className="px-2 py-2 text-right font-medium">سقف روزانه</th>
</tr>
</thead>
<tbody className="anim-stagger">
{defaults.map((model) => (
<tr key={model.ai_model_id} className="border-b border-surface-border/60">
<td className="px-2 py-2 text-slate-200">{model.label}</td>
<td className="px-2 py-2">
<Badge tone={TIER_TONES[model.tier] ?? "neutral"}>{model.tier_label}</Badge>
</td>
<td className="px-2 py-2">
<Checkbox
label="پیش‌فرض"
checked={model.granted}
disabled={busy === model.ai_model_id}
onChange={(e) => void toggleDefault(model, e.target.checked)}
/>
</td>
<td className="px-2 py-2">
<div className="flex items-center gap-2">
<input
type="number"
min={0}
step="0.5"
aria-label={`سقف روزانهٔ پیش‌فرض ${model.label}`}
value={defaultCaps[model.ai_model_id] ?? ""}
onChange={(e) =>
setDefaultCaps((current) => ({
...current,
[model.ai_model_id]: e.target.value,
}))
}
className="w-24 rounded-lg border border-surface-border bg-surface px-2 py-1 text-xs text-slate-100"
/>
<Button
size="sm"
variant="secondary"
disabled={!model.granted || busy === model.ai_model_id}
onClick={() => void toggleDefault(model, true)}
>
ذخیره
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{activeDefaults.length === 0 && (
<div className="mt-3">
<CardHint>
مجموعهٔ پیش‌فرض خالی است: سازمان‌های جدید تا زمانی که تخصیص دستی نگیرند هیچ
مدلی ندارند.
</CardHint>
</div>
)}
</Card>
)}
{tab === "policy" && (
<>
{orgError && <ErrorState message={orgError} onRetry={() => void loadOrgDetail(orgId)} />}
{!orgError && policy && (
<Card>
<CardTitle>سیاست سازمان</CardTitle>
<div className="mt-1">
<CardHint>
سقف پرسنل نمی‌تواند از سقف سامانه ({policy.max_employees_limit}) بیشتر باشد.
بودجهٔ ماهانه خالی یعنی بدون سقف صریح.
</CardHint>
</div>
<form onSubmit={savePolicy} className="mt-4 flex max-w-lg flex-col gap-4">
<Field
label="حداکثر پرسنل"
type="number"
min={1}
max={policy.max_employees_limit}
value={maxEmployees}
onChange={(e) => setMaxEmployees(e.target.value)}
/>
<Field
label="بودجهٔ ماهانه (اختیاری)"
type="number"
min={0}
step="0.5"
value={budget}
onChange={(e) => setBudget(e.target.value)}
hint="خالی بگذارید تا سقف صریحی اعمال نشود."
/>
<Checkbox
label="اتوماسیون فعال باشد"
checked={policy.automation_enabled}
onChange={(e) => setPolicyFlag("automation_enabled", e.target.checked)}
/>
<Checkbox
label="دسترسی MCP برای این سازمان فعال باشد"
checked={policy.mcp_enabled}
onChange={(e) => setPolicyFlag("mcp_enabled", e.target.checked)}
/>
<div>
<Button type="submit" loading={busy === "policy"}>
ذخیرهٔ سیاست
</Button>
</div>
</form>
</Card>
)}
</>
)}
<ToastStack
toasts={toasts}
onDismiss={(id) => setToasts((current) => current.filter((t) => t.id !== id))}
/>
</div>
);
}
+19 -15
View File
@@ -1,19 +1,23 @@
"use client";
import { PlaceholderPage } from "@/components/PlaceholderPage";
/**
* Knowledge is now a tab of ``/memory``: a document and a one-line rule are
* different things, and they belong on one page so a reader never has to guess
* which half of the organization's knowledge lives where.
*
* This route stays alive as a permanent redirect so an old bookmark (or a link
* in a chat message) lands on the page that replaced it.
*/
export default function KnowledgePage() {
return (
<PlaceholderPage
title="دانش سازمانی"
description="منابع دانش سازمان که کارمندهای هوش مصنوعی از آن‌ها استفاده می‌کنند."
planned={[
"افزودن فایل، متن، URL و مخزن به‌عنوان منبع دانش",
"استخراج متن، تکه‌بندی (chunk) و ایندکس برداری",
"جست‌وجوی سادهٔ کلیدواژه‌ای در MVP",
"اتصال هر منبع دانش به کارمندهای مشخص",
"جداسازی کامل دانش بین سازمان‌ها",
]}
/>
);
import { useEffect } from "react";
import { useRouter } from "next/navigation";
export default function KnowledgeRedirectPage() {
const router = useRouter();
useEffect(() => {
router.replace("/memory");
}, [router]);
return null;
}
+531 -11
View File
@@ -1,18 +1,538 @@
"use client";
import { PlaceholderPage } from "@/components/PlaceholderPage";
/**
* Org memory: what the organization has learned about itself.
*
* Four kinds of memory, four tabs, one page — the plan's "one place" rule. The
* Knowledge Base keeps its own endpoints and appears here as a second family of
* tabs, because a document and a one-line rule are different things and
* pretending otherwise is how knowledge bases become unreadable.
*
* Live by design: the API publishes ``memory.changed`` on ``org:<id>`` after
* every write, so a page left open on one tab sees a lesson that another member
* (or the engine's own reflection) just recorded — including the realtime
* refresh, not a stale list.
*/
import * as React from "react";
import {
Badge,
Button,
Card,
CardHint,
CardTitle,
ConfirmDialog,
EmptyState,
ErrorState,
Field,
LoadingState,
Modal,
Select,
ToastStack,
type ToastMessage,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import {
MEMORY_CHANGED_EVENT,
memoryApi,
memoryKinds,
type KnowledgeSourceRow,
type MemoryRow,
} from "@/lib/api-org";
import { useLiveChannel } from "@/lib/realtime";
import { useSession } from "@/lib/session";
type Tab = "rules" | "lessons" | "code" | "knowledge";
const TABS: { key: Tab; label: string; hint: string }[] = [
{ key: "rules", label: "قوانین و ترجیحات", hint: "تصمیم‌های همیشگی سازمان" },
{ key: "lessons", label: "درس‌های آموخته", hint: "آنچه کارهای تمام‌شده یاد دادند" },
{ key: "code", label: "نقشهٔ کد", hint: "واقعیت‌ها و نقشهٔ اپلیکیشن" },
{ key: "knowledge", label: "منابع دانش", hint: "اسناد و متن‌های بلند" },
];
/** Which kinds each tab shows. */
const TAB_KINDS: Record<Tab, string[]> = {
rules: ["rule", "preference"],
lessons: ["lesson"],
code: ["code_map", "fact"],
knowledge: [],
};
const KIND_TONES: Record<string, "success" | "info" | "warning" | "neutral"> = {
rule: "success",
preference: "info",
lesson: "warning",
fact: "neutral",
code_map: "info",
};
function errorMessage(err: unknown, fallback: string): string {
return err instanceof ApiError ? err.message : fallback;
}
function formatDate(value: string | null): string {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "—" : date.toLocaleDateString("fa-IR");
}
export default function MemoryPage() {
const { activeOrganization } = useSession();
const orgId = activeOrganization?.id ?? null;
const [tab, setTab] = React.useState<Tab>("rules");
const [items, setItems] = React.useState<MemoryRow[]>([]);
const [stats, setStats] = React.useState({ total: 0, pinned: 0, chars: 0 });
const [sources, setSources] = React.useState<KnowledgeSourceRow[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [busy, setBusy] = React.useState<string | null>(null);
const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
const [teachOpen, setTeachOpen] = React.useState(false);
const [teachKind, setTeachKind] = React.useState<"rule" | "preference">("rule");
const [teachContent, setTeachContent] = React.useState("");
const [editing, setEditing] = React.useState<MemoryRow | null>(null);
const [editContent, setEditContent] = React.useState("");
const [pendingDelete, setPendingDelete] = React.useState<MemoryRow | null>(null);
const [sourceName, setSourceName] = React.useState("");
const [sourceText, setSourceText] = React.useState("");
const [pendingSource, setPendingSource] = React.useState<KnowledgeSourceRow | null>(null);
const toast = React.useCallback(
(title: string, tone: ToastMessage["tone"] = "success", description?: string) => {
setToasts((current) => [
...current,
{ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, title, tone, description },
]);
},
[],
);
const load = React.useCallback(async () => {
if (!orgId) return;
setLoading(true);
setError(null);
try {
const [memory, knowledge] = await Promise.all([
memoryApi.list(orgId),
memoryApi.listKnowledge(orgId),
]);
setItems(memory.items);
setStats({ total: memory.total, pinned: memory.pinned, chars: memory.chars });
setSources(knowledge);
} catch (err) {
setError(errorMessage(err, "دریافت حافظهٔ سازمان ناموفق بود."));
} finally {
setLoading(false);
}
}, [orgId]);
React.useEffect(() => {
void load();
}, [load]);
// Live: another member (or a reflection) changed memory while this page was open.
useLiveChannel(orgId ? [`org:${orgId}`] : [], (event) => {
if ((event.type as string) === MEMORY_CHANGED_EVENT) void load();
});
const visible = React.useMemo(
() => items.filter((item) => TAB_KINDS[tab].includes(item.kind)),
[items, tab],
);
const run = async (key: string, action: () => Promise<void>) => {
setBusy(key);
try {
await action();
await load();
} catch (err) {
toast("عملیات ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید."));
} finally {
setBusy(null);
}
};
const teach = async (event: React.FormEvent) => {
event.preventDefault();
if (!orgId || !teachContent.trim()) return;
await run("teach", async () => {
const created = await memoryApi.teach(orgId, {
kind: teachKind,
content: teachContent.trim(),
});
setTeachOpen(false);
setTeachContent("");
toast(created.kind_label === "قاعده" ? "قاعده یادآوری شد" : "ترجیح ذخیره شد");
});
};
const saveEdit = async (event: React.FormEvent) => {
event.preventDefault();
if (!orgId || !editing) return;
const content = editContent.trim();
if (!content) return;
await run(`edit-${editing.id}`, async () => {
await memoryApi.edit(orgId, editing.id, { content });
setEditing(null);
toast("یادداشت ویرایش شد");
});
};
const togglePin = (item: MemoryRow) =>
void run(`pin-${item.id}`, async () => {
await memoryApi.setPinned(orgId!, item.id, !item.pinned);
toast(item.pinned ? "سنجاق برداشته شد" : "سنجاق شد: همیشه در متن کارها");
});
const confirmLesson = (item: MemoryRow) =>
void run(`confirm-${item.id}`, async () => {
await memoryApi.confirm(orgId!, item.id);
toast("درس تأیید شد: دیگر منقضی نمی‌شود");
});
const remove = (item: MemoryRow) =>
void run(`delete-${item.id}`, async () => {
await memoryApi.remove(orgId!, item.id);
setPendingDelete(null);
toast("یادداشت حذف شد");
});
const addSource = async (event: React.FormEvent) => {
event.preventDefault();
if (!orgId || !sourceName.trim() || !sourceText.trim()) return;
await run("source", async () => {
const created = await memoryApi.createKnowledge(orgId, {
name: sourceName.trim(),
content: sourceText,
});
setSources((current) => [created, ...current]);
setSourceName("");
setSourceText("");
toast(`منبع «${created.name}» افزوده شد`);
});
};
const removeSource = (source: KnowledgeSourceRow) =>
void run(`source-${source.id}`, async () => {
await memoryApi.removeKnowledge(orgId!, source.id);
setSources((current) => current.filter((row) => row.id !== source.id));
setPendingSource(null);
toast("منبع دانش حذف شد");
});
if (!orgId) return <LoadingState label="در حال بارگذاری سازمان…" />;
if (loading && items.length === 0) return <LoadingState label="در حال دریافت حافظه…" />;
if (error && items.length === 0) return <ErrorState message={error} onRetry={load} />;
return (
<PlaceholderPage
title="حافظهٔ سازمان"
description="آنچه سازمان دربارهٔ شیوهٔ مدیریت و کد اپلیکیشن یاد گرفته است."
planned={[
"قوانین و ترجیحات",
"درس‌های آموخته",
"نقشهٔ کد",
"منابع دانش",
]}
/>
<div className="anim-stagger flex flex-col gap-5">
<header className="flex flex-col gap-1">
<h1 className="text-lg font-medium text-slate-100">حافظهٔ سازمان</h1>
<p className="text-sm text-slate-400">
آنچه سازمان دربارهٔ شیوهٔ کار و اپلیکیشنش یاد گرفته است. کارمندهای هوش مصنوعی
پیش از هر کار، یادداشت‌های مرتبط را می‌خوانند؛ هر چه سازمان بیشتر یاد بدهد،
کارها دقیق‌تر انجام می‌شود.
</p>
</header>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-surface-border bg-surface p-3">
<p className="text-xs text-slate-400">یادداشت‌ها</p>
<p className="mt-1 text-lg text-slate-100">{stats.total}</p>
</div>
<div className="rounded-xl border border-surface-border bg-surface p-3">
<p className="text-xs text-slate-400">سنجاق‌شده</p>
<p className="mt-1 text-lg text-slate-100">{stats.pinned}</p>
</div>
<div className="rounded-xl border border-surface-border bg-surface p-3">
<p className="text-xs text-slate-400">حجم متن</p>
<p className="mt-1 text-lg text-slate-100">{stats.chars.toLocaleString("fa-IR")}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{TABS.map((entry) => (
<button
key={entry.key}
type="button"
onClick={() => setTab(entry.key)}
title={entry.hint}
className={
"rounded-lg border px-3 py-1.5 text-sm transition-colors " +
(tab === entry.key
? "border-accent bg-accent/10 text-slate-100"
: "border-surface-border text-slate-400 hover:text-slate-200")
}
>
{entry.label}
</button>
))}
<div className="ms-auto">
{tab !== "knowledge" && (
<Button size="sm" onClick={() => setTeachOpen(true)}>
به سازمان یاد بده
</Button>
)}
</div>
</div>
{error && <ErrorState message={error} onRetry={load} />}
{tab === "knowledge" ? (
<>
<Card>
<CardTitle>افزودن منبع متنی</CardTitle>
<CardHint>
منابع دانش اسناد بلند هستند: متن را یک‌جا می‌دهیم، سامانه آن را تکه‌تکه و
قابل جست‌وجو می‌کند. یادداشت‌های کوتاه‌تر را در تب‌های دیگر بیاموزید.
</CardHint>
<form onSubmit={addSource} className="mt-4 flex flex-col gap-3">
<Field
label="نام منبع"
value={sourceName}
onChange={(e) => setSourceName(e.target.value)}
placeholder="مثلاً قواعد کدنویسی"
required
/>
<label className="text-sm text-slate-300">
<span className="mb-1 block text-xs text-slate-400">متن</span>
<textarea
value={sourceText}
onChange={(e) => setSourceText(e.target.value)}
rows={5}
className="w-full rounded-lg border border-surface-border bg-surface px-3 py-2 text-sm text-slate-100"
placeholder="متن منبع دانش…"
required
/>
</label>
<div>
<Button type="submit" loading={busy === "source"}>
افزودن منبع
</Button>
</div>
</form>
</Card>
{sources.length === 0 ? (
<EmptyState
title="منبعی ثبت نشده است"
description="متن‌های بلند سازمان را اینجا بگذارید تا کارمندها در کارهایشان از آن‌ها استفاده کنند."
/>
) : (
<Card>
<CardTitle>منابع دانش</CardTitle>
<div className="mt-4 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-surface-border text-xs text-slate-400">
<th className="px-2 py-2 text-right font-medium">نام</th>
<th className="px-2 py-2 text-right font-medium">نوع</th>
<th className="px-2 py-2 text-right font-medium">تکه‌ها</th>
<th className="px-2 py-2 text-right font-medium">ایجاد</th>
<th className="px-2 py-2 text-right font-medium" />
</tr>
</thead>
<tbody>
{sources.map((source) => (
<tr key={source.id} className="border-b border-surface-border/60">
<td className="px-2 py-2 text-slate-200">{source.name}</td>
<td className="px-2 py-2 text-slate-400">{source.kind}</td>
<td className="px-2 py-2 text-slate-400">
{source.chunk_count.toLocaleString("fa-IR")}
</td>
<td className="px-2 py-2 text-slate-400">{formatDate(source.created_at)}</td>
<td className="px-2 py-2 text-end">
<Button
size="sm"
variant="ghost"
onClick={() => setPendingSource(source)}
>
حذف
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
</>
) : visible.length === 0 ? (
<EmptyState
title={tab === "lessons" ? "هنوز درسی ثبت نشده" : "هنوز چیزی یادشده نیست"}
description="با دکمهٔ «به سازمان یاد بده» یک قاعده یا ترجیح ثبت کنید؛ درس‌ها خودبه‌خود از کارهای تمام‌شده استخراج می‌شوند."
/>
) : (
<div className="flex flex-col gap-3">
{visible.map((item) => (
<Card key={item.id}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<Badge tone={KIND_TONES[item.kind] ?? "neutral"}>{item.kind_label}</Badge>
{item.pinned && <Badge tone="info">سنجاق‌شده</Badge>}
<span className="text-xs text-slate-500">{item.source_label}</span>
{item.key && (
<code className="rounded bg-surface-border/40 px-1.5 py-0.5 text-[11px] text-slate-400">
{item.key}
</code>
)}
{item.kind === "lesson" && (
<span className="text-[11px] text-slate-500">
اعتماد {Math.round(item.confidence * 100).toLocaleString("fa-IR")}٪
</span>
)}
</div>
<p className="whitespace-pre-wrap text-sm text-slate-200">{item.content}</p>
<p className="text-[11px] text-slate-500">
{item.hits > 0
? `${item.hits.toLocaleString("fa-IR")} بار در متن کارها آمده`
: "هنوز در کاری استفاده نشده"}
{" · "}
{formatDate(item.updated_at)}
{item.expires_at ? ` · انقضا ${formatDate(item.expires_at)}` : ""}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="secondary"
loading={busy === `pin-${item.id}`}
onClick={() => togglePin(item)}
>
{item.pinned ? "برداشتن سنجاق" : "سنجاق کردن"}
</Button>
{item.kind === "lesson" && item.confidence < 1 && (
<Button
size="sm"
variant="secondary"
loading={busy === `confirm-${item.id}`}
onClick={() => confirmLesson(item)}
>
تأیید درس
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => {
setEditing(item);
setEditContent(item.content);
}}
>
ویرایش
</Button>
<Button size="sm" variant="ghost" onClick={() => setPendingDelete(item)}>
حذف
</Button>
</div>
</div>
</Card>
))}
</div>
)}
<Modal
open={teachOpen}
title="به سازمان یاد بده"
description="یک قاعده یا یک ترجیح: یک جملهٔ کافی است."
onClose={() => setTeachOpen(false)}
>
<form onSubmit={teach} className="flex flex-col gap-4">
<Select
label="نوع"
value={teachKind}
onChange={(e) => setTeachKind(e.target.value as "rule" | "preference")}
options={memoryKinds
.filter((kind) => kind.key === "rule" || kind.key === "preference")
.map((kind) => ({ value: kind.key, label: kind.label }))}
/>
<label className="text-sm text-slate-300">
<span className="mb-1 block text-xs text-slate-400">متن یادداشت</span>
<textarea
value={teachContent}
onChange={(e) => setTeachContent(e.target.value)}
rows={4}
maxLength={1500}
className="w-full rounded-lg border border-surface-border bg-surface px-3 py-2 text-sm text-slate-100"
placeholder="مثلاً: پیش از هر تغییر، تست مربوطه را اجرا کن."
required
/>
</label>
<p className="text-xs text-slate-500">
یک جملهٔ کافی است. هیچ گذرواژه یا کلیدی در یادداشت‌ها ذخیره نمی‌شود.
</p>
<div className="flex justify-end gap-2">
<Button variant="ghost" type="button" onClick={() => setTeachOpen(false)}>
انصراف
</Button>
<Button type="submit" loading={busy === "teach"}>
یادآوری کن
</Button>
</div>
</form>
</Modal>
{editing && (
<Modal open title="ویرایش یادداشت" onClose={() => setEditing(null)}>
<form onSubmit={saveEdit} className="flex flex-col gap-4">
<label className="text-sm text-slate-300">
<span className="mb-1 block text-xs text-slate-400">متن</span>
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
rows={4}
maxLength={1500}
className="w-full rounded-lg border border-surface-border bg-surface px-3 py-2 text-sm text-slate-100"
required
/>
</label>
<div className="flex justify-end gap-2">
<Button variant="ghost" type="button" onClick={() => setEditing(null)}>
انصراف
</Button>
<Button type="submit" loading={busy === `edit-${editing.id}`}>
ذخیره
</Button>
</div>
</form>
</Modal>
)}
<ConfirmDialog
open={pendingDelete !== null}
title="حذف یادداشت"
description={
pendingDelete
? `«${pendingDelete.content.slice(0, 60)}…» از حافظهٔ سازمان برداشته شود؟`
: ""
}
confirmLabel="حذف"
onConfirm={() => pendingDelete && remove(pendingDelete)}
onCancel={() => setPendingDelete(null)}
/>
<ConfirmDialog
open={pendingSource !== null}
title="حذف منبع دانش"
description={
pendingSource ? `منبع «${pendingSource.name}» و تکه‌های متن آن حذف شود؟` : ""
}
confirmLabel="حذف"
onConfirm={() => pendingSource && removeSource(pendingSource)}
onCancel={() => setPendingSource(null)}
/>
<ToastStack
toasts={toasts}
onDismiss={(id) => setToasts((current) => current.filter((t) => t.id !== id))}
/>
</div>
);
}
+282 -3
View File
@@ -8,13 +8,17 @@ import {
Card,
CardHint,
CardTitle,
Checkbox,
ConfirmDialog,
EmptyState,
ErrorState,
Field,
LoadingState,
Modal,
Select,
Timeline,
ToastStack,
type ToastMessage,
} from "@agy/ui";
import { ApiError, api } from "@/lib/api";
import {
@@ -23,7 +27,14 @@ import {
CAN_VIEW_AUDIT,
roleHasCapability,
} from "@/lib/permissions";
import { appProfileApi, setupApi } from "@/lib/api-org";
import {
mcpApi,
appProfileApi,
setupApi,
type McpSettings,
type McpTokenCreated,
type McpTokenRow,
} from "@/lib/api-org";
import { useSession } from "@/lib/session";
const ROLE_LABELS: Record<Role, string> = {
@@ -64,7 +75,11 @@ const AUDIT_LABELS: Record<string, string> = {
"run.cancel": "لغو اجرا",
};
type Tab = "members" | "audit" | "app";
type Tab = "members" | "app" | "mcp" | "audit";
function errorText(err: unknown, fallback: string): string {
return err instanceof ApiError ? err.message : fallback;
}
/** Matches ``INVITE_TTL_DAYS`` in the API so the hint cannot drift. */
const INVITE_TTL_DAYS = 14;
@@ -101,6 +116,27 @@ export default function SettingsPage() {
const [busyMemberId, setBusyMemberId] = React.useState<string | null>(null);
const [pendingRemove, setPendingRemove] = React.useState<Member | null>(null);
// --- MCP integration ----------------------------------------------------
const [mcpTokens, setMcpTokens] = React.useState<McpTokenRow[]>([]);
const [mcpSettings, setMcpSettings] = React.useState<McpSettings | null>(null);
const [mcpLoading, setMcpLoading] = React.useState(false);
const [mcpName, setMcpName] = React.useState("");
const [mcpScopes, setMcpScopes] = React.useState<string[]>(["tasks:read"]);
const [mcpBusy, setMcpBusy] = React.useState(false);
const [mcpCreated, setMcpCreated] = React.useState<McpTokenCreated | null>(null);
const [pendingRevoke, setPendingRevoke] = React.useState<McpTokenRow | null>(null);
const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
const toast = React.useCallback(
(title: string, tone: ToastMessage["tone"] = "success", description?: string) => {
setToasts((current) => [
...current,
{ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, title, tone, description },
]);
},
[],
);
const load = React.useCallback(async () => {
if (!orgId) {
setLoading(false);
@@ -129,13 +165,36 @@ export default function SettingsPage() {
}
}, [orgId]);
const loadMcp = React.useCallback(async () => {
if (!orgId) return;
setMcpLoading(true);
try {
const [tokens, settings] = await Promise.all([
mcpApi.list(orgId),
mcpApi.settings(orgId),
]);
setMcpTokens(tokens);
setMcpSettings(settings);
setMcpScopes((current) =>
current.filter((scope) => (settings.scopes as string[]).includes(scope)).length > 0
? current
: (settings.scopes.slice(0, 1) as string[]),
);
} catch {
setMcpSettings(null);
} finally {
setMcpLoading(false);
}
}, [orgId]);
React.useEffect(() => {
void load();
}, [load]);
React.useEffect(() => {
if (tab === "audit") void loadAudit();
}, [tab, loadAudit]);
if (tab === "mcp") void loadMcp();
}, [tab, loadAudit, loadMcp]);
const onInvite = async (event: React.FormEvent) => {
event.preventDefault();
@@ -197,6 +256,45 @@ export default function SettingsPage() {
}
};
// --- MCP ----------------------------------------------------------------
const onCreateToken = async (event: React.FormEvent) => {
event.preventDefault();
if (!orgId || !mcpName.trim() || mcpScopes.length === 0) return;
setMcpBusy(true);
try {
const created = await mcpApi.create(orgId, {
name: mcpName.trim(),
scopes: mcpScopes,
});
setMcpCreated(created);
setMcpTokens((current) => [created, ...current]);
setMcpName("");
toast("توکن ساخته شد", "success", "توکن فقط یک بار نمایش داده می‌شود؛ آن را ذخیره کنید.");
} catch (err) {
toast("ساخت توکن ناموفق بود", "danger", errorText(err, "دوباره تلاش کنید."));
} finally {
setMcpBusy(false);
}
};
const onRevokeToken = async (token: McpTokenRow) => {
if (!orgId) return;
setMcpBusy(true);
try {
await mcpApi.revoke(orgId, token.id);
setMcpTokens((current) =>
current.map((row) => (row.id === token.id ? { ...row, status: "revoked" } : row)),
);
setPendingRevoke(null);
toast("دسترسی توکن لغو شد");
} catch (err) {
toast("لغو توکن ناموفق بود", "danger", errorText(err, "دوباره تلاش کنید."));
} finally {
setMcpBusy(false);
}
};
if (!activeOrganization) {
return (
<EmptyState
@@ -209,6 +307,8 @@ export default function SettingsPage() {
const canManageMembers = roleHasCapability(activeOrganization.role, CAN_MANAGE_MEMBERS);
const canViewAudit = roleHasCapability(activeOrganization.role, CAN_VIEW_AUDIT);
const canManageProjects = roleHasCapability(activeOrganization.role, CAN_MANAGE_PROJECTS);
const canManageMcp =
activeOrganization.role === "owner" || activeOrganization.role === "admin";
return (
<div className="anim-stagger flex flex-col gap-5">
@@ -222,6 +322,7 @@ export default function SettingsPage() {
[
["members", "اعضا و دعوت"],
["app", "اپلیکیشن"],
["mcp", "یکپارچه‌سازی (MCP)"],
["audit", "رویدادهای حسابرسی"],
] as const
).map(([value, label]) => (
@@ -378,6 +479,134 @@ export default function SettingsPage() {
</>
) : tab === "app" ? (
<AppTab orgId={activeOrganization.id} canEdit={canManageProjects} />
) : tab === "mcp" ? (
<div className="flex flex-col gap-4">
<Card>
<CardTitle>یکپارچه‌سازی (MCP)</CardTitle>
<CardHint>
با یک توکن، عامل‌های هوش مصنوعی بیرونی می‌توانند در همین سازمان کار کنند:
وظایف بسازند، حافظه را جست‌وجو کنند و اپلیکیشن را بخوانند. توکن نشان می‌دهد
که عامل از طرف کدام سازمان حرف می‌زند — هرگز برعکس.
</CardHint>
{mcpSettings && (!mcpSettings.gateway_enabled || !mcpSettings.mcp_enabled) && (
<p className="mt-3 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
{!mcpSettings.gateway_enabled
? "دسترسی MCP سامانه با دستور مالک سامانه خاموش است؛ توکن‌ها تا روشن‌شدن آن کار نمی‌کنند."
: "دسترسی MCP این سازمان خاموش است؛ برای روشن‌کردن آن با مالک سامانه تماس بگیرید."}
</p>
)}
{mcpLoading ? (
<LoadingState label="در حال دریافت توکن‌ها…" />
) : mcpTokens.length === 0 ? (
<EmptyState
title="توکنی ساخته نشده است"
description="توکن‌ها دسترسی یک عامل بیرونی را به همین سازمان محدود می‌کنند."
/>
) : (
<div className="mt-4 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-surface-border text-xs text-slate-400">
<th className="px-2 py-2 text-right font-medium">نام</th>
<th className="px-2 py-2 text-right font-medium">توکن</th>
<th className="px-2 py-2 text-right font-medium">محدوده‌ها</th>
<th className="px-2 py-2 text-right font-medium">وضعیت</th>
<th className="px-2 py-2 text-right font-medium" />
</tr>
</thead>
<tbody className="anim-stagger">
{mcpTokens.map((token) => (
<tr key={token.id} className="border-b border-surface-border/60">
<td className="px-2 py-2 text-slate-200">{token.name}</td>
<td className="px-2 py-2 text-slate-400" dir="ltr">
{token.prefix}…
</td>
<td className="px-2 py-2 text-xs text-slate-400" dir="ltr">
{token.scopes.join(", ")}
</td>
<td className="px-2 py-2">
<Badge
tone={
token.status === "active"
? "success"
: token.status === "expired"
? "warning"
: "neutral"
}
>
{token.status === "active"
? "فعال"
: token.status === "expired"
? "منقضی"
: "لغو شده"}
</Badge>
</td>
<td className="px-2 py-2 text-end">
{canManageMcp && token.status === "active" && (
<Button
size="sm"
variant="ghost"
onClick={() => setPendingRevoke(token)}
>
لغو
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
{canManageMcp && (
<Card>
<CardTitle>توکن تازه</CardTitle>
<CardHint>
توکن فقط یک بار، همین‌جا، نمایش داده می‌شود. محدوده‌ها را کمترین چیزی
بگیرید که عامل برای کارش لازم دارد.
</CardHint>
<form onSubmit={onCreateToken} className="mt-4 flex flex-col gap-4">
<Field
label="نام توکن"
value={mcpName}
onChange={(e) => setMcpName(e.target.value)}
placeholder="مثلاً عامل CI"
required
/>
<div>
<p className="mb-2 text-xs text-slate-400">محدوده‌های دسترسی</p>
<div className="grid gap-2 sm:grid-cols-2">
{(mcpSettings?.scopes ?? []).map((scope) => (
<Checkbox
key={scope}
label={scope}
checked={mcpScopes.includes(scope)}
onChange={(e) =>
setMcpScopes((current) =>
e.target.checked
? [...current, scope]
: current.filter((item) => item !== scope),
)
}
/>
))}
</div>
</div>
<div>
<Button
type="submit"
loading={mcpBusy}
disabled={mcpScopes.length === 0 || !mcpName.trim()}
>
ساخت توکن
</Button>
</div>
</form>
</Card>
)}
</div>
) : (
<Card>
<CardTitle>رویدادهای حسابرسی</CardTitle>
@@ -413,6 +642,56 @@ export default function SettingsPage() {
</Card>
)}
<Modal
open={mcpCreated !== null}
title="توکن ساخته شد"
description="این توکن فقط حالا نمایش داده می‌شود؛ بعداً هیچ راهی برای دیدن آن نیست."
onClose={() => setMcpCreated(null)}
>
<div className="flex flex-col gap-3">
<code
dir="ltr"
className="block break-all rounded-lg border border-surface-border bg-surface px-3 py-2 text-xs text-slate-100"
>
{mcpCreated?.token}
</code>
<div className="flex justify-end">
<Button
size="sm"
variant="secondary"
onClick={() => {
if (mcpCreated) void navigator.clipboard.writeText(mcpCreated.token);
toast("توکن کپی شد");
}}
>
کپی توکن
</Button>
</div>
<p className="text-xs text-slate-500">
این توکن را در تنظیمات عامل خود بگذارید (هدر Authorization با مقدار Bearer).
اگر گم شد، توکن را لغو و یکی دیگر بسازید.
</p>
</div>
</Modal>
<ConfirmDialog
open={pendingRevoke !== null}
title="لغو توکن"
description={
pendingRevoke
? `دسترسی «${pendingRevoke.name}» به این سازمان فوراً قطع می‌شود.`
: undefined
}
confirmLabel="لغو توکن"
onConfirm={() => pendingRevoke && void onRevokeToken(pendingRevoke)}
onCancel={() => setPendingRevoke(null)}
/>
<ToastStack
toasts={toasts}
onDismiss={(id) => setToasts((current) => current.filter((t) => t.id !== id))}
/>
<ConfirmDialog
open={pendingRemove !== null}
title="حذف عضو"
+325
View File
@@ -22,6 +22,57 @@ import type {
import { apiRequest } from "./api";
/* -------------------------------------------------------------------------- */
/* Owner model grants + org policy */
/* -------------------------------------------------------------------------- */
/**
* One catalogue model as the owner sees it, plus whether an organization has it.
*
* ``label`` is the owner-facing model name; organization users never see this
* list — for them a model is only ever a tier («سریع» / «متعادل» / «قوی»).
*/
export interface ModelGrantRow {
ai_model_id: string;
label: string;
provider_id: string;
capabilities: string[];
tier: string;
tier_label: string;
output_price_per_1m: number;
model_active: boolean;
granted: boolean;
daily_credit_cap: number | null;
}
/** An active grant, as the engine would see it (no model name). */
export interface ActiveGrantRow {
ai_model_id: string;
label: string;
tier: string;
tier_label: string;
capabilities: string[];
daily_credit_cap: number | null;
is_active: boolean;
}
/** The limits the owner sets for one organization. */
export interface OrgPolicyView {
organization_id: string;
max_employees: number;
monthly_credit_budget: number | null;
automation_enabled: boolean;
mcp_enabled: boolean;
/** The platform ceiling for ``max_employees`` (``ENGINE_MAX_EMPLOYEES``). */
max_employees_limit: number;
}
export interface OrgGrantsView {
organization_id: string;
models: ModelGrantRow[];
}
/** The platform owner's account invitations (split into organizations of the future). */
export const orgApi = {
/** Create an invitation link for a new manager account. */
@@ -76,6 +127,280 @@ export const orgApi = {
`/api/organizations/${orgId}/invites/${encodeURIComponent(inviteId)}`,
{ method: "DELETE" },
),
// --- owner model grants --------------------------------------------------
listOrgGrants: (orgId: string) =>
apiRequest<OrgGrantsView>(`/api/admin/model-grants/${encodeURIComponent(orgId)}`),
listActiveGrants: (orgId: string) =>
apiRequest<ActiveGrantRow[]>(
`/api/admin/model-grants/${encodeURIComponent(orgId)}/active`,
),
setOrgGrant: (orgId: string, modelId: string, dailyCreditCap: number | null) =>
apiRequest<ActiveGrantRow>(
`/api/admin/model-grants/${encodeURIComponent(orgId)}/models/${encodeURIComponent(modelId)}`,
{ method: "PUT", body: { daily_credit_cap: dailyCreditCap } },
),
revokeOrgGrant: (orgId: string, modelId: string) =>
apiRequest<{ organization_id: string; ai_model_id: string; granted: boolean }>(
`/api/admin/model-grants/${encodeURIComponent(orgId)}/models/${encodeURIComponent(modelId)}`,
{ method: "DELETE" },
),
// --- the default set copied into every new organization ------------------
listDefaultGrants: () => apiRequest<ModelGrantRow[]>("/api/admin/default-model-grants"),
setDefaultGrant: (
modelId: string,
body: { is_active: boolean; daily_credit_cap: number | null },
) =>
apiRequest<ModelGrantRow>(
`/api/admin/default-model-grants/${encodeURIComponent(modelId)}`,
{ method: "PUT", body },
),
removeDefaultGrant: (modelId: string) =>
apiRequest<{ ai_model_id: string; in_default_set: boolean }>(
`/api/admin/default-model-grants/${encodeURIComponent(modelId)}`,
{ method: "DELETE" },
),
// --- org policy ----------------------------------------------------------
getOrgPolicy: (orgId: string) =>
apiRequest<OrgPolicyView>(`/api/admin/org-policies/${encodeURIComponent(orgId)}`),
updateOrgPolicy: (
orgId: string,
body: Partial<
Pick<
OrgPolicyView,
"max_employees" | "monthly_credit_budget" | "automation_enabled" | "mcp_enabled"
>
>,
) =>
apiRequest<OrgPolicyView>(`/api/admin/org-policies/${encodeURIComponent(orgId)}`, {
method: "PUT",
body,
}),
};
/* -------------------------------------------------------------------------- */
/* Org memory + knowledge sources */
/* -------------------------------------------------------------------------- */
/**
* One row of the organization's memory: a rule, a preference, a lesson, a fact
* or a code-map note.
*
* ``key`` is the stable handle a rule can be addressed by (the same key always
* corrects the same entry), ``confidence`` is a lesson's 0..1 score, and
* ``hits`` counts how many tasks have been built with this entry in context.
*/
export interface MemoryRow {
id: string;
kind: string;
kind_label: string;
key: string | null;
content: string;
source: string;
source_label: string;
evidence_task_id: string | null;
pinned: boolean;
confidence: number;
expires_at: string | null;
hits: number;
last_used_at: string | null;
created_by_user_id: string | null;
created_at: string | null;
updated_at: string | null;
}
export interface MemoryList {
organization_id: string;
items: MemoryRow[];
total: number;
pinned: number;
chars: number;
}
/** A knowledge-base source, shown as the memory page's second family of tabs. */
export interface KnowledgeSourceRow {
id: string;
name: string;
kind: string;
project_id: string | null;
created_at: string;
chunk_count: number;
}
export const memoryKinds = [
{ key: "rule", label: "قاعده" },
{ key: "preference", label: "ترجیح" },
{ key: "lesson", label: "درس" },
{ key: "fact", label: "واقعیت" },
{ key: "code_map", label: "نقشهٔ کد" },
] as const;
export const memoryApi = {
// --- memory -------------------------------------------------------------
list: (orgId: string, params: { kind?: string; q?: string } = {}) =>
apiRequest<MemoryList>(
`/api/organizations/${encodeURIComponent(orgId)}/memory?${new URLSearchParams(
Object.fromEntries(
Object.entries(params).filter(([, value]) => Boolean(value)),
) as Record<string, string>,
).toString()}`,
),
teach: (
orgId: string,
body: { kind: string; content: string; key?: string | null },
) =>
apiRequest<MemoryRow>(`/api/organizations/${encodeURIComponent(orgId)}/memory`, {
method: "POST",
body,
}),
edit: (orgId: string, memoryId: string, body: { content: string; key?: string | null }) =>
apiRequest<MemoryRow>(
`/api/organizations/${encodeURIComponent(orgId)}/memory/${encodeURIComponent(memoryId)}`,
{ method: "PATCH", body },
),
setPinned: (orgId: string, memoryId: string, pinned: boolean) =>
apiRequest<MemoryRow>(
`/api/organizations/${encodeURIComponent(orgId)}/memory/${encodeURIComponent(memoryId)}/pin`,
{ method: "POST", body: { pinned } },
),
confirm: (orgId: string, memoryId: string) =>
apiRequest<MemoryRow>(
`/api/organizations/${encodeURIComponent(orgId)}/memory/${encodeURIComponent(memoryId)}/confirm`,
{ method: "POST" },
),
remove: (orgId: string, memoryId: string) =>
apiRequest<void>(
`/api/organizations/${encodeURIComponent(orgId)}/memory/${encodeURIComponent(memoryId)}`,
{ method: "DELETE" },
),
// --- knowledge sources (unchanged endpoints, shown as a second tab) ------
listKnowledge: (orgId: string) =>
apiRequest<KnowledgeSourceRow[]>(
`/api/organizations/${encodeURIComponent(orgId)}/knowledge/sources`,
),
createKnowledge: (
orgId: string,
body: { name: string; content: string; title?: string },
) =>
apiRequest<KnowledgeSourceRow>(
`/api/organizations/${encodeURIComponent(orgId)}/knowledge/sources`,
{ method: "POST", body },
),
removeKnowledge: (orgId: string, sourceId: string) =>
apiRequest<void>(
`/api/organizations/${encodeURIComponent(orgId)}/knowledge/sources/${encodeURIComponent(sourceId)}`,
{ method: "DELETE" },
),
};
/* -------------------------------------------------------------------------- */
/* Org memory realtime */
/* -------------------------------------------------------------------------- */
/**
* The event the API publishes on ``org:<id>`` whenever memory changes.
*
* Declared here rather than in ``@agy/contracts``: the event vocabulary is a
* frozen, foundation-owned list, so a page compares the raw string instead of
* widening the union for one new type.
*/
export const MEMORY_CHANGED_EVENT = "memory.changed";
/* -------------------------------------------------------------------------- */
/* MCP tokens (org admin) + the owner's switches */
/* -------------------------------------------------------------------------- */
/** Every scope the gateway understands, as the mint dialog offers it. */
export const MCP_SCOPES = [
"app:read",
"tasks:read",
"tasks:write",
"memory:read",
"memory:write",
"delivery:read",
] as const;
export type McpScope = (typeof MCP_SCOPES)[number];
/**
* A token as the UI sees it: the prefix, the scopes, the status.
*
* The raw token exists only on the create response (``McpTokenCreated``), which
* is why that type is separate: nothing else in the app can produce one.
*/
export interface McpTokenRow {
id: string;
organization_id: string;
name: string;
prefix: string;
scopes: McpScope[];
expires_at: string | null;
last_used_at: string | null;
revoked_at: string | null;
created_at: string | null;
status: "active" | "expired" | "revoked";
}
export interface McpTokenCreated extends McpTokenRow {
/** Shown once, in the modal, with a copy button. Never stored by the client. */
token: string;
}
export interface McpSettings {
organization_id: string;
mcp_enabled: boolean;
gateway_enabled: boolean;
scopes: McpScope[];
token_count: number;
}
export interface McpOrgState {
organization_id: string;
name: string;
slug: string;
mcp_enabled: boolean;
token_count: number;
}
export interface McpAdminState {
gateway_enabled: boolean;
organizations: McpOrgState[];
total_tokens: number;
}
export const mcpApi = {
list: (orgId: string) =>
apiRequest<McpTokenRow[]>(`/api/organizations/${encodeURIComponent(orgId)}/mcp-tokens`),
create: (orgId: string, body: { name: string; scopes: string[]; ttl_days?: number }) =>
apiRequest<McpTokenCreated>(
`/api/organizations/${encodeURIComponent(orgId)}/mcp-tokens`,
{ method: "POST", body },
),
revoke: (orgId: string, tokenId: string) =>
apiRequest<{ revoked: boolean }>(
`/api/organizations/${encodeURIComponent(orgId)}/mcp-tokens/${encodeURIComponent(tokenId)}`,
{ method: "DELETE" },
),
settings: (orgId: string) =>
apiRequest<McpSettings>(
`/api/organizations/${encodeURIComponent(orgId)}/mcp-tokens/settings`,
),
// --- owner ---------------------------------------------------------------
adminState: () => apiRequest<McpAdminState>("/api/admin/mcp"),
setGateway: (enabled: boolean) =>
apiRequest<{ gateway_enabled: boolean }>("/api/admin/mcp", {
method: "PUT",
body: { enabled },
}),
setOrgEnabled: (orgId: string, enabled: boolean) =>
apiRequest<{ mcp_enabled: boolean }>(
`/api/admin/mcp/organizations/${encodeURIComponent(orgId)}`,
{ method: "PUT", body: { enabled } },
),
};
+240
View File
@@ -0,0 +1,240 @@
/**
* MCP integration: the org settings tab and the owner's gateway switches.
*
* The security-relevant assertion is the show-once token: the raw value comes
* back from the create call and nowhere else, so the list table must never carry
* it, and the modal that shows it is the only place a user can copy it.
*/
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
class FakeApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
return {
FakeApiError,
api: {
listMembers: vi.fn(),
listAudit: vi.fn(),
updateMember: vi.fn(),
removeMember: vi.fn(),
},
orgApi: {
listOrgInvites: vi.fn(),
getAppProfile: vi.fn(),
patchAppProfile: vi.fn(),
transferOwnership: vi.fn(),
leaveOrganization: vi.fn(),
},
mcpApi: {
list: vi.fn(),
settings: vi.fn(),
create: vi.fn(),
revoke: vi.fn(),
adminState: vi.fn(),
setGateway: vi.fn(),
setOrgEnabled: vi.fn(),
},
session: {
activeOrganization: { id: "org-1", name: "Acme", slug: "acme", role: "admin" as const },
refresh: vi.fn(async () => {}),
},
router: { replace: vi.fn() },
clipboard: { writeText: vi.fn(async () => {}) },
channels: [] as string[][],
};
});
vi.mock("next/navigation", () => ({ useRouter: () => mocks.router }));
vi.mock("@/lib/session", () => ({ useSession: () => mocks.session }));
vi.mock("@/lib/api", () => ({ ApiError: mocks.FakeApiError, api: mocks.api }));
vi.mock("@/lib/api-org", () => ({
orgApi: mocks.orgApi,
mcpApi: mocks.mcpApi,
MCP_SCOPES: [
"app:read",
"tasks:read",
"tasks:write",
"memory:read",
"memory:write",
"delivery:read",
],
memoryApi: { list: vi.fn(), listKnowledge: vi.fn() },
MEMORY_CHANGED_EVENT: "memory.changed",
memoryKinds: [],
useNeedsOnboarding: () => ({ needsOnboarding: false, ready: true }),
}));
vi.mock("@/lib/realtime", () => ({
useLiveChannel: (channels: string[], onEvent: (event: unknown) => void) => {
mocks.channels.push(channels);
},
}));
import AdminMcpPage from "@/app/(app)/admin/mcp/page";
import SettingsPage from "@/app/(app)/settings/page";
const TOKEN_ROW = {
id: "tok-1",
organization_id: "org-1",
name: "agent CI",
prefix: "smo_abc123",
scopes: ["tasks:read"],
expires_at: "2026-06-01T00:00:00Z",
last_used_at: null,
revoked_at: null,
created_at: "2026-01-01T00:00:00Z",
status: "active" as const,
};
beforeEach(() => {
vi.clearAllMocks();
mocks.channels = [];
Object.defineProperty(window.navigator, "clipboard", {
value: mocks.clipboard,
configurable: true,
});
mocks.session.activeOrganization = {
id: "org-1",
name: "Acme",
slug: "acme",
role: "admin" as const,
};
mocks.api.listMembers.mockResolvedValue([]);
mocks.api.listAudit.mockResolvedValue([]);
mocks.orgApi.listOrgInvites.mockResolvedValue([]);
mocks.orgApi.getAppProfile.mockRejectedValue(new mocks.FakeApiError(404, "nf", "no app"));
mocks.mcpApi.list.mockResolvedValue([TOKEN_ROW]);
mocks.mcpApi.settings.mockResolvedValue({
organization_id: "org-1",
mcp_enabled: true,
gateway_enabled: true,
scopes: [
"app:read",
"tasks:read",
"tasks:write",
"memory:read",
"memory:write",
"delivery:read",
],
token_count: 1,
});
mocks.mcpApi.adminState.mockResolvedValue({
gateway_enabled: true,
organizations: [
{
organization_id: "org-1",
name: "Acme",
slug: "acme",
mcp_enabled: true,
token_count: 1,
},
],
total_tokens: 1,
});
});
describe("settings — MCP integration", () => {
it("lists tokens by prefix and never shows the raw token", async () => {
render(<SettingsPage />);
fireEvent.click(await screen.findByRole("button", { name: "یکپارچه‌سازی (MCP)" }));
expect(await screen.findByText("agent CI")).toBeInTheDocument();
// Only the prefix is rendered — a nine-character handle, never a secret.
expect(screen.getByText("smo_abc123…")).toBeInTheDocument();
expect(screen.queryByText(/smo_[A-Za-z0-9_\-]{20,}/)).not.toBeInTheDocument();
});
it("shows a minted token exactly once, with a copy button", async () => {
mocks.mcpApi.create.mockResolvedValue({
...TOKEN_ROW,
id: "tok-2",
token: "smo_SEKRET-VALUE-1234567890abcdef",
});
render(<SettingsPage />);
fireEvent.click(await screen.findByRole("button", { name: "یکپارچه‌سازی (MCP)" }));
fireEvent.change(await screen.findByLabelText("نام توکن"), {
target: { value: "agent جدید" },
});
fireEvent.click(screen.getByRole("button", { name: "ساخت توکن" }));
const dialog = await screen.findByRole("dialog", { name: "توکن ساخته شد" });
expect(within(dialog).getByText("smo_SEKRET-VALUE-1234567890abcdef")).toBeInTheDocument();
fireEvent.click(within(dialog).getByRole("button", { name: "کپی توکن" }));
await waitFor(() =>
expect(mocks.clipboard.writeText).toHaveBeenCalledWith("smo_SEKRET-VALUE-1234567890abcdef"),
);
// After closing, the raw value is gone from the page for good.
fireEvent.click(within(dialog).getByRole("button", { name: "بستن" }));
await waitFor(() =>
expect(screen.queryByText("smo_SEKRET-VALUE-1234567890abcdef")).not.toBeInTheDocument(),
);
});
it("requires a scope before minting, and confirms before revoking", async () => {
render(<SettingsPage />);
fireEvent.click(await screen.findByRole("button", { name: "یکپارچه‌سازی (MCP)" }));
// Deselect the pre-checked scope: mint is disabled without one.
fireEvent.change(await screen.findByLabelText("نام توکن"), {
target: { value: "بدون محدوده" },
});
const checkbox = document.getElementById("check-tasks:read") as HTMLInputElement;
expect(checkbox.checked).toBe(true);
fireEvent.click(checkbox);
expect(checkbox.checked).toBe(false);
expect(screen.getByRole("button", { name: "ساخت توکن" })).toBeDisabled();
// …and selectable again, which is what re-enables it.
fireEvent.click(checkbox);
expect(screen.getByRole("button", { name: "ساخت توکن" })).toBeEnabled();
mocks.mcpApi.revoke.mockResolvedValue({ revoked: true });
fireEvent.click(screen.getByRole("button", { name: "لغو" }));
const dialog = await screen.findByRole("dialog", { name: "لغو توکن" });
fireEvent.click(dialog.querySelectorAll("button")[1]);
await waitFor(() => expect(mocks.mcpApi.revoke).toHaveBeenCalledWith("org-1", "tok-1"));
});
});
describe("admin — MCP switches", () => {
it("shows the gateway state and switches off one organization", async () => {
mocks.mcpApi.setOrgEnabled.mockResolvedValue({ mcp_enabled: false });
render(<AdminMcpPage />);
expect(await screen.findByText("Acme")).toBeInTheDocument();
expect(screen.getByText("روشن")).toBeInTheDocument();
// Turning one organization off asks first — it is a destructive switch.
fireEvent.click(screen.getByRole("button", { name: "خاموش کن" }));
const dialog = await screen.findByRole("dialog", { name: "خاموش‌کردن دسترسی یک سازمان" });
fireEvent.click(dialog.querySelectorAll("button")[1]);
await waitFor(() =>
expect(mocks.mcpApi.setOrgEnabled).toHaveBeenCalledWith("org-1", false),
);
});
it("turns the whole gateway off with a confirmation", async () => {
mocks.mcpApi.setGateway.mockResolvedValue({ gateway_enabled: false });
render(<AdminMcpPage />);
const button = await screen.findByRole("button", {
name: "خاموش‌کردن دسترسی MCP سامانه",
});
fireEvent.click(button);
await waitFor(() => expect(mocks.mcpApi.setGateway).toHaveBeenCalledWith(false));
expect(
await screen.findByRole("button", { name: "روشن‌کردن دسترسی MCP" }),
).toBeInTheDocument();
});
});
+216
View File
@@ -0,0 +1,216 @@
/**
* Org memory page.
*
* Four tabs over one organization's memory, a "teach the organization" dialog
* that is a single sentence, and a live refresh on ``memory.changed``. The
* security-relevant assertion here is the payload one: a credential typed into
* the teach box must never reach the API — the server redacts, and the client
* must not smuggle it into a key field either.
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
class FakeApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
return {
FakeApiError,
api: {},
memoryApi: {
list: vi.fn(),
teach: vi.fn(),
edit: vi.fn(),
setPinned: vi.fn(),
confirm: vi.fn(),
remove: vi.fn(),
listKnowledge: vi.fn(),
createKnowledge: vi.fn(),
removeKnowledge: vi.fn(),
},
session: {
activeOrganization: { id: "org-1", name: "Acme", slug: "acme", role: "admin" as const },
refresh: vi.fn(async () => {}),
},
router: { replace: vi.fn() },
subscriptions: [] as string[][],
handlers: [] as ((event: unknown) => void)[],
};
});
vi.mock("next/navigation", () => ({ useRouter: () => mocks.router }));
vi.mock("@/lib/session", () => ({ useSession: () => mocks.session }));
vi.mock("@/lib/api", () => ({ ApiError: mocks.FakeApiError, api: mocks.api }));
vi.mock("@/lib/api-org", () => ({
MEMORY_CHANGED_EVENT: "memory.changed",
memoryApi: mocks.memoryApi,
memoryKinds: [
{ key: "rule", label: "قاعده" },
{ key: "preference", label: "ترجیح" },
{ key: "lesson", label: "درس" },
{ key: "fact", label: "واقعیت" },
{ key: "code_map", label: "نقشهٔ کد" },
],
}));
vi.mock("@/lib/realtime", () => ({
useLiveChannel: (channels: string[], onEvent: (event: unknown) => void) => {
mocks.subscriptions.push(channels);
mocks.handlers.push(onEvent);
},
}));
import MemoryPage from "@/app/(app)/memory/page";
const RULE = {
id: "m-rule",
kind: "rule",
kind_label: "قاعده",
key: "tests-first",
content: "پیش از هر تغییر، تست مربوطه را اجرا کن.",
source: "user",
source_label: "کاربر",
evidence_task_id: null,
pinned: true,
confidence: 1,
expires_at: null,
hits: 3,
last_used_at: "2026-02-01T00:00:00Z",
created_by_user_id: "u-1",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T00:00:00Z",
};
const LESSON = {
...RULE,
id: "m-lesson",
kind: "lesson",
kind_label: "درس",
key: null,
content: "دروازهٔ lint دو بار شکست؛ پیش از ارسال، lint محلی را اجرا کن.",
source: "reflection",
source_label: "بازتاب",
confidence: 0.5,
pinned: false,
expires_at: "2026-02-15T00:00:00Z",
evidence_task_id: "task-9",
};
const KNOWLEDGE = {
id: "src-1",
name: "قواعد کدنویسی",
kind: "text",
project_id: null,
created_at: "2026-01-05T00:00:00Z",
chunk_count: 4,
};
beforeEach(() => {
vi.clearAllMocks();
mocks.subscriptions = [];
mocks.handlers = [];
mocks.memoryApi.list.mockResolvedValue({
organization_id: "org-1",
items: [RULE, LESSON],
total: 2,
pinned: 1,
chars: 180,
});
mocks.memoryApi.listKnowledge.mockResolvedValue([KNOWLEDGE]);
});
describe("memory page", () => {
it("shows rules and lessons on their own tabs", async () => {
render(<MemoryPage />);
expect(await screen.findByText(RULE.content)).toBeInTheDocument();
// The lesson lives on its own tab, not in the rules list.
expect(screen.queryByText(LESSON.content)).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "درس‌های آموخته" }));
expect(await screen.findByText(LESSON.content)).toBeInTheDocument();
// A reflection-sourced lesson carries its half-confidence in the UI.
expect(screen.getByText(/اعتماد ۵۰/)).toBeInTheDocument();
expect(screen.queryByText(RULE.content)).not.toBeInTheDocument();
});
it("teaches a rule with one sentence and never puts a secret in the payload", async () => {
mocks.memoryApi.teach.mockResolvedValue({ ...RULE, id: "m-new", content: "یادداشت تازه" });
render(<MemoryPage />);
fireEvent.click(await screen.findByRole("button", { name: "به سازمان یاد بده" }));
const dialog = await screen.findByRole("dialog");
// A credential pasted into the teach box is a user error the server must
// catch; the client's job is to send exactly what was typed and nothing else.
fireEvent.change(dialog.querySelector("textarea")!, {
target: { value: "توکن CI من ghp_ABCDEFGHIJKLMNOPQRSTUVWXyz123 است" },
});
fireEvent.click(dialog.querySelector('button[type="submit"]')!);
await waitFor(() => expect(mocks.memoryApi.teach).toHaveBeenCalledTimes(1));
const [orgId, payload] = mocks.memoryApi.teach.mock.calls[0];
expect(orgId).toBe("org-1");
expect(payload.kind).toBe("rule");
// Only kind/content are ever sent (no key is invented), and there is no
// field a secret could hide in.
expect(Object.keys(payload).sort()).toEqual(["content", "kind"]);
});
it("pins a rule and asks for confirmation before deleting one", async () => {
mocks.memoryApi.setPinned.mockResolvedValue({ ...RULE, pinned: false });
mocks.memoryApi.remove.mockResolvedValue(undefined);
render(<MemoryPage />);
fireEvent.click(await screen.findByRole("button", { name: "برداشتن سنجاق" }));
await waitFor(() =>
expect(mocks.memoryApi.setPinned).toHaveBeenCalledWith("org-1", "m-rule", false),
);
fireEvent.click(screen.getByRole("button", { name: "حذف" }));
const dialog = await screen.findByRole("dialog", { name: "حذف یادداشت" });
const confirm = dialog.querySelector('button') as HTMLButtonElement;
// The confirm button is the dangerous one; the dialog explains the damage.
expect(confirm).toBeDefined();
expect(screen.getByText(/از حافظهٔ سازمان برداشته شود/)).toBeInTheDocument();
fireEvent.click(dialog.querySelectorAll("button")[1]);
await waitFor(() => expect(mocks.memoryApi.remove).toHaveBeenCalledWith("org-1", "m-rule"));
});
it("refreshes when the org channel says memory changed", async () => {
render(<MemoryPage />);
await screen.findByText(RULE.content);
// The page subscribes to its own tenant's channel only.
expect(mocks.subscriptions.at(-1)).toEqual(["org:org-1"]);
const callsBefore = mocks.memoryApi.list.mock.calls.length;
mocks.handlers.at(-1)!({ type: "memory.changed", data: { kind: "lesson" } });
await waitFor(() =>
expect(mocks.memoryApi.list.mock.calls.length).toBeGreaterThan(callsBefore),
);
// An unrelated event does not cause a reload.
const callsAfter = mocks.memoryApi.list.mock.calls.length;
mocks.handlers.at(-1)!({ type: "run.step", data: {} });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(mocks.memoryApi.list.mock.calls.length).toBe(callsAfter);
});
it("keeps the knowledge sources as a second tab", async () => {
render(<MemoryPage />);
fireEvent.click(await screen.findByRole("button", { name: "منابع دانش" }));
expect(await screen.findByText(KNOWLEDGE.name)).toBeInTheDocument();
// Chunk counts use Persian digits like every other number in the product.
expect(screen.getByText("۴")).toBeInTheDocument();
});
});