"""The plugin SDK — the ONLY ``app.*`` surface a plugin may import (SPEC §11). Everything a plugin needs is reached through :func:`sdk`, which returns a :class:`PluginSdk` bound to the plugin's name: a plugin-scoped DB session, the event bus (publishing under ``plugin.{name}.*``), the blob store, outbox draft/send helpers, the settings accessor, an SSRF-guarded HTTP client (§10.6), and the namespaced KV store (§11). The frozen contract types (:class:`Plugin`, :class:`UiManifest`, :class:`JobSpec`, …) are re-exported here so plugin packages import only from ``app.plugins.sdk``. """ from __future__ import annotations import uuid from contextlib import AbstractAsyncContextManager from dataclasses import dataclass from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from app.accounts.ssrf import guarded_get from app.config import Settings, get_settings from app.events import get_event_bus from app.indexer import blobstore from app.outbox import service as outbox_service from app.plugins.db import plugin_session from app.plugins.kv import PluginKvStore from app.plugins.types import ( Hook, JobFunc, JobSpec, Page, PageNav, Plugin, Slot, UiManifest, ) from app.queue import enqueue_job __all__ = [ "Hook", "JobFunc", "JobSpec", "Page", "PageNav", "Plugin", "PluginSdk", "Slot", "UiManifest", "blobstore", "sdk", ] @dataclass(frozen=True) class PluginSdk: """A lightweight facade over shared services, scoped to one plugin. Cheap to construct (wraps process-wide singletons + the plugin name), so handlers/routes obtain it on demand via ``sdk("")``.""" name: str # --- config ------------------------------------------------------------- @property def settings(self) -> Settings: return get_settings() # --- database ----------------------------------------------------------- def session(self, user_id: uuid.UUID) -> AbstractAsyncContextManager[AsyncSession]: """An RLS-enforced transactional session scoped to ``user_id`` (SPEC §11.1). Row-level security limits every query to this user's rows on ``plg_*`` tables — even a query that forgets ``WHERE user_id`` cannot leak or write across users (see :mod:`app.plugins.db`).""" return plugin_session(user_id) def kv(self, user_id: uuid.UUID) -> PluginKvStore: """The namespaced KV store for this plugin and ``user_id`` (SPEC §11).""" return PluginKvStore(self.name, user_id) # --- events ------------------------------------------------------------- async def publish(self, event: str, payload: dict[str, object]) -> None: """Publish ``plugin.{name}.{event}`` on the bus (payload needs ``user_id``).""" await get_event_bus().publish(f"plugin.{self.name}.{event}", payload) # --- background jobs ---------------------------------------------------- async def enqueue(self, job_func: JobFunc, **kwargs: Any) -> None: """Enqueue one of this plugin's jobs on the shared worker (SPEC §11.2). ``job_func`` must be a job declared in this plugin's ``jobs=[...]``. Pass ``user_id=`` for singleton jobs so the per-user lock applies.""" await enqueue_job(f"plg.{self.name}.{job_func.__name__}", **kwargs) # --- blob store --------------------------------------------------------- @property def blob(self) -> object: """The blob store (``store``/``load``/``exists``/``remove``), keyed by user.""" return blobstore # --- outbox ------------------------------------------------------------- @property def outbox(self) -> object: """Draft/send helpers (``upsert_draft``, ``create_outbox``, …).""" return outbox_service # --- outbound HTTP ------------------------------------------------------ async def http_get(self, url: str) -> str | None: """GET ``url`` through the §10.6 SSRF guard (blocks private IPs/oversize).""" return await guarded_get(url) def sdk(name: str) -> PluginSdk: """Return the SDK facade bound to plugin ``name`` (SPEC §11).""" return PluginSdk(name)