Compare commits
4 Commits
d697709f60
...
widget-pha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bccfff0c69 | ||
|
|
9a8b556c13 | ||
|
|
a626dd849d | ||
|
|
d0b1edea7a |
72
README.md
72
README.md
@@ -1,6 +1,6 @@
|
|||||||
# Shared
|
# Shared
|
||||||
|
|
||||||
Shared infrastructure, models, contracts, services, and templates used by all five Rose Ash microservices (blog, market, cart, events, federation). Included as a git submodule in each app.
|
Shared infrastructure, models, templates, and configuration used by all four Rose Ash microservices (blog, market, cart, events). Included as a git submodule in each app.
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
@@ -8,41 +8,20 @@ Shared infrastructure, models, contracts, services, and templates used by all fi
|
|||||||
shared/
|
shared/
|
||||||
db/
|
db/
|
||||||
base.py # SQLAlchemy declarative Base
|
base.py # SQLAlchemy declarative Base
|
||||||
session.py # Async session factory (get_session, register_db)
|
session.py # Async session factory (get_session)
|
||||||
models/ # Canonical domain models
|
models/ # Shared domain models
|
||||||
user.py # User
|
user.py # User
|
||||||
magic_link.py # MagicLink (auth tokens)
|
magic_link.py # MagicLink (auth tokens)
|
||||||
domain_event.py # DomainEvent (transactional outbox)
|
domain_event.py # DomainEvent (transactional outbox)
|
||||||
kv.py # KeyValue (key-value store)
|
kv.py # KeyValue (key-value store)
|
||||||
menu_item.py # MenuItem (deprecated — use MenuNode)
|
menu_item.py # MenuItem
|
||||||
menu_node.py # MenuNode (navigation tree)
|
|
||||||
container_relation.py # ContainerRelation (parent-child content)
|
|
||||||
ghost_membership_entities.py # GhostNewsletter, UserNewsletter
|
ghost_membership_entities.py # GhostNewsletter, UserNewsletter
|
||||||
federation.py # ActorProfile, APActivity, APFollower, APFollowing,
|
|
||||||
# RemoteActor, APRemotePost, APLocalPost,
|
|
||||||
# APInteraction, APNotification, APAnchor, IPFSPin
|
|
||||||
contracts/
|
|
||||||
dtos.py # Frozen dataclasses for cross-domain data transfer
|
|
||||||
protocols.py # Service protocols (Blog, Calendar, Market, Cart, Federation)
|
|
||||||
widgets.py # Widget types (NavWidget, CardWidget, AccountPageWidget)
|
|
||||||
services/
|
|
||||||
registry.py # Typed singleton: services.blog, .calendar, .market, .cart, .federation
|
|
||||||
blog_impl.py # SqlBlogService
|
|
||||||
calendar_impl.py # SqlCalendarService
|
|
||||||
market_impl.py # SqlMarketService
|
|
||||||
cart_impl.py # SqlCartService
|
|
||||||
federation_impl.py # SqlFederationService
|
|
||||||
federation_publish.py # try_publish() — inline AP publication helper
|
|
||||||
stubs.py # No-op stubs for absent domains
|
|
||||||
navigation.py # get_navigation_tree()
|
|
||||||
relationships.py # attach_child, get_children, detach_child
|
|
||||||
widget_registry.py # Widget registry singleton
|
|
||||||
widgets/ # Per-domain widget registration
|
|
||||||
infrastructure/
|
infrastructure/
|
||||||
factory.py # create_base_app() — Quart app factory
|
factory.py # create_base_app() — Quart app factory
|
||||||
cart_identity.py # current_cart_identity() (user_id or session_id)
|
cart_identity.py # current_cart_identity() (user_id or session_id)
|
||||||
cart_loader.py # Cart data loader for context processors
|
cart_loader.py # Cart data loader for context processors
|
||||||
context.py # Jinja2 context processors
|
context.py # Jinja2 context processors
|
||||||
|
internal_api.py # Inter-app HTTP client (get/post via httpx)
|
||||||
jinja_setup.py # Jinja2 template environment setup
|
jinja_setup.py # Jinja2 template environment setup
|
||||||
urls.py # URL helpers (coop_url, market_url, etc.)
|
urls.py # URL helpers (coop_url, market_url, etc.)
|
||||||
user_loader.py # Load current user from session
|
user_loader.py # Load current user from session
|
||||||
@@ -50,36 +29,32 @@ shared/
|
|||||||
events/
|
events/
|
||||||
bus.py # emit_event(), register_handler()
|
bus.py # emit_event(), register_handler()
|
||||||
processor.py # EventProcessor (polls domain_events, runs handlers)
|
processor.py # EventProcessor (polls domain_events, runs handlers)
|
||||||
handlers/ # Shared event handlers
|
browser/app/
|
||||||
container_handlers.py # Navigation rebuild on attach/detach
|
csrf.py # CSRF protection
|
||||||
login_handlers.py # Cart/entry adoption on login
|
errors.py # Error handlers
|
||||||
order_handlers.py # Order lifecycle events
|
middleware.py # Request/response middleware
|
||||||
ap_delivery_handler.py # AP activity delivery to follower inboxes
|
redis_cacher.py # Tag-based Redis page caching
|
||||||
utils/
|
authz.py # Authorization helpers
|
||||||
__init__.py
|
filters/ # Jinja2 template filters (currency, truncate, etc.)
|
||||||
calendar_helpers.py # Calendar period/entry utilities
|
utils/ # HTMX helpers, UTC time, parsing
|
||||||
http_signatures.py # RSA keypair generation, HTTP signature signing/verification
|
payments/sumup.py # SumUp checkout API integration
|
||||||
ipfs_client.py # Async IPFS client (add_bytes, add_json, pin_cid)
|
browser/templates/ # ~300 Jinja2 templates shared across all apps
|
||||||
anchoring.py # Merkle trees + OpenTimestamps Bitcoin anchoring
|
|
||||||
webfinger.py # WebFinger actor resolution
|
|
||||||
browser/
|
|
||||||
app/ # Middleware, CSRF, errors, Redis caching, authz, filters
|
|
||||||
templates/ # ~300 Jinja2 templates shared across all apps
|
|
||||||
containers.py # ContainerType, container_filter, content_filter helpers
|
|
||||||
config.py # YAML config loader
|
config.py # YAML config loader
|
||||||
|
containers.py # ContainerType, container_filter, content_filter helpers
|
||||||
log_config/setup.py # Logging configuration (JSON formatter)
|
log_config/setup.py # Logging configuration (JSON formatter)
|
||||||
|
utils.py # host_url and other shared utilities
|
||||||
static/ # Shared static assets (CSS, JS, images, FontAwesome)
|
static/ # Shared static assets (CSS, JS, images, FontAwesome)
|
||||||
editor/ # Koenig (Ghost) rich text editor build
|
editor/ # Koenig (Ghost) rich text editor build
|
||||||
alembic/ # Database migrations
|
alembic/ # Database migrations (25 versions)
|
||||||
|
env.py # Imports models from all apps (with try/except guards)
|
||||||
|
versions/ # Migration files — single head: j0h8e4f6g7
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key Patterns
|
## Key Patterns
|
||||||
|
|
||||||
- **App factory:** All apps call `create_base_app()` which sets up DB sessions, CSRF, error handling, event processing, logging, widget registration, and domain service wiring.
|
- **App factory:** All apps call `create_base_app()` which sets up DB sessions, CSRF, error handling, event processing, logging, and the glue handler registry.
|
||||||
- **Service contracts:** Cross-domain communication via typed Protocols + frozen DTO dataclasses. Apps call `services.calendar.method()`, never import models from other domains.
|
|
||||||
- **Service registry:** Typed singleton (`services.blog`, `.calendar`, `.market`, `.cart`, `.federation`). Apps wire their own domain + stubs for others via `register_domain_services()`.
|
|
||||||
- **Event bus:** `emit_event()` writes to `domain_events` table in the caller's transaction. `EventProcessor` polls and dispatches to registered handlers.
|
- **Event bus:** `emit_event()` writes to `domain_events` table in the caller's transaction. `EventProcessor` polls and dispatches to registered handlers.
|
||||||
- **Widget registry:** Domain services register widgets (nav, card, account); templates consume via `widgets.container_nav`, `widgets.container_cards`.
|
- **Inter-app HTTP:** `internal_api.get/post("cart", "/internal/cart/summary")` for cross-app reads. URLs resolved from `app-config.yaml`.
|
||||||
- **Cart identity:** `current_cart_identity()` returns `{"user_id": int|None, "session_id": str|None}` from the request session.
|
- **Cart identity:** `current_cart_identity()` returns `{"user_id": int|None, "session_id": str|None}` from the request session.
|
||||||
|
|
||||||
## Alembic Migrations
|
## Alembic Migrations
|
||||||
@@ -87,5 +62,8 @@ shared/
|
|||||||
All apps share one PostgreSQL database. Migrations are managed here and run from the blog app's entrypoint (other apps skip migrations on startup).
|
All apps share one PostgreSQL database. Migrations are managed here and run from the blog app's entrypoint (other apps skip migrations on startup).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# From any app directory (shared/ must be on sys.path)
|
||||||
alembic -c shared/alembic.ini upgrade head
|
alembic -c shared/alembic.ini upgrade head
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Current head: `j0h8e4f6g7` (drop cross-domain FK constraints).
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
# shared package — infrastructure, models, contracts, and services
|
# shared package — extracted from blog/shared_lib/
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from shared.db.base import Base
|
|||||||
|
|
||||||
# Import ALL models so Base.metadata sees every table
|
# Import ALL models so Base.metadata sees every table
|
||||||
import shared.models # noqa: F401 User, KV, MagicLink, MenuItem, Ghost*
|
import shared.models # noqa: F401 User, KV, MagicLink, MenuItem, Ghost*
|
||||||
for _mod in ("blog.models", "market.models", "cart.models", "events.models", "federation.models"):
|
for _mod in ("blog.models", "market.models", "cart.models", "events.models", "federation.models", "glue.models"):
|
||||||
try:
|
try:
|
||||||
__import__(_mod)
|
__import__(_mod)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
|||||||
@@ -221,11 +221,6 @@ class FederationService(Protocol):
|
|||||||
self, session: AsyncSession, username: str,
|
self, session: AsyncSession, username: str,
|
||||||
) -> list[APFollowerDTO]: ...
|
) -> list[APFollowerDTO]: ...
|
||||||
|
|
||||||
async def get_followers_paginated(
|
|
||||||
self, session: AsyncSession, username: str,
|
|
||||||
page: int = 1, per_page: int = 20,
|
|
||||||
) -> tuple[list[RemoteActorDTO], int]: ...
|
|
||||||
|
|
||||||
async def add_follower(
|
async def add_follower(
|
||||||
self, session: AsyncSession, username: str,
|
self, session: AsyncSession, username: str,
|
||||||
follower_acct: str, follower_inbox: str, follower_actor_url: str,
|
follower_acct: str, follower_inbox: str, follower_actor_url: str,
|
||||||
@@ -288,11 +283,6 @@ class FederationService(Protocol):
|
|||||||
before: datetime | None = None, limit: int = 20,
|
before: datetime | None = None, limit: int = 20,
|
||||||
) -> list[TimelineItemDTO]: ...
|
) -> list[TimelineItemDTO]: ...
|
||||||
|
|
||||||
async def get_actor_timeline(
|
|
||||||
self, session: AsyncSession, remote_actor_id: int,
|
|
||||||
before: datetime | None = None, limit: int = 20,
|
|
||||||
) -> list[TimelineItemDTO]: ...
|
|
||||||
|
|
||||||
# -- Local posts ----------------------------------------------------------
|
# -- Local posts ----------------------------------------------------------
|
||||||
async def create_local_post(
|
async def create_local_post(
|
||||||
self, session: AsyncSession, actor_profile_id: int,
|
self, session: AsyncSession, actor_profile_id: int,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ _engine = create_async_engine(
|
|||||||
future=True,
|
future=True,
|
||||||
echo=False,
|
echo=False,
|
||||||
pool_pre_ping=True,
|
pool_pre_ping=True,
|
||||||
pool_size=0, # 0 = unlimited (NullPool equivalent for asyncpg)
|
pool_size=-1 # ned to look at this!!!
|
||||||
)
|
)
|
||||||
|
|
||||||
_Session = async_sessionmaker(
|
_Session = async_sessionmaker(
|
||||||
@@ -34,6 +34,7 @@ async def get_session():
|
|||||||
await sess.close()
|
await sess.close()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def register_db(app: Quart):
|
def register_db(app: Quart):
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Shared event handlers."""
|
"""Shared event handlers (replaces glue.setup.register_glue_handlers)."""
|
||||||
|
|
||||||
|
|
||||||
def register_shared_handlers():
|
def register_shared_handlers():
|
||||||
@@ -6,4 +6,5 @@ def register_shared_handlers():
|
|||||||
import shared.events.handlers.container_handlers # noqa: F401
|
import shared.events.handlers.container_handlers # noqa: F401
|
||||||
import shared.events.handlers.login_handlers # noqa: F401
|
import shared.events.handlers.login_handlers # noqa: F401
|
||||||
import shared.events.handlers.order_handlers # noqa: F401
|
import shared.events.handlers.order_handlers # noqa: F401
|
||||||
|
# federation_handlers removed — publication is now inline at write sites
|
||||||
import shared.events.handlers.ap_delivery_handler # noqa: F401
|
import shared.events.handlers.ap_delivery_handler # noqa: F401
|
||||||
|
|||||||
8
events/handlers/federation_handlers.py
Normal file
8
events/handlers/federation_handlers.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"""Federation event handlers — REMOVED.
|
||||||
|
|
||||||
|
Federation publication is now inline at the write site (ghost_sync, entries,
|
||||||
|
market routes) via shared.services.federation_publish.try_publish().
|
||||||
|
|
||||||
|
AP delivery (federation.activity_created → inbox POST) remains async via
|
||||||
|
ap_delivery_handler.
|
||||||
|
"""
|
||||||
@@ -6,7 +6,7 @@ from shared.db.base import Base
|
|||||||
|
|
||||||
|
|
||||||
class MenuItem(Base):
|
class MenuItem(Base):
|
||||||
"""Deprecated — kept so the table isn't dropped. Use shared.models.menu_node.MenuNode."""
|
"""Deprecated — kept so the table isn't dropped. Use glue.models.MenuNode."""
|
||||||
__tablename__ = "menu_items"
|
__tablename__ = "menu_items"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
|||||||
@@ -371,7 +371,7 @@ class SqlCalendarService:
|
|||||||
entries_by_post.setdefault(post_id, []).append(_entry_to_dto(entry))
|
entries_by_post.setdefault(post_id, []).append(_entry_to_dto(entry))
|
||||||
return entries_by_post
|
return entries_by_post
|
||||||
|
|
||||||
# -- writes ---------------------------------------------------------------
|
# -- writes (absorb glue lifecycle) ---------------------------------------
|
||||||
|
|
||||||
async def adopt_entries_for_user(
|
async def adopt_entries_for_user(
|
||||||
self, session: AsyncSession, user_id: int, session_id: str,
|
self, session: AsyncSession, user_id: int, session_id: str,
|
||||||
|
|||||||
@@ -376,65 +376,6 @@ class SqlFederationService:
|
|||||||
)
|
)
|
||||||
return result.rowcount > 0
|
return result.rowcount > 0
|
||||||
|
|
||||||
async def get_followers_paginated(
|
|
||||||
self, session: AsyncSession, username: str,
|
|
||||||
page: int = 1, per_page: int = 20,
|
|
||||||
) -> tuple[list[RemoteActorDTO], int]:
|
|
||||||
actor = (
|
|
||||||
await session.execute(
|
|
||||||
select(ActorProfile).where(ActorProfile.preferred_username == username)
|
|
||||||
)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if actor is None:
|
|
||||||
return [], 0
|
|
||||||
|
|
||||||
total = (
|
|
||||||
await session.execute(
|
|
||||||
select(func.count(APFollower.id)).where(
|
|
||||||
APFollower.actor_profile_id == actor.id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).scalar() or 0
|
|
||||||
|
|
||||||
offset = (page - 1) * per_page
|
|
||||||
followers = (
|
|
||||||
await session.execute(
|
|
||||||
select(APFollower)
|
|
||||||
.where(APFollower.actor_profile_id == actor.id)
|
|
||||||
.order_by(APFollower.created_at.desc())
|
|
||||||
.limit(per_page)
|
|
||||||
.offset(offset)
|
|
||||||
)
|
|
||||||
).scalars().all()
|
|
||||||
|
|
||||||
results: list[RemoteActorDTO] = []
|
|
||||||
for f in followers:
|
|
||||||
# Try to resolve from cached remote actors first
|
|
||||||
remote = (
|
|
||||||
await session.execute(
|
|
||||||
select(RemoteActor).where(
|
|
||||||
RemoteActor.actor_url == f.follower_actor_url,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if remote:
|
|
||||||
results.append(_remote_actor_to_dto(remote))
|
|
||||||
else:
|
|
||||||
# Synthesise a minimal DTO from follower data
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
domain = urlparse(f.follower_actor_url).netloc
|
|
||||||
results.append(RemoteActorDTO(
|
|
||||||
id=0,
|
|
||||||
actor_url=f.follower_actor_url,
|
|
||||||
inbox_url=f.follower_inbox,
|
|
||||||
preferred_username=f.follower_acct.split("@")[0] if "@" in f.follower_acct else f.follower_acct,
|
|
||||||
domain=domain,
|
|
||||||
display_name=None,
|
|
||||||
summary=None,
|
|
||||||
icon_url=None,
|
|
||||||
))
|
|
||||||
return results, total
|
|
||||||
|
|
||||||
# -- Remote actors --------------------------------------------------------
|
# -- Remote actors --------------------------------------------------------
|
||||||
|
|
||||||
async def get_or_fetch_remote_actor(
|
async def get_or_fetch_remote_actor(
|
||||||
@@ -1025,46 +966,6 @@ class SqlFederationService:
|
|||||||
))
|
))
|
||||||
return items
|
return items
|
||||||
|
|
||||||
async def get_actor_timeline(
|
|
||||||
self, session: AsyncSession, remote_actor_id: int,
|
|
||||||
before: datetime | None = None, limit: int = 20,
|
|
||||||
) -> list[TimelineItemDTO]:
|
|
||||||
remote_actor = (
|
|
||||||
await session.execute(
|
|
||||||
select(RemoteActor).where(RemoteActor.id == remote_actor_id)
|
|
||||||
)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if not remote_actor:
|
|
||||||
return []
|
|
||||||
|
|
||||||
q = (
|
|
||||||
select(APRemotePost)
|
|
||||||
.where(APRemotePost.remote_actor_id == remote_actor_id)
|
|
||||||
)
|
|
||||||
if before:
|
|
||||||
q = q.where(APRemotePost.published < before)
|
|
||||||
q = q.order_by(APRemotePost.published.desc()).limit(limit)
|
|
||||||
|
|
||||||
posts = (await session.execute(q)).scalars().all()
|
|
||||||
return [
|
|
||||||
TimelineItemDTO(
|
|
||||||
id=f"remote:{p.id}",
|
|
||||||
post_type="remote",
|
|
||||||
content=p.content or "",
|
|
||||||
published=p.published,
|
|
||||||
actor_name=remote_actor.display_name or remote_actor.preferred_username,
|
|
||||||
actor_username=remote_actor.preferred_username,
|
|
||||||
object_id=p.object_id,
|
|
||||||
summary=p.summary,
|
|
||||||
url=p.url,
|
|
||||||
actor_domain=remote_actor.domain,
|
|
||||||
actor_icon=remote_actor.icon_url,
|
|
||||||
actor_url=remote_actor.actor_url,
|
|
||||||
author_inbox=remote_actor.inbox_url,
|
|
||||||
)
|
|
||||||
for p in posts
|
|
||||||
]
|
|
||||||
|
|
||||||
# -- Local posts ----------------------------------------------------------
|
# -- Local posts ----------------------------------------------------------
|
||||||
|
|
||||||
async def create_local_post(
|
async def create_local_post(
|
||||||
|
|||||||
@@ -239,9 +239,6 @@ class StubFederationService:
|
|||||||
async def get_following(self, session, username, page=1, per_page=20):
|
async def get_following(self, session, username, page=1, per_page=20):
|
||||||
return [], 0
|
return [], 0
|
||||||
|
|
||||||
async def get_followers_paginated(self, session, username, page=1, per_page=20):
|
|
||||||
return [], 0
|
|
||||||
|
|
||||||
async def accept_follow_response(self, session, local_username, remote_actor_url):
|
async def accept_follow_response(self, session, local_username, remote_actor_url):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -263,9 +260,6 @@ class StubFederationService:
|
|||||||
async def get_public_timeline(self, session, before=None, limit=20):
|
async def get_public_timeline(self, session, before=None, limit=20):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
async def get_actor_timeline(self, session, remote_actor_id, before=None, limit=20):
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def create_local_post(self, session, actor_profile_id, content, visibility="public", in_reply_to=None):
|
async def create_local_post(self, session, actor_profile_id, content, visibility="public", in_reply_to=None):
|
||||||
raise RuntimeError("FederationService not available")
|
raise RuntimeError("FederationService not available")
|
||||||
|
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ async def upgrade_ots_proof(proof_bytes: bytes) -> tuple[bytes, bool]:
|
|||||||
"""
|
"""
|
||||||
# OpenTimestamps upgrade is done via the `ots` CLI or their calendar API.
|
# OpenTimestamps upgrade is done via the `ots` CLI or their calendar API.
|
||||||
# For now, return the proof as-is with is_confirmed=False.
|
# For now, return the proof as-is with is_confirmed=False.
|
||||||
# Calendar-based upgrade polling not yet implemented.
|
# TODO: Implement calendar-based upgrade polling.
|
||||||
return proof_bytes, False
|
return proof_bytes, False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user