feat(scribe): add the Scribe domain — addresses, send, mailbox, history, find, mark, tag (T-5)
The Office (workspace D-66, D-68, D-73) needs a message bus: an address is
a topic named for an identity, owned by a tenant and a consumer. D-68 puts
it here, in core-api's own database, behind its own auth, following
src/domains/book/.
Migration 006 lands the whole schema by hand (down_revision="005"):
scribe_addresses (owner_tenant + name primary key — no surrogate id, since
no route ever references an address except by name), scribe_messages
(retained; a generated tsvector search column + GIN index on subject/body,
and a GIN index on the topics array for history's lookup), and
scribe_mailbox_entries (one row per recipient address per message, unique
on address+message_id). No data: seeding `jeroen` and `tower-control` is a
deploy step through `register`, not this migration.
Routes, every one behind require_caller("office", "user"), tenant from the
caller (T-4):
- register/list addresses — register is idempotent for the same consumer,
409 address_taken for another consumer in the tenant; list marks which
addresses the caller's consumer owns.
- send — `from` is always the caller's own identity, stamped server-side,
never read from the body; 403 identity_not_owned if that identity is not
a registered address owned by the caller's consumer; 404 unknown_address
(naming the missing ones) if any recipient does not exist in the tenant;
writes one mailbox entry per recipient.
- mailbox / find — the caller's own identity's entries (mailbox: unread
first; find: full text via websearch_to_tsquery), both requiring the
identity be owned by the caller's consumer.
- history — the retained messages sent to an address, oldest first;
reading never consumes or marks, and reads scribe_messages directly
rather than joining through mailbox entries.
- mark / tag / untag — share one PATCH route; an entry outside the
caller's tenant, or addressed to an identity the caller isn't reading
as, answers 404 not_found — indistinguishable from a nonexistent entry.
Every query filters on the caller's tenant (D-73): an address or message
in another tenant answers exactly like one that does not exist, so no id
is ever confirmed across that boundary (test_scribe.py::TestTenantIsolation,
including two tests proving the tenant filter is load-bearing even when
the same address *name* legitimately exists, differently owned, in two
tenants — not just when the caller's own identity is unregistered there).
Messaging only this round: groups, subscriptions, the notification matrix
and delivery are explicitly not built (workspace T-176) — the schema
already carries what `topics`/`tags` need for them.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Scribe Domain
|
||||
|
||||
The household's message bus (workspace D-66, D-68, D-73): addresses, send,
|
||||
mailbox, history, find, mark, tag. Served under /scribe/*.
|
||||
"""
|
||||
from src.domains.scribe.controller import scribe_controller
|
||||
|
||||
__all__ = ["scribe_controller"]
|
||||
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
Scribe Controller
|
||||
|
||||
The Scribe's messaging verbs (dossier core-api T-5): register, list
|
||||
(addresses), send, mailbox, history, find, mark, tag/untag. Every route is
|
||||
behind an Office or user caller (T-1/T-4); every query filters on the
|
||||
caller's tenant (D-73) — an address or message in another tenant answers
|
||||
exactly like one that does not exist.
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from src.domains.auth.callers import Caller, require_caller
|
||||
from src.domains.scribe.schemas import (
|
||||
AddressListResponse,
|
||||
AddressRegisterRequest,
|
||||
AddressResponse,
|
||||
HistoryResponse,
|
||||
MailboxEntryResponse,
|
||||
MailboxListResponse,
|
||||
MailboxPatchRequest,
|
||||
MessageResponse,
|
||||
MessageSendRequest,
|
||||
)
|
||||
from src.domains.scribe.service import ScribeError, get_scribe_service
|
||||
from src.shared.base import BaseController
|
||||
from src.shared.database import get_async_session
|
||||
from src.shared.logging import get_logger
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
require_scribe_reader = require_caller("office", "user")
|
||||
require_scribe_writer = require_caller("office", "user", identity_required=True)
|
||||
|
||||
|
||||
def _address_response(address, owned: bool) -> AddressResponse:
|
||||
return AddressResponse(
|
||||
name=address.name,
|
||||
owner_consumer=address.owner_consumer,
|
||||
created_by=address.created_by,
|
||||
created_at=address.created_at,
|
||||
owned=owned,
|
||||
)
|
||||
|
||||
|
||||
def _message_response(message) -> MessageResponse:
|
||||
return MessageResponse.model_validate(message, from_attributes=True)
|
||||
|
||||
|
||||
def _entry_response(entry, message) -> MailboxEntryResponse:
|
||||
return MailboxEntryResponse(
|
||||
id=entry.id,
|
||||
address=entry.address,
|
||||
read_at=entry.read_at,
|
||||
tags=entry.tags,
|
||||
created_at=entry.created_at,
|
||||
message=_message_response(message),
|
||||
)
|
||||
|
||||
|
||||
class ScribeController(BaseController):
|
||||
"""Controller for the Scribe domain."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(prefix="/scribe", tags=["Scribe"])
|
||||
|
||||
def create_router(self) -> APIRouter:
|
||||
router = APIRouter(prefix=self.prefix, tags=self.tags)
|
||||
service = get_scribe_service()
|
||||
|
||||
# =====================================================================
|
||||
# register / list addresses
|
||||
# =====================================================================
|
||||
|
||||
@router.post(
|
||||
"/addresses",
|
||||
response_model=AddressResponse,
|
||||
summary="Register an address (idempotent for the same consumer)",
|
||||
)
|
||||
async def register_address(
|
||||
data: AddressRegisterRequest,
|
||||
caller: Caller = Depends(require_scribe_writer),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
address, owned = await service.register_address(session, data.name, caller)
|
||||
except ScribeError as error:
|
||||
raise HTTPException(
|
||||
status_code=error.status_code, detail={"code": error.code, **error.extra}
|
||||
) from error
|
||||
return _address_response(address, owned)
|
||||
|
||||
@router.get(
|
||||
"/addresses",
|
||||
response_model=AddressListResponse,
|
||||
summary="List addresses in the caller's tenant",
|
||||
)
|
||||
async def list_addresses(
|
||||
caller: Caller = Depends(require_scribe_reader),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
results = await service.list_addresses(session, caller)
|
||||
except ScribeError as error:
|
||||
raise HTTPException(
|
||||
status_code=error.status_code, detail={"code": error.code, **error.extra}
|
||||
) from error
|
||||
return AddressListResponse(
|
||||
addresses=[_address_response(address, owned) for address, owned in results]
|
||||
)
|
||||
|
||||
# =====================================================================
|
||||
# send
|
||||
# =====================================================================
|
||||
|
||||
@router.post(
|
||||
"/messages",
|
||||
response_model=MessageResponse,
|
||||
status_code=201,
|
||||
summary="Send a message to one or more addresses",
|
||||
)
|
||||
async def send_message(
|
||||
data: MessageSendRequest,
|
||||
caller: Caller = Depends(require_scribe_writer),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
message = await service.send_message(session, data, caller)
|
||||
except ScribeError as error:
|
||||
raise HTTPException(
|
||||
status_code=error.status_code, detail={"code": error.code, **error.extra}
|
||||
) from error
|
||||
return _message_response(message)
|
||||
|
||||
# =====================================================================
|
||||
# find
|
||||
# =====================================================================
|
||||
|
||||
@router.get(
|
||||
"/messages",
|
||||
response_model=MailboxListResponse,
|
||||
summary="Full text search over messages in the caller identity's mailbox",
|
||||
)
|
||||
async def find_messages(
|
||||
q: str = Query(..., min_length=1),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
caller: Caller = Depends(require_scribe_reader),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
results, total = await service.find_messages(session, caller, q, limit)
|
||||
except ScribeError as error:
|
||||
raise HTTPException(
|
||||
status_code=error.status_code, detail={"code": error.code, **error.extra}
|
||||
) from error
|
||||
return MailboxListResponse(
|
||||
entries=[_entry_response(entry, message) for entry, message in results],
|
||||
total=total,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# =====================================================================
|
||||
# mailbox
|
||||
# =====================================================================
|
||||
|
||||
@router.get(
|
||||
"/mailbox",
|
||||
response_model=MailboxListResponse,
|
||||
summary="The caller identity's mailbox, unread first",
|
||||
)
|
||||
async def get_mailbox(
|
||||
unread: bool = Query(False),
|
||||
tag: Optional[str] = Query(None),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
before_id: Optional[int] = Query(None),
|
||||
caller: Caller = Depends(require_scribe_reader),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
results, total = await service.get_mailbox(
|
||||
session, caller, unread=unread, tag=tag, limit=limit, before_id=before_id
|
||||
)
|
||||
except ScribeError as error:
|
||||
raise HTTPException(
|
||||
status_code=error.status_code, detail={"code": error.code, **error.extra}
|
||||
) from error
|
||||
return MailboxListResponse(
|
||||
entries=[_entry_response(entry, message) for entry, message in results],
|
||||
total=total,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
@router.patch(
|
||||
"/mailbox/{entry_id}",
|
||||
response_model=MailboxEntryResponse,
|
||||
summary="Mark read/unread, or tag/untag a mailbox entry",
|
||||
)
|
||||
async def patch_mailbox_entry(
|
||||
entry_id: int,
|
||||
data: MailboxPatchRequest,
|
||||
caller: Caller = Depends(require_scribe_writer),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
entry, message = await service.patch_mailbox_entry(session, entry_id, data, caller)
|
||||
except ScribeError as error:
|
||||
raise HTTPException(
|
||||
status_code=error.status_code, detail={"code": error.code, **error.extra}
|
||||
) from error
|
||||
return _entry_response(entry, message)
|
||||
|
||||
# =====================================================================
|
||||
# history
|
||||
# =====================================================================
|
||||
|
||||
@router.get(
|
||||
"/addresses/{name}/history",
|
||||
response_model=HistoryResponse,
|
||||
summary="The retained messages sent to an address, oldest first",
|
||||
)
|
||||
async def address_history(
|
||||
name: str,
|
||||
since_id: Optional[int] = Query(None),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
caller: Caller = Depends(require_scribe_reader),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
try:
|
||||
messages = await service.get_history(session, name, caller, since_id, limit)
|
||||
except ScribeError as error:
|
||||
raise HTTPException(
|
||||
status_code=error.status_code, detail={"code": error.code, **error.extra}
|
||||
) from error
|
||||
return HistoryResponse(
|
||||
address=name,
|
||||
messages=[_message_response(message) for message in messages],
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
# Create controller instance
|
||||
scribe_controller = ScribeController()
|
||||
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Scribe Domain Models
|
||||
|
||||
The Scribe is the household's message bus (workspace D-66, D-68, D-73): a
|
||||
retained pub/sub log where an address is a topic. This round ships the
|
||||
primitives migration 006 creates — addresses, messages, mailbox entries —
|
||||
for direct messaging only; groups, subscriptions, the notification matrix
|
||||
and delivery are later work (workspace T-176).
|
||||
|
||||
SQLAlchemy 2.0 Mapped/mapped_column style, timestamptz, following
|
||||
src/domains/book/models.py. Every table carries `owner_tenant` and every
|
||||
query in the service layer filters on it (D-73's hard tenant boundary) —
|
||||
there is no database-level RLS here, so that filter is load-bearing in
|
||||
Python, not just in SQL.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import (
|
||||
Computed,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Identity,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import ARRAY, TSVECTOR
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from src.shared.database import Base
|
||||
|
||||
# Address name shape, identical to src/domains/auth/callers.py's identity
|
||||
# rule (an address is named for an identity, D-66) — not imported from there
|
||||
# because that module's constants are private to it; kept here so a drift
|
||||
# between the two never happens silently, the regex is asserted equal in
|
||||
# tests/test_scribe.py instead.
|
||||
ADDRESS_NAME_PATTERN = r"^[a-z0-9][a-z0-9-]{0,62}$"
|
||||
RESERVED_ADDRESS_NAMES = {"book", "office", "scheduler", "tatlock"}
|
||||
|
||||
|
||||
class ScribeAddress(Base):
|
||||
"""
|
||||
An address: a topic named for an identity. `register` creates it once
|
||||
(D-66) — the pair (owner_tenant, name) is the natural key, so no
|
||||
surrogate id: no route ever references an address except by name.
|
||||
"""
|
||||
|
||||
__tablename__ = "scribe_addresses"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("owner_tenant", "name", name="uq_scribe_addresses_tenant_name"),
|
||||
)
|
||||
|
||||
owner_tenant: Mapped[str] = mapped_column(String(63), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(63), primary_key=True)
|
||||
# Nullable: a "user"-kind caller (direct OIDC, not through the Office)
|
||||
# carries no consumer (src/domains/auth/callers.py), so an address it
|
||||
# registers has none either.
|
||||
owner_consumer: Mapped[Optional[str]] = mapped_column(String(63), nullable=True)
|
||||
created_by: Mapped[str] = mapped_column(String(63), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ScribeAddress {self.owner_tenant}/{self.name}>"
|
||||
|
||||
|
||||
class ScribeMessage(Base):
|
||||
"""
|
||||
A retained message. `topics` is the set of addresses (and, later,
|
||||
groups) it was published to — an integer array is denormalized from
|
||||
the mailbox entries so `history` can read the retained log directly
|
||||
without joining through them (D-66: "topics are retained and
|
||||
re-readable"). Nothing deletes a message.
|
||||
"""
|
||||
|
||||
__tablename__ = "scribe_messages"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, Identity(always=False), primary_key=True)
|
||||
owner_tenant: Mapped[str] = mapped_column(String(63), nullable=False, index=True)
|
||||
topics: Mapped[List[str]] = mapped_column(ARRAY(Text), nullable=False, default=list)
|
||||
from_identity: Mapped[str] = mapped_column(String(63), nullable=False)
|
||||
from_consumer: Mapped[Optional[str]] = mapped_column(String(63), nullable=True)
|
||||
subject: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
tags: Mapped[List[str]] = mapped_column(ARRAY(Text), nullable=False, default=list)
|
||||
reply_to: Mapped[Optional[int]] = mapped_column(
|
||||
Integer, ForeignKey("scribe_messages.id"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
# Generated by migration 006 (`to_tsvector('simple', ...)`, GIN-indexed),
|
||||
# 'simple' for the same reason as book_records.search: mixed Dutch/
|
||||
# English content. `Computed` mirrors the migration's SQL.
|
||||
search: Mapped[Optional[str]] = mapped_column(
|
||||
TSVECTOR,
|
||||
Computed("to_tsvector('simple', coalesce(subject, '') || ' ' || body)", persisted=True),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ScribeMessage {self.id} from={self.from_identity}>"
|
||||
|
||||
|
||||
class ScribeMailboxEntry(Base):
|
||||
"""One row per recipient address per message — what lands in a mailbox."""
|
||||
|
||||
__tablename__ = "scribe_mailbox_entries"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("address", "message_id", name="uq_scribe_mailbox_entries_address_message"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, Identity(always=False), primary_key=True)
|
||||
address: Mapped[str] = mapped_column(String(63), nullable=False)
|
||||
owner_tenant: Mapped[str] = mapped_column(String(63), nullable=False)
|
||||
message_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("scribe_messages.id"), nullable=False
|
||||
)
|
||||
read_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
tags: Mapped[List[str]] = mapped_column(ARRAY(Text), nullable=False, default=list)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ScribeMailboxEntry {self.id} address={self.address} message={self.message_id}>"
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Scribe Domain Schemas
|
||||
|
||||
Pydantic request/response payloads for the Scribe (D-66, D-68, D-73). This
|
||||
round ships `register`, `list` (addresses), `send`, `mailbox`, `history`,
|
||||
`find`, `mark`, `tag`/`untag` — groups, subscriptions, the notification
|
||||
matrix and delivery are later work (dossier core-api T-5).
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from src.domains.scribe.models import ADDRESS_NAME_PATTERN
|
||||
from src.shared.base import BaseSchema
|
||||
|
||||
|
||||
class AddressRegisterRequest(BaseSchema):
|
||||
"""`register` — the name only; owner (tenant, consumer) comes from the caller."""
|
||||
|
||||
name: str = Field(..., pattern=ADDRESS_NAME_PATTERN)
|
||||
|
||||
|
||||
class AddressResponse(BaseSchema):
|
||||
name: str
|
||||
owner_consumer: Optional[str]
|
||||
created_by: str
|
||||
created_at: datetime
|
||||
owned: bool = Field(..., description="True when the caller's consumer registered this address")
|
||||
|
||||
|
||||
class AddressListResponse(BaseSchema):
|
||||
addresses: List[AddressResponse]
|
||||
|
||||
|
||||
class MessageSendRequest(BaseSchema):
|
||||
"""`send` — `from` is never in the body; it is the caller's own identity."""
|
||||
|
||||
to: List[str] = Field(..., min_length=1)
|
||||
subject: Optional[str] = Field(None, max_length=500)
|
||||
body: str = Field(..., min_length=1)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
reply_to: Optional[int] = None
|
||||
|
||||
|
||||
class MessageResponse(BaseSchema):
|
||||
id: int
|
||||
topics: List[str]
|
||||
from_identity: str
|
||||
from_consumer: Optional[str]
|
||||
subject: Optional[str]
|
||||
body: str
|
||||
tags: List[str]
|
||||
reply_to: Optional[int]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class MailboxEntryResponse(BaseSchema):
|
||||
"""`mailbox` / `find` rows, and the result of `mark`/`tag`/`untag`."""
|
||||
|
||||
id: int
|
||||
address: str
|
||||
read_at: Optional[datetime]
|
||||
tags: List[str]
|
||||
created_at: datetime
|
||||
message: MessageResponse
|
||||
|
||||
|
||||
class MailboxListResponse(BaseSchema):
|
||||
entries: List[MailboxEntryResponse]
|
||||
total: int
|
||||
limit: int
|
||||
|
||||
|
||||
class HistoryResponse(BaseSchema):
|
||||
address: str
|
||||
messages: List[MessageResponse]
|
||||
|
||||
|
||||
class MailboxPatchRequest(BaseSchema):
|
||||
"""`mark` (`read`) and `tag`/`untag` (`add_tags`/`remove_tags`) share one route."""
|
||||
|
||||
read: Optional[bool] = None
|
||||
add_tags: Optional[List[str]] = None
|
||||
remove_tags: Optional[List[str]] = None
|
||||
@@ -0,0 +1,335 @@
|
||||
"""
|
||||
Scribe Domain Service
|
||||
|
||||
Business logic for the Scribe — the household's message bus (D-66, D-68,
|
||||
D-73). Every method starts by requiring a tenant on the caller and ends by
|
||||
filtering its query on it: **an address or message in another tenant
|
||||
answers exactly like one that does not exist** (dossier core-api T-5), so
|
||||
no method here ever confirms an id across that boundary.
|
||||
|
||||
Methods take `session: AsyncSession` per call rather than storing it on
|
||||
`self`, following src/domains/book/service.py.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.domains.auth.callers import Caller
|
||||
from src.domains.scribe.models import (
|
||||
RESERVED_ADDRESS_NAMES,
|
||||
ScribeAddress,
|
||||
ScribeMailboxEntry,
|
||||
ScribeMessage,
|
||||
)
|
||||
from src.domains.scribe.schemas import MailboxPatchRequest, MessageSendRequest
|
||||
from src.shared.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ScribeError(Exception):
|
||||
"""Raised for a Scribe-domain failure; the controller maps it to a response."""
|
||||
|
||||
def __init__(self, code: str, status_code: int = 400, **extra):
|
||||
self.code = code
|
||||
self.status_code = status_code
|
||||
self.extra = extra
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
class ScribeService:
|
||||
"""Service for Scribe operations."""
|
||||
|
||||
# =========================================================================
|
||||
# Internal helpers
|
||||
# =========================================================================
|
||||
|
||||
def _require_tenant(self, caller: Caller) -> str:
|
||||
"""Every Scribe operation needs a tenant; a caller without one gets
|
||||
a clear error rather than a query that silently matches nothing."""
|
||||
if not caller.tenant:
|
||||
raise ScribeError("tenant_required", 400)
|
||||
return caller.tenant
|
||||
|
||||
def _validate_address_name(self, name: str) -> None:
|
||||
"""Beyond the schema's regex (shape only): not `group-*`, not reserved.
|
||||
Mirrors src/domains/auth/callers.py's `_validate_identity` rule —
|
||||
an address is named for an identity (D-66)."""
|
||||
if name.startswith("group-") or name in RESERVED_ADDRESS_NAMES:
|
||||
raise ScribeError("address_invalid", 400)
|
||||
|
||||
async def _get_address(self, session: AsyncSession, tenant: str, name: str) -> Optional[ScribeAddress]:
|
||||
return await session.get(ScribeAddress, (tenant, name))
|
||||
|
||||
async def _require_owned_identity(self, session: AsyncSession, caller: Caller, tenant: str) -> None:
|
||||
"""mailbox/find read the caller's own identity's mailbox: the identity
|
||||
must be a registered address owned by the caller's consumer, else
|
||||
403 identity_not_owned — the same refusal `send` uses for `from`."""
|
||||
address = await self._get_address(session, tenant, caller.identity)
|
||||
if address is None or address.owner_consumer != caller.consumer:
|
||||
raise ScribeError("identity_not_owned", 403)
|
||||
|
||||
async def _messages_for_entries(
|
||||
self, session: AsyncSession, entries: List[ScribeMailboxEntry]
|
||||
) -> dict:
|
||||
if not entries:
|
||||
return {}
|
||||
ids = [entry.message_id for entry in entries]
|
||||
stmt = select(ScribeMessage).where(ScribeMessage.id.in_(ids))
|
||||
messages = (await session.execute(stmt)).scalars().all()
|
||||
return {message.id: message for message in messages}
|
||||
|
||||
# =========================================================================
|
||||
# register / list addresses
|
||||
# =========================================================================
|
||||
|
||||
async def register_address(
|
||||
self, session: AsyncSession, name: str, caller: Caller
|
||||
) -> Tuple[ScribeAddress, bool]:
|
||||
"""`register` — idempotent for the same consumer; 409 address_taken
|
||||
for another consumer in the tenant (D-66)."""
|
||||
tenant = self._require_tenant(caller)
|
||||
self._validate_address_name(name)
|
||||
|
||||
existing = await self._get_address(session, tenant, name)
|
||||
if existing is not None:
|
||||
if existing.owner_consumer == caller.consumer:
|
||||
return existing, True
|
||||
raise ScribeError("address_taken", 409)
|
||||
|
||||
address = ScribeAddress(
|
||||
owner_tenant=tenant,
|
||||
name=name,
|
||||
owner_consumer=caller.consumer,
|
||||
created_by=caller.identity,
|
||||
)
|
||||
session.add(address)
|
||||
await session.commit()
|
||||
await session.refresh(address)
|
||||
|
||||
logger.info(f"Scribe address registered: {tenant}/{name} by={caller.identity}")
|
||||
return address, True
|
||||
|
||||
async def list_addresses(
|
||||
self, session: AsyncSession, caller: Caller
|
||||
) -> List[Tuple[ScribeAddress, bool]]:
|
||||
"""`list` — the caller's tenant; mark which the caller's consumer owns."""
|
||||
tenant = self._require_tenant(caller)
|
||||
|
||||
stmt = (
|
||||
select(ScribeAddress)
|
||||
.where(ScribeAddress.owner_tenant == tenant)
|
||||
.order_by(ScribeAddress.name)
|
||||
)
|
||||
addresses = (await session.execute(stmt)).scalars().all()
|
||||
return [(a, a.owner_consumer == caller.consumer) for a in addresses]
|
||||
|
||||
# =========================================================================
|
||||
# send
|
||||
# =========================================================================
|
||||
|
||||
async def send_message(
|
||||
self, session: AsyncSession, data: MessageSendRequest, caller: Caller
|
||||
) -> ScribeMessage:
|
||||
"""`send` — `from` is the caller's own identity, never the body."""
|
||||
tenant = self._require_tenant(caller)
|
||||
|
||||
from_address = await self._get_address(session, tenant, caller.identity)
|
||||
if from_address is None or from_address.owner_consumer != caller.consumer:
|
||||
raise ScribeError("identity_not_owned", 403)
|
||||
|
||||
recipients = list(dict.fromkeys(data.to)) # de-dup, keep order
|
||||
found_stmt = select(ScribeAddress.name).where(
|
||||
ScribeAddress.owner_tenant == tenant, ScribeAddress.name.in_(recipients)
|
||||
)
|
||||
found = set((await session.execute(found_stmt)).scalars().all())
|
||||
missing = [name for name in recipients if name not in found]
|
||||
if missing:
|
||||
raise ScribeError("unknown_address", 404, names=missing)
|
||||
|
||||
if data.reply_to is not None:
|
||||
reply_target = await session.get(ScribeMessage, data.reply_to)
|
||||
if reply_target is None or reply_target.owner_tenant != tenant:
|
||||
raise ScribeError("not_found", 404)
|
||||
|
||||
tags = list(dict.fromkeys(data.tags))
|
||||
|
||||
message = ScribeMessage(
|
||||
owner_tenant=tenant,
|
||||
topics=recipients,
|
||||
from_identity=caller.identity,
|
||||
from_consumer=caller.consumer,
|
||||
subject=data.subject,
|
||||
body=data.body,
|
||||
tags=tags,
|
||||
reply_to=data.reply_to,
|
||||
)
|
||||
session.add(message)
|
||||
await session.flush() # assign message.id
|
||||
|
||||
for recipient in recipients:
|
||||
session.add(
|
||||
ScribeMailboxEntry(address=recipient, owner_tenant=tenant, message_id=message.id)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(message)
|
||||
|
||||
logger.info(
|
||||
f"Scribe message sent: id={message.id} from={caller.identity} to={recipients}"
|
||||
)
|
||||
return message
|
||||
|
||||
# =========================================================================
|
||||
# mailbox / find
|
||||
# =========================================================================
|
||||
|
||||
async def get_mailbox(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
caller: Caller,
|
||||
unread: Optional[bool] = None,
|
||||
tag: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
before_id: Optional[int] = None,
|
||||
) -> Tuple[List[Tuple[ScribeMailboxEntry, ScribeMessage]], int]:
|
||||
"""`mailbox` — the caller's identity's entries, unread first."""
|
||||
tenant = self._require_tenant(caller)
|
||||
await self._require_owned_identity(session, caller, tenant)
|
||||
|
||||
conditions = [
|
||||
ScribeMailboxEntry.owner_tenant == tenant,
|
||||
ScribeMailboxEntry.address == caller.identity,
|
||||
]
|
||||
if unread:
|
||||
conditions.append(ScribeMailboxEntry.read_at.is_(None))
|
||||
if tag is not None:
|
||||
conditions.append(ScribeMailboxEntry.tags.contains([tag]))
|
||||
if before_id is not None:
|
||||
conditions.append(ScribeMailboxEntry.id < before_id)
|
||||
|
||||
base_query = select(ScribeMailboxEntry).where(*conditions)
|
||||
count_query = select(func.count()).select_from(base_query.subquery())
|
||||
total = (await session.execute(count_query)).scalar() or 0
|
||||
|
||||
query = (
|
||||
base_query
|
||||
.order_by(ScribeMailboxEntry.read_at.is_(None).desc(), ScribeMailboxEntry.id.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
entries = list((await session.execute(query)).scalars().all())
|
||||
messages_by_id = await self._messages_for_entries(session, entries)
|
||||
|
||||
return [(entry, messages_by_id[entry.message_id]) for entry in entries], total
|
||||
|
||||
async def find_messages(
|
||||
self, session: AsyncSession, caller: Caller, q: str, limit: int = 50
|
||||
) -> Tuple[List[Tuple[ScribeMailboxEntry, ScribeMessage]], int]:
|
||||
"""`find` — full text over messages in the caller identity's mailbox."""
|
||||
tenant = self._require_tenant(caller)
|
||||
await self._require_owned_identity(session, caller, tenant)
|
||||
|
||||
tsquery = func.websearch_to_tsquery("simple", q)
|
||||
base_query = (
|
||||
select(ScribeMailboxEntry)
|
||||
.join(ScribeMessage, ScribeMailboxEntry.message_id == ScribeMessage.id)
|
||||
.where(
|
||||
ScribeMailboxEntry.owner_tenant == tenant,
|
||||
ScribeMailboxEntry.address == caller.identity,
|
||||
ScribeMessage.search.op("@@")(tsquery),
|
||||
)
|
||||
)
|
||||
count_query = select(func.count()).select_from(base_query.subquery())
|
||||
total = (await session.execute(count_query)).scalar() or 0
|
||||
|
||||
query = base_query.order_by(ScribeMailboxEntry.id.desc()).limit(limit)
|
||||
entries = list((await session.execute(query)).scalars().all())
|
||||
messages_by_id = await self._messages_for_entries(session, entries)
|
||||
|
||||
return [(entry, messages_by_id[entry.message_id]) for entry in entries], total
|
||||
|
||||
# =========================================================================
|
||||
# history
|
||||
# =========================================================================
|
||||
|
||||
async def get_history(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
name: str,
|
||||
caller: Caller,
|
||||
since_id: Optional[int] = None,
|
||||
limit: int = 100,
|
||||
) -> List[ScribeMessage]:
|
||||
"""`history` — the retained messages sent to that address, oldest
|
||||
first. Reading never consumes or marks: this reads scribe_messages
|
||||
directly, never scribe_mailbox_entries."""
|
||||
tenant = self._require_tenant(caller)
|
||||
|
||||
address = await self._get_address(session, tenant, name)
|
||||
if address is None:
|
||||
raise ScribeError("unknown_address", 404)
|
||||
|
||||
conditions = [
|
||||
ScribeMessage.owner_tenant == tenant,
|
||||
ScribeMessage.topics.contains([name]),
|
||||
]
|
||||
if since_id is not None:
|
||||
conditions.append(ScribeMessage.id > since_id)
|
||||
|
||||
stmt = (
|
||||
select(ScribeMessage)
|
||||
.where(*conditions)
|
||||
.order_by(ScribeMessage.id.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
# =========================================================================
|
||||
# mark / tag / untag
|
||||
# =========================================================================
|
||||
|
||||
async def patch_mailbox_entry(
|
||||
self, session: AsyncSession, entry_id: int, data: MailboxPatchRequest, caller: Caller
|
||||
) -> Tuple[ScribeMailboxEntry, ScribeMessage]:
|
||||
"""`mark` (`read`) and `tag`/`untag` (`add_tags`/`remove_tags`) share
|
||||
this route. An entry outside the caller's tenant, or addressed to
|
||||
an identity the caller is not reading as, answers 404 not_found —
|
||||
indistinguishable from a nonexistent entry, so an id never confirms
|
||||
someone else's mail exists."""
|
||||
tenant = self._require_tenant(caller)
|
||||
|
||||
entry = await session.get(ScribeMailboxEntry, entry_id)
|
||||
if entry is None or entry.owner_tenant != tenant or entry.address != caller.identity:
|
||||
raise ScribeError("not_found", 404)
|
||||
|
||||
if data.read is True and entry.read_at is None:
|
||||
entry.read_at = datetime.now(timezone.utc)
|
||||
elif data.read is False:
|
||||
entry.read_at = None
|
||||
|
||||
if data.add_tags:
|
||||
current = set(entry.tags)
|
||||
entry.tags = list(entry.tags) + [t for t in data.add_tags if t not in current]
|
||||
if data.remove_tags:
|
||||
remove = set(data.remove_tags)
|
||||
entry.tags = [t for t in entry.tags if t not in remove]
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
|
||||
message = await session.get(ScribeMessage, entry.message_id)
|
||||
return entry, message
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_scribe_service: Optional[ScribeService] = None
|
||||
|
||||
|
||||
def get_scribe_service() -> ScribeService:
|
||||
"""Get singleton Scribe service instance"""
|
||||
global _scribe_service
|
||||
if _scribe_service is None:
|
||||
_scribe_service = ScribeService()
|
||||
return _scribe_service
|
||||
@@ -20,6 +20,7 @@ from src.domains.housekeeping import housekeeping_controller
|
||||
from src.domains.static import static_controller
|
||||
from src.domains.dashboard import dashboard_controller
|
||||
from src.domains.book import book_controller
|
||||
from src.domains.scribe import scribe_controller
|
||||
|
||||
# Initialize settings
|
||||
settings = get_settings()
|
||||
@@ -108,6 +109,7 @@ app.include_router(housekeeping_controller.router) # /housekeeping/*
|
||||
app.include_router(static_controller.router) # /static/*
|
||||
app.include_router(dashboard_controller.router) # /dashboard/*
|
||||
app.include_router(book_controller.router) # /book/*
|
||||
app.include_router(scribe_controller.router) # /scribe/*
|
||||
|
||||
|
||||
# Global exception handler
|
||||
|
||||
Reference in New Issue
Block a user