From 5b74b02cc07178a2380e9752ee4e1ad2e966aefd Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 17 Aug 2026 14:47:59 +0800 Subject: [PATCH 01/15] feat(extension): support Twitter setup wizard - add extension-owned state and OAuth setup flow - expose peer-aware registry and setup runtime contracts - keep source, cron, and job operations simple for a single-user deployment --- app/business/cron.py | 33 +- app/business/extension/AGENTS.md | 9 +- app/business/extension/__init__.py | 6 +- app/business/extension/config.py | 62 ++ app/business/extension/main.py | 189 +++- app/business/extension/runtime.py | 98 ++ app/business/extension/state.py | 116 ++- app/business/peer/AGENTS.md | 3 + app/business/peer/main.py | 11 + app/database_contract/constants.py | 2 +- app/database_contract/readiness.py | 12 +- app/database_contract/roles.py | 6 + app/middleware.py | 23 +- app/routes/extension.py | 16 +- app/schemas/extension/main.py | 12 + app/schemas/peer/main.py | 19 + app/version.py | 2 +- extensions/AGENTS.md | 1 + extensions/twitter/__init__.py | 61 +- extensions/twitter/api.py | 333 +++---- extensions/twitter/bookmark.py | 5 +- extensions/twitter/pyproject.toml | 6 +- extensions/twitter/setup_flow.py | 928 ++++++++++++++++++ migrations/revision-integrity.json | 1 + .../c6d7e8f9a0b1_add_extension_setup_state.py | 129 +++ pyproject.toml | 5 +- tests/extensions/runtime_support.py | 29 +- .../test_extension_peer_enabled_rpc.py | 225 ++++- tests/test_extension_registry_config.py | 94 ++ 29 files changed, 2159 insertions(+), 277 deletions(-) create mode 100644 app/business/extension/config.py create mode 100644 extensions/twitter/setup_flow.py create mode 100644 migrations/versions/c6d7e8f9a0b1_add_extension_setup_state.py create mode 100644 tests/test_extension_registry_config.py diff --git a/app/business/cron.py b/app/business/cron.py index 0b70cd3..7785392 100644 --- a/app/business/cron.py +++ b/app/business/cron.py @@ -10,7 +10,7 @@ from app.business.deployment_config import DeploymentConfigManager from app.business.job import JobManager from app.engine import SessionLocal -from app.schemas.cron import CronID, CronModel +from app.schemas.cron import CronForm, CronID, CronModel from app.schemas.job import JobModel, JobStatus from libs.obsrv.main import get_logger @@ -132,3 +132,34 @@ def run_now(cls, cron_id: CronID) -> JobModel: db_session.commit() db_session.refresh(job) return job + + @classmethod + def create(cls, form: CronForm) -> CronModel: + """Validate and create one Cron template.""" + if not croniter.croniter.is_valid(form.schedule): + raise ValueError("Cron schedule must be a valid five-field UNIX expression") + with SessionLocal() as db_session: + cron = CronModel(**form.model_dump()) + db_session.add(cron) + db_session.commit() + db_session.refresh(cron) + return cron + + @classmethod + def update(cls, cron_id: CronID, form: CronForm) -> CronModel: + """Validate and replace the editable fields of one Cron template.""" + if not croniter.croniter.is_valid(form.schedule): + raise ValueError("Cron schedule must be a valid five-field UNIX expression") + with SessionLocal() as db_session: + cron = db_session.get(CronModel, cron_id) + if cron is None: + raise ValueError(f"Cron {cron_id} does not exist") + cron.schedule = form.schedule + cron.enabled = form.enabled + cron.job_type = form.job_type + cron.job_parameters = dict(form.job_parameters) + cron.job_timeout_seconds = form.job_timeout_seconds + db_session.add(cron) + db_session.commit() + db_session.refresh(cron) + return cron diff --git a/app/business/extension/AGENTS.md b/app/business/extension/AGENTS.md index 1d97044..80b572c 100644 --- a/app/business/extension/AGENTS.md +++ b/app/business/extension/AGENTS.md @@ -21,10 +21,17 @@ - enable 先启动 runtime,再调用 atomic enabled RPC;返回 version 不一致时移除 peer 并停止旧 runtime。 - disable 先停止 runtime,再调用 RPC;RPC 失败时重启 exact prior runtime,durable intent 不变。 - cold restore 失败不得删除 `enabled[]`;bootstrap/readiness 明确报告 durable intent 尚未运行。 +- `ExtensionBase` 向 Extension 提供 fresh validated config 读写与 typed deployment-wide state + mutation;Extension 不接触 SQLModel,数据库行锁与并发语义仍由 Core store 实现。 +- Extension-specific setup 通过 running Extension 发布的 typed Peer inbound 实现;Host 不提供 + generic setup/wizard protocol。公开 OAuth callback 必须是 lifecycle-bound exact route claim。 +- Registry origin 每次 operation 按 executing Peer override、deployment config、process fallback + 解析一次,并由 exact Release 与 Distribution consumer 共用该 snapshot。 ## 权限和持久化 -`state.py` 是唯一 DB adapter。`enabled[]` 只能通过 +`state.py` 是唯一 DB adapter。`extensions.state` 是 deployment-wide Extension-produced state; +`enabled[]` 只能通过 `inkcre.set_extension_peer_enabled(p_name text,p_peer_id uuid,p_enabled boolean)` 变更,禁止 read-modify-write。SQLModel 不应泄露成 Host 的稳定接口。 diff --git a/app/business/extension/__init__.py b/app/business/extension/__init__.py index 9942e77..0ce69ab 100644 --- a/app/business/extension/__init__.py +++ b/app/business/extension/__init__.py @@ -4,8 +4,9 @@ ExtensionBase, ExtensionDelegationError, ExtensionHost, + PublicHTTPRoute, ) -from .state import ExtensionState +from .state import InstalledExtension __all__ = [ "EXTENSION_HOST", @@ -13,5 +14,6 @@ "ExtensionBase", "ExtensionDelegationError", "ExtensionHost", - "ExtensionState", + "InstalledExtension", + "PublicHTTPRoute", ] diff --git a/app/business/extension/config.py b/app/business/extension/config.py new file mode 100644 index 0000000..581f6f5 --- /dev/null +++ b/app/business/extension/config.py @@ -0,0 +1,62 @@ +"""Registry-origin authority for Core Extension Host operations.""" + +from urllib.parse import urlsplit, urlunsplit + +import pydantic + +from app.business.deployment_config import DeploymentConfigManager +from app.business.peer import PeerManager +from app.settings import settings + + +EXTENSION_REGISTRY_CONFIG_KEY = "extension.registry" +EXTENSION_REGISTRY_CONFIG_SCHEMA = "extension.registry.config.v1" + + +def normalize_registry_origin(value: str) -> str: + parts = urlsplit(value.strip()) + if ( + parts.scheme not in {"http", "https"} + or not parts.netloc + or parts.username is not None + or parts.password is not None + or parts.path not in {"", "/"} + or parts.query + or parts.fragment + ): + raise ValueError("Extension Registry URL must be one HTTP(S) origin") + return urlunsplit((parts.scheme, parts.netloc, "", "", "")) + + +class ExtensionRegistryDeploymentConfig(pydantic.BaseModel): + """Deployment default overridden only by an executing Host Peer.""" + + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + extension_registry_url: str | None = None + + @pydantic.field_validator("extension_registry_url") + @classmethod + def validate_registry_origin(cls, value: str | None) -> str | None: + if value is None or not value.strip(): + return None + return normalize_registry_origin(value) + + +DeploymentConfigManager.register_schema( + EXTENSION_REGISTRY_CONFIG_SCHEMA, + ExtensionRegistryDeploymentConfig, +) + + +def resolve_extension_registry_origin() -> str: + """Resolve one immutable origin snapshot for a Host operation.""" + peer_override = PeerManager.get_current_config().extension_registry_url + if peer_override is not None: + return peer_override + deployment = DeploymentConfigManager.get(EXTENSION_REGISTRY_CONFIG_KEY) + if deployment is not None: + configured = ExtensionRegistryDeploymentConfig.model_validate(deployment) + if configured.extension_registry_url is not None: + return configured.extension_registry_url + return normalize_registry_origin(settings.extension_registry_url) diff --git a/app/business/extension/main.py b/app/business/extension/main.py index 1cdd851..c43e241 100644 --- a/app/business/extension/main.py +++ b/app/business/extension/main.py @@ -30,6 +30,7 @@ DistributionModules, PipDistributionConsumer, ) +from .config import resolve_extension_registry_origin from .errors import ( ExtensionCompatibilityError, ExtensionHostError, @@ -48,11 +49,13 @@ from .runtime import ( ExtensionPublication, ExtensionPublicationSnapshot, + PublicHTTPRoute, + PublicHTTPRouteClaim, ExtensionRuntimeClaim, ExtensionRuntimeClaimConflictError, ExtensionRuntimeRecord, ) -from .state import ExtensionState, ExtensionStateStore, SQLExtensionStateStore +from .state import ExtensionStore, InstalledExtension, SQLExtensionStore LOGGER = get_logger().getChild(__name__) @@ -66,6 +69,9 @@ class ExtensionDelegationError(RuntimeError): class EmptyConfig(sqlmodel.SQLModel): ... +class EmptyState(pydantic.BaseModel): ... + + ConfigTV = typing.TypeVar("ConfigTV", bound=pydantic.BaseModel) @@ -78,11 +84,13 @@ def __init_subclass__( cls, ext_id: str, config_cls: type[ConfigTV], + state_cls: type[pydantic.BaseModel] = EmptyState, **kwargs: typing.Any, ) -> None: cls.__extid__ = ext_id cls.__configcls__ = config_cls # pyrefly: ignore[no-access] cls.__configschema__ = config_cls.model_json_schema() + cls.__statecls__ = state_cls super().__init_subclass__(**kwargs) @classmethod @@ -115,12 +123,18 @@ def on_start( dependencies=cls.api_dependencies(), ) cls._register_apis(router) + registered_routes = tuple(router.routes) app.include_router(router, tags=["extension", cls.__extid__]) cls._init_sources() cls._init_resolvers() for inbound in cls.peer_inbounds(): PeerManager.register_inbound(inbound) publication = snapshot.finish() + publication.public_http_claim = PublicHTTPRouteClaim.acquire( + cls.__extid__, + cls.public_http_routes(), + registered_routes, + ) publication.activate_source_types() extension.persist_config_schema(dict(cls.__configschema__)) except Exception: @@ -154,6 +168,10 @@ def peer_inbounds(cls) -> tuple[typing.Any, ...]: """Return exact Peer inbounds published while this Extension is running.""" return () + @classmethod + def public_http_routes(cls) -> tuple[PublicHTTPRoute, ...]: + return () + @classmethod async def on_close(cls) -> None: record = typing.cast( @@ -162,8 +180,6 @@ async def on_close(cls) -> None: ) if record is None: raise ExtensionRuntimeError(f"Extension runtime {cls.__extid__} has no state") - config = typing.cast(sqlmodel.SQLModel, getattr(cls, "config")) - record.persist_config(config.model_dump()) LOGGER.info("Extension %s closed", cls.__extid__) @classmethod @@ -195,12 +211,79 @@ def _register_apis(cls, router: fastapi.APIRouter) -> None: """Register Extension-owned API endpoints.""" @classmethod - def update_config(cls, new_config: dict[str, typing.Any] | ConfigTV) -> None: + def _runtime_record(cls) -> ExtensionRuntimeRecord: + record = typing.cast( + ExtensionRuntimeRecord | None, + cls.__dict__.get("__runtime_record__"), + ) + if record is None: + raise ExtensionRuntimeError(f"Extension runtime {cls.__extid__} has no state") + return record + + @classmethod + def get_config(cls) -> ConfigTV: + validated = cls.__configcls__( # pyrefly: ignore[missing-attribute] + **cls._runtime_record().read_config() + ) + setattr(cls, "config", validated) + return validated + + @classmethod + def update_config(cls, new_config: dict[str, typing.Any] | ConfigTV) -> ConfigTV: if isinstance(new_config, dict): validated = cls.__configcls__(**new_config) # pyrefly: ignore[missing-attribute] else: validated = new_config + cls._runtime_record().persist_config(validated.model_dump(mode="json")) setattr(cls, "config", validated) + return validated + + @classmethod + def get_state(cls) -> pydantic.BaseModel: + return cls.__statecls__( # pyrefly: ignore[missing-attribute] + **cls._runtime_record().read_state() + ) + + @classmethod + def mutate_state( + cls, + transform: typing.Callable[[pydantic.BaseModel], pydantic.BaseModel], + ) -> pydantic.BaseModel: + state_cls = cls.__statecls__ # pyrefly: ignore[missing-attribute] + + def mutate(raw: dict[str, typing.Any]) -> dict[str, typing.Any]: + current = state_cls(**raw) + updated = transform(current) + if not isinstance(updated, state_cls): + raise TypeError("Extension state transform returned the wrong model") + return updated.model_dump(mode="json") + + return state_cls(**cls._runtime_record().mutate_state(mutate)) + + @classmethod + def mutate_config_and_state( + cls, + transform: typing.Callable[ + [ConfigTV, pydantic.BaseModel], tuple[ConfigTV, pydantic.BaseModel] + ], + ) -> tuple[ConfigTV, pydantic.BaseModel]: + config_cls = cls.__configcls__ # pyrefly: ignore[missing-attribute] + state_cls = cls.__statecls__ # pyrefly: ignore[missing-attribute] + + def mutate( + raw_config: dict[str, typing.Any], + raw_state: dict[str, typing.Any], + ) -> tuple[dict[str, typing.Any], dict[str, typing.Any]]: + config, state = transform(config_cls(**raw_config), state_cls(**raw_state)) + if not isinstance(config, config_cls) or not isinstance(state, state_cls): + raise TypeError("Extension config/state transform returned the wrong models") + return config.model_dump(mode="json"), state.model_dump(mode="json") + + raw_config, raw_state = cls._runtime_record().mutate_config_and_state(mutate) + config = config_cls(**raw_config) + state = state_cls(**raw_state) + setattr(cls, "config", config) + return config, state @classmethod def validate_config(cls, config: dict[str, typing.Any]) -> ConfigTV: @@ -230,24 +313,23 @@ class ExtensionHost: def __init__( self, *, - store: ExtensionStateStore | None = None, + store: ExtensionStore | None = None, release_client: ReleaseResolver | None = None, distribution_consumer: DistributionConsumer | None = None, + registry_origin_resolver: typing.Callable[[], str] | None = None, ) -> None: - self.store = store or SQLExtensionStateStore() - self.release_client = release_client or RegistryReleaseClient( - settings.extension_registry_url, - settings.extension_registry_timeout_seconds, - ) - self.distribution_consumer = distribution_consumer or PipDistributionConsumer( - settings.extension_registry_url, + self.store = store or SQLExtensionStore() + self.release_client = release_client + self.distribution_consumer = distribution_consumer + self.registry_origin_resolver = ( + registry_origin_resolver or resolve_extension_registry_origin ) self.running: dict[str, RunningExtension] = {} self.fastapi_app: fastapi.FastAPI | None = None self._loaded_versions: dict[str, str] = {} self._runtime_lock = asyncio.Lock() - def list(self) -> tuple[ExtensionState, ...]: + def list(self) -> tuple[InstalledExtension, ...]: return self.store.list() async def manage( @@ -255,7 +337,7 @@ async def manage( command: ExtensionManagementCommand, *, route_to_peer: PeerRef, - ) -> ExtensionState: + ) -> InstalledExtension: """Execute one Extension command on one exact Peer.""" if route_to_peer == PeerManager.get_current_peer_ref(): return await self.manage_local(command) @@ -275,7 +357,7 @@ async def manage( raise ExtensionDelegationError( f"Extension management Peer returned HTTP {response.status}" ) - return ExtensionState.model_validate(response.body) + return InstalledExtension.model_validate(response.body) except pydantic.ValidationError as error: raise ExtensionDelegationError( "Extension management Peer returned an invalid response" @@ -284,7 +366,7 @@ async def manage( async def manage_local( self, command: ExtensionManagementCommand, - ) -> ExtensionState: + ) -> InstalledExtension: """Execute one already-validated command without entering delegation.""" if isinstance(command, EnableExtensionCommand): return await self.enable(command.extension) @@ -294,7 +376,7 @@ async def manage_local( return self.patch_config(command.extension, command.patch) typing.assert_never(command) - def get(self, name: str) -> ExtensionState: + def get(self, name: str) -> InstalledExtension: validate_coordinate(name) state = self.store.get(name) if state is None: @@ -307,8 +389,9 @@ def _resolve( version: str, *, allow_yanked: bool, + release_client: ReleaseResolver, ): - release = self.release_client.get(name, version) + release = release_client.get(name, version) if release.state == "yanked" and allow_yanked: LOGGER.warning("Using yanked exact installed Release %s@%s", name, version) elif release.state != "published": @@ -318,7 +401,7 @@ def _resolve( association = require_python_association(release) return release, association - def install(self, name: str, version: str) -> ExtensionState: + def install(self, name: str, version: str) -> InstalledExtension: validate_coordinate(name, version) existing = self.store.get(name) if existing is not None and existing.version == version: @@ -328,7 +411,13 @@ def install(self, name: str, version: str) -> ExtensionState: raise ExtensionRestartRequiredError( f"{name} {loaded_version} was already imported; restart before installing {version}" ) - release, _ = self._resolve(name, version, allow_yanked=False) + release_client, _ = self._operation_consumers() + release, _ = self._resolve( + name, + version, + allow_yanked=False, + release_client=release_client, + ) return self.store.install(name, version, release.nickname) def uninstall(self, name: str) -> None: @@ -341,7 +430,7 @@ def update_config( self, name: str, config: dict[str, typing.Any], - ) -> ExtensionState: + ) -> InstalledExtension: state = self.get(name) running = self.running.get(name) if running is None: @@ -351,32 +440,46 @@ def update_config( getattr(running.extension_class, "__configcls__"), ) validated = config_class(**config) - updated = self.store.update_config(name, validated.model_dump()) running.extension_class.update_config(validated) - return updated + return self.get(name) def patch_config( self, name: str, patch: dict[str, typing.Any], - ) -> ExtensionState: + ) -> InstalledExtension: """Apply one shallow config patch through the canonical update path.""" current = self.get(name) return self.update_config(name, {**current.config, **patch}) - def _acquire(self, state: ExtensionState): + def _operation_consumers( + self, + ) -> tuple[ReleaseResolver, DistributionConsumer]: + if self.release_client is not None and self.distribution_consumer is not None: + return self.release_client, self.distribution_consumer + origin = self.registry_origin_resolver() + release_client = self.release_client or RegistryReleaseClient( + origin, + settings.extension_registry_timeout_seconds, + ) + distribution_consumer = self.distribution_consumer or PipDistributionConsumer(origin) + return release_client, distribution_consumer + + def _acquire(self, state: InstalledExtension): + release_client, distribution_consumer = self._operation_consumers() release, association = self._resolve( state.name, state.version, allow_yanked=True, + release_client=release_client, ) - acquired = self.distribution_consumer.acquire(release, association) + acquired = distribution_consumer.acquire(release, association) return association, acquired async def _start( self, app: fastapi.FastAPI, - state: ExtensionState, + state: InstalledExtension, ) -> RunningExtension: existing = self.running.get(state.name) if existing is not None: @@ -392,7 +495,7 @@ async def _start( async def _start_acquired( self, app: fastapi.FastAPI, - state: ExtensionState, + state: InstalledExtension, association: PythonReleaseDescriptor, acquired: AcquiredDistribution, *, @@ -414,6 +517,25 @@ async def _start_acquired( def persist_config(config: dict[str, typing.Any]) -> None: self.store.update_config(state.name, config) + def read_config() -> dict[str, typing.Any]: + return self.store.read_config(state.name) + + def read_state() -> dict[str, typing.Any]: + return self.store.read_state(state.name) + + def mutate_state( + transform: typing.Callable[[dict[str, typing.Any]], dict[str, typing.Any]], + ) -> dict[str, typing.Any]: + return self.store.mutate_state(state.name, transform) + + def mutate_config_and_state( + transform: typing.Callable[ + [dict[str, typing.Any], dict[str, typing.Any]], + tuple[dict[str, typing.Any], dict[str, typing.Any]], + ], + ) -> tuple[dict[str, typing.Any], dict[str, typing.Any]]: + return self.store.mutate_config_and_state(state.name, transform) + def stage_schema(schema: dict[str, typing.Any]) -> None: schema_box["value"] = schema @@ -426,7 +548,11 @@ def stage_schema(schema: dict[str, typing.Any]) -> None: runtime_record = ExtensionRuntimeRecord( extension_id=association.entry_point.name, config=dict(state.config), + read_config=read_config, persist_config=persist_config, + read_state=read_state, + mutate_state=mutate_state, + mutate_config_and_state=mutate_config_and_state, persist_config_schema=stage_schema, ) extension_class.on_start( @@ -501,7 +627,7 @@ async def enable( name: str, *, app: fastapi.FastAPI | None = None, - ) -> ExtensionState: + ) -> InstalledExtension: validate_coordinate(name) runtime_app = app or self.fastapi_app if runtime_app is None: @@ -544,7 +670,7 @@ async def enable( ) from conflict raise conflict - async def disable(self, name: str) -> ExtensionState: + async def disable(self, name: str) -> InstalledExtension: validate_coordinate(name) peer_id = PeerManager.get_current_peer_ref() async with self._runtime_lock: @@ -629,5 +755,6 @@ async def close_running(self) -> None: "ExtensionDelegationError", "ExtensionHost", "ExtensionHostError", - "ExtensionState", + "InstalledExtension", + "PublicHTTPRoute", ] diff --git a/app/business/extension/runtime.py b/app/business/extension/runtime.py index edabb1e..ca5b75c 100644 --- a/app/business/extension/runtime.py +++ b/app/business/extension/runtime.py @@ -17,6 +17,85 @@ from app.schemas.info_base.block import ResolverType +@dataclass(frozen=True) +class PublicHTTPRoute: + """One exact Extension route intentionally published without Peer JWT.""" + + method: typing.Literal["GET", "POST"] + path: str + + def __post_init__(self) -> None: + if ( + not self.path.startswith("/") + or self.path == "/" + or "{" in self.path + or "}" in self.path + or "*" in self.path + or "?" in self.path + or "#" in self.path + ): + raise ValueError("Public Extension route must be an exact relative path") + + +class PublicHTTPRouteClaim: + """Process authority for exact public routes contributed by a runtime.""" + + _lock = threading.Lock() + _owners: dict[tuple[str, str], object] = {} + + def __init__(self, routes: frozenset[tuple[str, str]], token: object) -> None: + self.routes = routes + self._token = token + self._released = False + + @classmethod + def acquire( + cls, + extension_id: str, + declarations: tuple[PublicHTTPRoute, ...], + published_routes: tuple[typing.Any, ...], + ) -> PublicHTTPRouteClaim | None: + if not declarations: + return None + available = { + (method, route.path) + for route in published_routes + for method in (getattr(route, "methods", None) or ()) + if isinstance(getattr(route, "path", None), str) + } + absolute = frozenset( + (declaration.method, f"/{extension_id}{declaration.path}") + for declaration in declarations + ) + missing = absolute - available + if missing: + raise ValueError(f"Public Extension routes were not published: {sorted(missing)}") + token = object() + with cls._lock: + conflicts = absolute & cls._owners.keys() + if conflicts: + raise ExtensionRuntimeClaimConflictError( + f"Public Extension route already claimed: {sorted(conflicts)}" + ) + for route in absolute: + cls._owners[route] = token + return cls(absolute, token) + + @classmethod + def permits(cls, method: str, path: str) -> bool: + with cls._lock: + return (method.upper(), path) in cls._owners + + def release(self) -> None: + if self._released: + return + with self._lock: + for route in self.routes: + if self._owners.get(route) is self._token: + self._owners.pop(route) + self._released = True + + class ExtensionRuntimeClaimConflictError(RuntimeError): """Raised when another manager already owns an Extension runtime ID.""" @@ -59,7 +138,22 @@ class ExtensionRuntimeRecord: extension_id: str config: dict[str, typing.Any] + read_config: Callable[[], dict[str, typing.Any]] persist_config: Callable[[dict[str, typing.Any]], None] + read_state: Callable[[], dict[str, typing.Any]] + mutate_state: Callable[ + [Callable[[dict[str, typing.Any]], dict[str, typing.Any]]], + dict[str, typing.Any], + ] + mutate_config_and_state: Callable[ + [ + Callable[ + [dict[str, typing.Any], dict[str, typing.Any]], + tuple[dict[str, typing.Any], dict[str, typing.Any]], + ] + ], + tuple[dict[str, typing.Any], dict[str, typing.Any]], + ] persist_config_schema: Callable[[dict[str, typing.Any]], None] @@ -75,6 +169,7 @@ class ExtensionPublication: resolvers_published: dict[ResolverType, type[Resolver]] peer_inbounds_before: dict[CapabilityID, PeerInbound] peer_inbounds_published: dict[CapabilityID, PeerInbound] + public_http_claim: PublicHTTPRouteClaim | None = None restored: bool = False def _contributed_source_types(self) -> dict[str, type[SourceBase]]: @@ -104,6 +199,9 @@ def restore(self) -> None: self.peer_inbounds_before, self.peer_inbounds_published, ) + if self.public_http_claim is not None: + self.public_http_claim.release() + self.public_http_claim = None SourceManager.restore_source_types( self.source_types_before, self.source_types_published, diff --git a/app/business/extension/state.py b/app/business/extension/state.py index 11a5c09..3874008 100644 --- a/app/business/extension/state.py +++ b/app/business/extension/state.py @@ -17,7 +17,7 @@ from .errors import ExtensionNotInstalledError, ExtensionStateConflictError -class ExtensionState(pydantic.BaseModel): +class InstalledExtension(pydantic.BaseModel): """Stable Host-facing projection; SQLModel and table details remain private.""" model_config = pydantic.ConfigDict(frozen=True) @@ -27,30 +27,54 @@ class ExtensionState(pydantic.BaseModel): enabled: tuple[uuid.UUID, ...] = () nickname: str | None = None config: dict[str, typing.Any] = pydantic.Field(default_factory=dict) + # Extension-produced state may contain credentials. It remains available to + # the in-process Host SDK but is never serialized through generic management. + state: dict[str, typing.Any] = pydantic.Field(default_factory=dict, exclude=True) config_schema: dict[str, typing.Any] | None = None -class ExtensionStateStore(typing.Protocol): - def list(self) -> tuple[ExtensionState, ...]: ... +StateMutation: typing.TypeAlias = Callable[[dict[str, typing.Any]], dict[str, typing.Any]] +ConfigStateMutation: typing.TypeAlias = Callable[ + [dict[str, typing.Any], dict[str, typing.Any]], + tuple[dict[str, typing.Any], dict[str, typing.Any]], +] - def get(self, name: str) -> ExtensionState | None: ... - def install(self, name: str, version: str, nickname: str) -> ExtensionState: ... +class ExtensionStore(typing.Protocol): + def list(self) -> tuple[InstalledExtension, ...]: ... + + def get(self, name: str) -> InstalledExtension | None: ... + + def install(self, name: str, version: str, nickname: str) -> InstalledExtension: ... def uninstall(self, name: str) -> None: ... - def update_config(self, name: str, config: dict[str, typing.Any]) -> ExtensionState: ... + def read_config(self, name: str) -> dict[str, typing.Any]: ... + + def update_config( + self, name: str, config: dict[str, typing.Any] + ) -> InstalledExtension: ... + + def read_state(self, name: str) -> dict[str, typing.Any]: ... + + def mutate_state(self, name: str, transform: StateMutation) -> dict[str, typing.Any]: ... + + def mutate_config_and_state( + self, + name: str, + transform: ConfigStateMutation, + ) -> tuple[dict[str, typing.Any], dict[str, typing.Any]]: ... def update_config_schema( self, name: str, schema: dict[str, typing.Any] - ) -> ExtensionState: ... + ) -> InstalledExtension: ... def set_peer_enabled( self, name: str, peer_id: uuid.UUID, enabled: bool - ) -> ExtensionState: ... + ) -> InstalledExtension: ... -class SQLExtensionStateStore: +class SQLExtensionStore: """Transactional adapter over the one canonical deployment relation.""" def __init__( @@ -59,29 +83,30 @@ def __init__( self._session_factory = session_factory @staticmethod - def _state(model: ExtensionModel) -> ExtensionState: - return ExtensionState( + def _state(model: ExtensionModel) -> InstalledExtension: + return InstalledExtension( name=model.name, version=model.version, enabled=tuple(model.enabled), nickname=model.nickname, config=dict(model.config), + state=dict(model.state), config_schema=( dict(model.config_schema) if model.config_schema is not None else None ), ) - def list(self) -> tuple[ExtensionState, ...]: + def list(self) -> tuple[InstalledExtension, ...]: with self._session_factory() as db: rows = db.exec(sqlmodel.select(ExtensionModel).order_by(ExtensionModel.name)).all() return tuple(self._state(row) for row in rows) - def get(self, name: str) -> ExtensionState | None: + def get(self, name: str) -> InstalledExtension | None: with self._session_factory() as db: row = db.get(ExtensionModel, name) return self._state(row) if row is not None else None - def install(self, name: str, version: str, nickname: str) -> ExtensionState: + def install(self, name: str, version: str, nickname: str) -> InstalledExtension: with self._session_factory() as db: locked = ( db.connection() @@ -105,6 +130,7 @@ def install(self, name: str, version: str, nickname: str) -> ExtensionState: enabled=[], nickname=nickname, config={}, + state={}, config_schema=None, ) elif row.version != version: @@ -112,6 +138,10 @@ def install(self, name: str, version: str, nickname: str) -> ExtensionState: raise ExtensionStateConflictError( f"Cannot change {name} while one or more peers are enabled" ) + if row.state: + raise ExtensionStateConflictError( + f"Cannot change {name} while Extension state is not empty" + ) row.version = version row.nickname = nickname row.config_schema = None @@ -141,7 +171,7 @@ def _update_json( name: str, field: typing.Literal["config", "config_schema"], value: dict[str, typing.Any], - ) -> ExtensionState: + ) -> InstalledExtension: with self._session_factory() as db: row = db.get(ExtensionModel, name) if row is None: @@ -152,17 +182,65 @@ def _update_json( db.refresh(row) return self._state(row) - def update_config(self, name: str, config: dict[str, typing.Any]) -> ExtensionState: + def update_config(self, name: str, config: dict[str, typing.Any]) -> InstalledExtension: return self._update_json(name, "config", config) + def read_config(self, name: str) -> dict[str, typing.Any]: + state = self.get(name) + if state is None: + raise ExtensionNotInstalledError(f"{name} is not installed") + return dict(state.config) + + def read_state(self, name: str) -> dict[str, typing.Any]: + state = self.get(name) + if state is None: + raise ExtensionNotInstalledError(f"{name} is not installed") + return dict(state.state) + + def mutate_state( + self, + name: str, + transform: StateMutation, + ) -> dict[str, typing.Any]: + with self._session_factory() as db: + row = db.exec( + sqlmodel.select(ExtensionModel).where(ExtensionModel.name == name).with_for_update() + ).one_or_none() + if row is None: + raise ExtensionNotInstalledError(f"{name} is not installed") + row.state = transform(dict(row.state)) + db.add(row) + db.commit() + db.refresh(row) + return dict(row.state) + + def mutate_config_and_state( + self, + name: str, + transform: ConfigStateMutation, + ) -> tuple[dict[str, typing.Any], dict[str, typing.Any]]: + with self._session_factory() as db: + row = db.exec( + sqlmodel.select(ExtensionModel).where(ExtensionModel.name == name).with_for_update() + ).one_or_none() + if row is None: + raise ExtensionNotInstalledError(f"{name} is not installed") + config, state = transform(dict(row.config), dict(row.state)) + row.config = config + row.state = state + db.add(row) + db.commit() + db.refresh(row) + return dict(row.config), dict(row.state) + def update_config_schema( self, name: str, schema: dict[str, typing.Any] - ) -> ExtensionState: + ) -> InstalledExtension: return self._update_json(name, "config_schema", schema) def set_peer_enabled( self, name: str, peer_id: uuid.UUID, enabled: bool - ) -> ExtensionState: + ) -> InstalledExtension: """Use the shared atomic RPC; Core never performs array read-modify-write.""" statement = sqlalchemy.text( f"SELECT * FROM {PROTOCOL_SCHEMA}.set_extension_peer_enabled(" @@ -185,4 +263,4 @@ def set_peer_enabled( if row is None: raise ExtensionNotInstalledError(f"{name} is not installed") db.commit() - return ExtensionState.model_validate(dict(row)) + return InstalledExtension.model_validate(dict(row)) diff --git a/app/business/peer/AGENTS.md b/app/business/peer/AGENTS.md index d540820..2cf9afa 100644 --- a/app/business/peer/AGENTS.md +++ b/app/business/peer/AGENTS.md @@ -15,3 +15,6 @@ - `route_to_peer` is a caller-local constraint and never enters payload/advertisement。 - A normal response or outcome-unknown dispatch stops generic failover。 - HTTP absolute URLs come only from owner config + fixed inbound paths;do not infer them from bind host or requests。 +- Browser runtimes register with an ordinary `peers` upsert whose payload contains only + runtime-owned `id`、`name`、`config_schema` and `capabilities`; omitted owner-authored + `config` and `labels` must remain unchanged. diff --git a/app/business/peer/main.py b/app/business/peer/main.py index 6fc01fa..f39c362 100644 --- a/app/business/peer/main.py +++ b/app/business/peer/main.py @@ -118,6 +118,17 @@ def register_self(cls) -> PeerModel: def get_current_peer_ref(cls) -> PeerRef: return settings.peer_id + @classmethod + def get_current_config(cls) -> CorePeerConfig: + """Load the current Peer owner's complete validated configuration.""" + peer = cls.get(cls.get_current_peer_ref()) + if peer is None: + raise RuntimeError("Current Peer must be registered before reading config") + try: + return cls._config_contract.validate(peer.config) + except pydantic.ValidationError as error: + raise ValueError("Current Peer config is invalid") from error + @classmethod def get(cls, peer: PeerRef) -> PeerModel | None: with SessionLocal() as db: diff --git a/app/database_contract/constants.py b/app/database_contract/constants.py index 7117ce3..15d6de4 100644 --- a/app/database_contract/constants.py +++ b/app/database_contract/constants.py @@ -4,7 +4,7 @@ CONTRACT_FORMAT = 1 -CONTRACT_REVISION = "extension-registry-feature-retrieval-v1" +CONTRACT_REVISION = "peer-extension-setup-v1" PROTOCOL_SCHEMA = "inkcre" INTERNAL_SCHEMA = "inkcre_internal" diff --git a/app/database_contract/readiness.py b/app/database_contract/readiness.py index 6614ff9..987eb18 100644 --- a/app/database_contract/readiness.py +++ b/app/database_contract/readiness.py @@ -43,6 +43,7 @@ } EXTENSIONS_TABLE_PRIVILEGES = TABLE_PRIVILEGES - {"UPDATE"} EXTENSIONS_UPDATE_COLUMNS = {"config", "config_schema", "nickname", "version"} +CORE_EXTENSIONS_UPDATE_COLUMNS = {"state"} SEQUENCE_PRIVILEGES = {"SELECT", "UPDATE", "USAGE"} @@ -201,7 +202,7 @@ def _function_acl_rows(cursor, schema_name: str) -> dict[tuple[str, str], set[st return rows -def _extension_update_column_acl(cursor) -> set[str]: +def _extension_update_column_acl(cursor, role: str) -> set[str]: cursor.execute( """ SELECT column_name @@ -211,7 +212,7 @@ def _extension_update_column_acl(cursor) -> set[str]: AND grantee = %s AND privilege_type = 'UPDATE' """, - (PROTOCOL_SCHEMA, AUTHENTICATED_ROLE), + (PROTOCOL_SCHEMA, role), ) return {row[0] for row in cursor.fetchall()} @@ -269,8 +270,13 @@ def _privilege_component(cursor, owner_role: str) -> dict[str, Any]: for denied in ("PUBLIC", ANONYMOUS_ROLE, AUTHENTICATOR_ROLE): if table_acls.get((table_name, denied)): problems.append(f"table_acl:{table_name}:{denied}") - if _extension_update_column_acl(cursor) != EXTENSIONS_UPDATE_COLUMNS: + if _extension_update_column_acl(cursor, AUTHENTICATED_ROLE) != EXTENSIONS_UPDATE_COLUMNS: problems.append("column_acl:extensions:update") + if ( + _extension_update_column_acl(cursor, CORE_RUNTIME_ROLE) + != CORE_EXTENSIONS_UPDATE_COLUMNS + ): + problems.append("column_acl:extensions:core_runtime_update") sequence_acls = _relation_acl_rows(cursor, "S") cursor.execute( diff --git a/app/database_contract/roles.py b/app/database_contract/roles.py index bd09f26..62acf1f 100644 --- a/app/database_contract/roles.py +++ b/app/database_contract/roles.py @@ -230,6 +230,12 @@ def _reconcile_object_privileges(cursor) -> None: sql.Identifier(AUTHENTICATED_ROLE), ) ) + cursor.execute( + sql.SQL("GRANT UPDATE (state) ON TABLE {}.extensions TO {}").format( + sql.Identifier(PROTOCOL_SCHEMA), + sql.Identifier(CORE_RUNTIME_ROLE), + ) + ) cursor.execute( sql.SQL("GRANT ALL ON ALL SEQUENCES IN SCHEMA {} TO {}").format( sql.Identifier(PROTOCOL_SCHEMA), diff --git a/app/middleware.py b/app/middleware.py index 799a9c8..c9405e9 100644 --- a/app/middleware.py +++ b/app/middleware.py @@ -1,15 +1,14 @@ """Middleware for logging and request tracking.""" import time -import uuid -import jwt from typing import Callable +import uuid -from fastapi import Request, Response, HTTPException +from fastapi import HTTPException, Request, Response +import jwt from starlette.middleware.base import BaseHTTPMiddleware from starlette.types import ASGIApp -from libs.obsrv.log_record import TRACE_ID from app.database_contract.constants import ( JWT_ALGORITHM, JWT_AUDIENCE, @@ -18,9 +17,23 @@ JWT_ROLE, ) from app.settings import settings +from libs.obsrv.log_record import TRACE_ID from libs.obsrv.main import get_logger +SENSITIVE_QUERY_PARAMETERS = frozenset( + {"access_token", "client_secret", "code", "refresh_token", "state", "token"} +) + + +def logged_query_params(request: Request) -> str: + """Render query diagnostics without persisting OAuth or credential material.""" + return "&".join( + f"{key}={'' if key.lower() in SENSITIVE_QUERY_PARAMETERS else value}" + for key, value in request.query_params.multi_items() + ) + + def decode_peer_jwt( token: str, secret: str, @@ -144,7 +157,7 @@ async def dispatch(self, request: Request, call_next: Callable) -> Response: extra={ "method": request.method, "path": request.url.path, - "query_params": str(request.query_params), + "query_params": logged_query_params(request), }, ) diff --git a/app/routes/extension.py b/app/routes/extension.py index 800115e..99b242a 100644 --- a/app/routes/extension.py +++ b/app/routes/extension.py @@ -8,7 +8,7 @@ from app.business.extension import ( EXTENSION_HOST, EXTENSION_MANAGEMENT_CAPABILITY, - ExtensionState, + InstalledExtension, ) from app.business.peer import PeerHTTPInbound from app.business.extension.errors import ( @@ -58,12 +58,12 @@ def _raise_http_error(error: ExtensionHostError) -> typing.NoReturn: @ROUTER.get("/extensions") -def list_extensions() -> tuple[ExtensionState, ...]: +def list_extensions() -> tuple[InstalledExtension, ...]: return EXTENSION_HOST.list() @ROUTER.get("/extensions/{namespace}/{name}") -def get_extension(namespace: str, name: str) -> ExtensionState: +def get_extension(namespace: str, name: str) -> InstalledExtension: try: return EXTENSION_HOST.get(_coordinate(namespace, name)) except ExtensionHostError as error: @@ -75,7 +75,7 @@ def install_extension( namespace: str, name: str, version: str = fastapi.Query(...), -) -> ExtensionState: +) -> InstalledExtension: """Install one exact published Extension Release with no enabled peers.""" try: return EXTENSION_HOST.install(_coordinate(namespace, name), version) @@ -97,7 +97,7 @@ def update_extension_config( namespace: str, name: str, body: dict[str, typing.Any] = fastapi.Body(...), -) -> ExtensionState: +) -> InstalledExtension: try: return EXTENSION_HOST.update_config(_coordinate(namespace, name), body) except pydantic.ValidationError as error: @@ -110,7 +110,7 @@ def update_extension_config( @ROUTER.post("/extensions/{namespace}/{name}/enable") -async def enable_extension(namespace: str, name: str) -> ExtensionState: +async def enable_extension(namespace: str, name: str) -> InstalledExtension: try: return await EXTENSION_HOST.enable(_coordinate(namespace, name)) except ExtensionHostError as error: @@ -118,7 +118,7 @@ async def enable_extension(namespace: str, name: str) -> ExtensionState: @ROUTER.post("/extensions/{namespace}/{name}/disable") -async def disable_extension(namespace: str, name: str) -> ExtensionState: +async def disable_extension(namespace: str, name: str) -> InstalledExtension: try: return await EXTENSION_HOST.disable(_coordinate(namespace, name)) except ExtensionHostError as error: @@ -126,7 +126,7 @@ async def disable_extension(namespace: str, name: str) -> ExtensionState: @ROUTER.post("/extension-management", include_in_schema=False) -async def manage_extension(body: ExtensionManagementCommand) -> ExtensionState: +async def manage_extension(body: ExtensionManagementCommand) -> InstalledExtension: """Execute the fixed Peer-local Extension management capability.""" try: return await EXTENSION_HOST.manage_local(body) diff --git a/app/schemas/extension/main.py b/app/schemas/extension/main.py index adc55a0..3fb37ed 100644 --- a/app/schemas/extension/main.py +++ b/app/schemas/extension/main.py @@ -60,6 +60,10 @@ class ExtensionModel(sqlmodel.SQLModel, table=True): f"version ~ '{EXTENSION_SEMVER_PATTERN}'", name="extensions_version_canonical", ), + sqlalchemy.CheckConstraint( + "jsonb_typeof(state) = 'object'", + name="extensions_state_object", + ), ) name: ExtensionName = sqlmodel.Field( @@ -90,6 +94,14 @@ class ExtensionModel(sqlmodel.SQLModel, table=True): nullable=False, ), ) + state: dict = sqlmodel.Field( + default_factory=dict, + sa_column=sqlalchemy.Column( + sqlalchemy.dialects.postgresql.JSONB, + server_default=sqlalchemy.text("'{}'::jsonb"), + nullable=False, + ), + ) config_schema: dict | None = sqlmodel.Field( default=None, sa_column=sqlalchemy.Column( diff --git a/app/schemas/peer/main.py b/app/schemas/peer/main.py index 475d6e6..c4ccbaa 100644 --- a/app/schemas/peer/main.py +++ b/app/schemas/peer/main.py @@ -56,6 +56,7 @@ class CorePeerConfig(pydantic.BaseModel): model_config = pydantic.ConfigDict(extra="forbid", frozen=True) http_public_base_url: str | None = None + extension_registry_url: str | None = None @pydantic.field_validator("http_public_base_url") @classmethod @@ -78,6 +79,24 @@ def valid_public_http_base(cls, value: str | None) -> str | None: path = parts.path.rstrip("/") return urlunsplit((parts.scheme, parts.netloc, path, "", "")) + @pydantic.field_validator("extension_registry_url") + @classmethod + def valid_extension_registry_origin(cls, value: str | None) -> str | None: + if value is None or not value.strip(): + return None + parts = urlsplit(value.strip()) + if ( + parts.scheme not in {"http", "https"} + or not parts.netloc + or parts.username is not None + or parts.password is not None + or parts.path not in {"", "/"} + or parts.query + or parts.fragment + ): + raise ValueError("extension_registry_url must be one HTTP(S) origin") + return urlunsplit((parts.scheme, parts.netloc, "", "", "")) + class PeerModel(sqlmodel.SQLModel, table=True): """One equal deployment Peer with a runtime-owned capability/lease snapshot.""" diff --git a/app/version.py b/app/version.py index 52515b3..6ef8d87 100644 --- a/app/version.py +++ b/app/version.py @@ -1,3 +1,3 @@ """Single source of truth for the Core and Core Extension Host SDK version.""" -CORE_VERSION = "0.1.0" +CORE_VERSION = "0.1.1" diff --git a/extensions/AGENTS.md b/extensions/AGENTS.md index dc38a6c..415a273 100644 --- a/extensions/AGENTS.md +++ b/extensions/AGENTS.md @@ -5,6 +5,7 @@ - Each child is a PEP 420 `extensions.` wheel. Never add `extensions/__init__.py`. - Each wheel declares complete direct dependencies, exactly one `inkcre.core.extensions` entry point, and the required namespaced product/Host SDK metadata. - Extension lifecycle registration must be reversible within one process so disable/re-enable can rebuild sources and resolvers. +- Extension-specific setup may publish typed Peer inbound capabilities and exact public callback routes; it must not require a generic Host wizard protocol or duplicate Source/Cron/Job authority in Extension state. - `scripts/extension_distribution.py` owns build-shape validation; `scripts/extension_release.py`, Changie, and the publish workflow own release admission. - Release name/version is immutable. Any changed artifact input requires an admitted version and changelog transition before publication. - Resolver IDs remain namespaced and versioned. Source execution uses the ordinary Job path; storage owns opaque pointers and bytes, not semantic interpretation. diff --git a/extensions/twitter/__init__.py b/extensions/twitter/__init__.py index 6bb098f..ebd73c3 100644 --- a/extensions/twitter/__init__.py +++ b/extensions/twitter/__init__.py @@ -1,8 +1,10 @@ import typing -import sqlmodel from typing import Optional as Opt + from fastapi import APIRouter -from app.business.extension.main import ExtensionBase +import sqlmodel + +from app.business.extension.main import ExtensionBase, PublicHTTPRoute from app.business.info_base.resolver import ResolverManager from app.business.source import SourceManager @@ -20,10 +22,14 @@ class TwitterExtensionConfig(sqlmodel.SQLModel): totp_secret: Opt[str] = None +from .setup_flow import TwitterExtensionState + + class Extension( ExtensionBase[TwitterExtensionConfig], ext_id="twitter", config_cls=TwitterExtensionConfig, + state_cls=TwitterExtensionState, ): @classmethod def _init_resolvers(cls): @@ -57,11 +63,50 @@ async def on_close(cls): @classmethod def _register_apis(cls, router: APIRouter): - from .api import TwitterAPI + from .setup_flow import _reconcile_oauth_state, register_setup_routes + + _reconcile_oauth_state() + register_setup_routes(router) + + @classmethod + def api_dependencies(cls) -> list[typing.Any]: + """Compose Peer auth only around setup; OAuth callback stays public.""" + return [] - TwitterAPI.new(api_router=router) - router.post("/bookmark")( - lambda nickname: SourceManager.create( - f"extensions.{cls.__extid__}.bookmark.Source", nickname - ) + @classmethod + def peer_inbounds(cls) -> tuple[typing.Any, ...]: + from .setup_flow import TWITTER_SETUP_INBOUND + + return (TWITTER_SETUP_INBOUND,) + + @classmethod + def public_http_routes(cls) -> tuple[PublicHTTPRoute, ...]: + return (PublicHTTPRoute(method="GET", path="/auth/callback"),) + + @classmethod + def update_config( + cls, + new_config: dict[str, typing.Any] | TwitterExtensionConfig, + ) -> TwitterExtensionConfig: + """Keep generic config writes consistent with setup-owned OAuth state.""" + from .setup_flow import _fingerprint, _invalidate_mismatched_oauth_state + + validated = ( + TwitterExtensionConfig.model_validate(new_config) + if isinstance(new_config, dict) + else new_config ) + + def update(config_model, state_model): + config = TwitterExtensionConfig.model_validate(config_model) + state = TwitterExtensionState.model_validate(state_model) + if _fingerprint(validated) != _fingerprint(config): + state, _ = _invalidate_mismatched_oauth_state( + validated, + state, + reason="OAuth App changed", + ) + return validated, state + + config, _ = cls.mutate_config_and_state(update) + return config diff --git a/extensions/twitter/api.py b/extensions/twitter/api.py index 653f58a..d6561b8 100644 --- a/extensions/twitter/api.py +++ b/extensions/twitter/api.py @@ -1,21 +1,20 @@ +from __future__ import annotations + import abc import asyncio -import base64 import datetime -import os from pathlib import Path import re import typing -import secrets -import aiohttp -import fastapi +from authlib.integrations.base_client.errors import OAuthError # pyrefly: ignore[untyped-import] +from authlib.integrations.httpx_client import AsyncOAuth2Client # pyrefly: ignore[untyped-import] +import httpx +import pydantic import sqlmodel import twikit import twikit.media -import urllib.parse from typing import Optional as Opt from dd import dd -from utils.base import AIOHTTP_CONNECTOR_GETTER from utils.datetime_ import get_timestamp from .schema import TweetPhoto, TweetVideo, VideoVariant from . import Extension @@ -63,39 +62,32 @@ async def close_singleton(cls) -> None: cls.SINGLETON = None @classmethod - def new(cls, api_router: Opt[fastapi.APIRouter] = None) -> "TwitterAPI": + def new( + cls, + *, + expected_authorization_id: str | None = None, + ) -> "TwitterAPI": """Create an instance of the Twitter API client. Use `config.backend` to determine which backend to use. """ + config = Extension.get_config() + backend_type = config.backend + if backend_type == "official": + return OfficialAPI.from_extension(expected_authorization_id=expected_authorization_id) if cls.SINGLETON is not None: return cls.SINGLETON - else: - backend_type = Extension.config.backend - if backend_type == "official": - cls.SINGLETON = OfficialAPI( - client_id=Extension.config.client_id, - client_secret=Extension.config.client_secret, - ) - if api_router: - api_router.get("/auth/authorize")(cls.SINGLETON.get_oauth_authorize_url) - api_router.get("/auth/callback")(cls.SINGLETON.handle_oauth_callback) - else: - # log warning - pass - elif backend_type == "twikit": - cls.SINGLETON = TwikitAPI( - email=Extension.config.email, - username=Extension.config.username, - password=Extension.config.password, - totp_secret=Extension.config.totp_secret, - language=Extension.config.api_language, - proxy=Extension.config.proxy, - ) - else: - raise ValueError(f"Unknown backend type: {backend_type}") - + if backend_type == "twikit": + cls.SINGLETON = TwikitAPI( + email=config.email, + username=config.username, + password=config.password, + totp_secret=config.totp_secret, + language=config.api_language, + proxy=config.proxy, + ) return cls.SINGLETON + raise ValueError(f"Unknown backend type: {backend_type}") async def close(self): ... @@ -126,8 +118,6 @@ async def get_replies( class OfficialAPI(TwitterAPI): """Official Twitter API client.""" - state = None - challenge = None request_records: dict[str, tuple[int, datetime.datetime]] = {} """How many requests made to each endpoint for last 15 mins. """ @@ -135,13 +125,53 @@ class OfficialAPI(TwitterAPI): """When the rate limit for each endpoint will reset. """ - def __init__(self, client_id: str, client_secret: str): + def __init__( # noqa: PLR0913 + self, + client_id: str, + client_secret: str, + *, + token: dict[str, typing.Any], + user_id: str, + user_handle: str, + authorization_id: str, + ): self.__client_id = client_id self.__client_secret = client_secret - self.__access_token: Opt[str] = None - self.__refresh_token: Opt[str] = None - self.__user_id: Opt[str] = None - self.__user_handle: Opt[str] = None + self.__token = token + self.__user_id = user_id + self.__user_handle = user_handle + self.__authorization_id = authorization_id + + @classmethod + def from_extension( + cls, + *, + expected_authorization_id: str | None = None, + ) -> OfficialAPI: + from .setup_flow import TwitterExtensionState, TwitterSetupConflict, _fingerprint + + config = Extension.get_config() + state = TwitterExtensionState.model_validate(Extension.get_state()) + account = state.account + if ( + account is None + or account.reconnect_required + or account.app_fingerprint != _fingerprint(config) + ): + raise TwitterSetupConflict("Twitter account is not connected") + if ( + expected_authorization_id is not None + and account.authorization_id != expected_authorization_id + ): + raise TwitterSetupConflict("Twitter authorization changed before collection") + return cls( + config.client_id, + config.client_secret, + token=dict(account.token), + user_id=account.user_id, + user_handle=account.handle, + authorization_id=account.authorization_id, + ) @property def user_handle(self) -> str: @@ -182,19 +212,26 @@ async def _request( # noqa: PLR0913 - Error handling - Resopnse body parsing """ - if not self.__access_token: - # TODO raise Unauthorized - raise ValueError("Access token is not set. Please authorize first.") - + from .setup_flow import TwitterExtensionState, TwitterSetupConflict, _fingerprint + + latest = TwitterExtensionState.model_validate(Extension.get_state()) + latest_config = Extension.get_config() + if ( + latest.account is None + or latest.account.reconnect_required + or latest.account.authorization_id != self.__authorization_id + or latest.account.app_fingerprint != _fingerprint(latest_config) + ): + raise TwitterSetupConflict("Twitter authorization changed before provider access") + self.__token = dict(latest.account.token) + request_token = dict(self.__token) endpoint_with_params = endpoint.format(**path_params) if path_params else endpoint - headers = { - "Authorization": f"Bearer {self.__access_token}", - } rate_limit_reset_at = self.rate_limit_reset.get(endpoint) - if rate_limit_reset_at: - await asyncio.sleep((rate_limit_reset_at - get_timestamp()) + 5) - del self.request_records[endpoint] + if rate_limit_reset_at is not None: + await asyncio.sleep(max(0, (rate_limit_reset_at - get_timestamp()) + 5)) + self.rate_limit_reset.pop(endpoint, None) + self.request_records.pop(endpoint, None) # request_record = cls.request_records.get(endpoint) # if request_record: @@ -214,17 +251,57 @@ async def _request( # noqa: PLR0913 # else: # last_request_count, last_15m_start_at = 0, datetime.datetime.now() - async with aiohttp.ClientSession(connector=AIOHTTP_CONNECTOR_GETTER()) as session: - async with session.request( + async def update_token(token: dict[str, typing.Any], **_: typing.Any) -> None: + from .setup_flow import TwitterExtensionState, TwitterSetupConflict + + def update(model: pydantic.BaseModel) -> pydantic.BaseModel: + state = TwitterExtensionState.model_validate(model) + if ( + state.account is None or state.account.authorization_id != self.__authorization_id + ): + raise TwitterSetupConflict("Twitter authorization changed during refresh") + if dict(state.account.token) != request_token: + raise TwitterSetupConflict("Twitter token changed during refresh") + state.account.token = dict(token) + return state + + Extension.mutate_state(update) + self.__token = dict(token) + + def require_reconnect() -> None: + from .setup_flow import TwitterExtensionState + + def update(model: pydantic.BaseModel) -> pydantic.BaseModel: + state = TwitterExtensionState.model_validate(model) + if ( + state.account is not None + and state.account.authorization_id == self.__authorization_id + ): + state.account.reconnect_required = True + return state + + Extension.mutate_state(update) + + client = AsyncOAuth2Client( + client_id=self.__client_id, + client_secret=self.__client_secret, + token=self.__token, + token_endpoint="https://api.x.com/2/oauth2/token", + token_endpoint_auth_method="client_secret_basic", + update_token=update_token, + timeout=10, + ) + try: + response = await client.request( method, f"https://api.x.com/2{endpoint_with_params}", params=query, - headers=headers, - ) as resp: - if resp.status == 429: - x_rate_limit_reset = resp.headers.get("x-rate-limit-reset") - if x_rate_limit_reset: - self.rate_limit_reset[endpoint] = int(x_rate_limit_reset) + json=body, + ) + if response.status_code == 429: + x_rate_limit_reset = response.headers.get("x-rate-limit-reset") + if x_rate_limit_reset: + self.rate_limit_reset[endpoint] = int(x_rate_limit_reset) # # Rate limit exceeded but not expected, set request count to max # # and request again when rate limit reset @@ -233,15 +310,31 @@ async def _request( # noqa: PLR0913 # datetime.datetime.now() # ) - if retried < 3: - return await self._request( - method, endpoint, path_params, query, body, retried + 1 - ) - else: - raise RuntimeError("Twitter API rate limit exceeded after retries") - - resp.raise_for_status() - return await resp.json() + if retried < 3: + return await self._request( + method, endpoint, path_params, query, body, retried + 1 + ) + raise RuntimeError("Twitter API rate limit exceeded after retries") + response.raise_for_status() + payload = response.json() + if not isinstance(payload, dict): + raise RuntimeError("Twitter API returned an invalid response") + return payload + except OAuthError: + require_reconnect() + raise RuntimeError("Twitter authorization requires reconnection") from None + except httpx.HTTPStatusError as error: + if error.response.status_code == 401: + require_reconnect() + raise RuntimeError("Twitter authorization requires reconnection") from None + raise RuntimeError("Twitter API request failed") from error + except httpx.HTTPError as error: + raise RuntimeError("Twitter API request failed") from error + finally: + await typing.cast(httpx.AsyncClient, client).aclose() + + async def close(self) -> None: + """Official clients are operation-scoped and hold no open transport.""" async def get_user(self) -> tuple[str, str]: """Get the user info the token represents and store to state. @@ -398,112 +491,6 @@ async def get_replies( ) return res - @staticmethod - def _get_oauth_redirect_url(): - return os.getenv("API_BASE_URL", "") + "/twitter/auth/callback" - - def get_oauth_authorize_url(self) -> str: - BASE_URL = "https://x.com/i/oauth2/authorize" - REDIRECT_URL = self._get_oauth_redirect_url() - CLIENT_ID = self.__client_id - - scope = " ".join( - [ - "tweet.read", - "users.read", - "bookmark.read", - "bookmark.write", - "offline.access", - ] - ) - self.state = secrets.token_urlsafe(16) # A random string to prevent CSRF attacks - self.code_challenge = secrets.token_urlsafe(32) # Code challenge for PKCE - - params = { - "response_type": "code", - "client_id": CLIENT_ID, - "state": self.state, - "code_challenge": self.code_challenge, - "code_challenge_method": "plain", - "redirect_uri": REDIRECT_URL, - "scope": scope, - } - - return BASE_URL + "?" + urllib.parse.urlencode(params) - - async def handle_oauth_callback(self, code: str, state: str): - """Handle the OAuth2 callback from Twitter. - - Exchange the authorization code for an access token. - """ - # verify state - if state != self.state: - raise ValueError("Invalid state parameter") - - token_url = "https://api.x.com/2/oauth2/token" # noqa: S105 - CLIENT_ID = self.__client_id - CLIENT_SECRET = self.__client_secret - - data = { - "grant_type": "authorization_code", - "code": code, - "redirect_uri": self._get_oauth_redirect_url(), - "client_id": CLIENT_ID, - "code_verifier": self.code_challenge, - } - - headers = { - "Content-Type": "application/x-www-form-urlencoded", - "Authorization": f"Basic { - base64.b64encode(f'{CLIENT_ID}:{CLIENT_SECRET}'.encode()).decode() - }", - } - - async with aiohttp.ClientSession(connector=AIOHTTP_CONNECTOR_GETTER()) as session: - async with session.post(token_url, data=data, headers=headers) as resp: - resp.raise_for_status() - resp_body = await resp.json() - access_token = resp_body.get("access_token") - refresh_token = resp_body.get("refresh_token") - if not access_token or not refresh_token: - raise ValueError("Failed to obtain access token or refresh token") - # Extension.state["access_token"] = access_token - # Extension.state["refresh_token"] = refresh_token - self.__access_token = access_token - self.__refresh_token = refresh_token - - self.state = None - self.challenge = None - - # Get user info and store to state - await self.get_user() - - return resp_body - - async def refresh_access_token(self, refresh_token: str) -> str: - """Get a new access token using the refresh token. - - Docs https://docs.x.com/fundamentals/authentication/oauth-2-0/authorization-code#refresh-tokens - """ - token_url = "https://api.x.com/2/oauth2/token" # noqa: S105 - CLIENT_ID = self.__client_id - - data = { - "grant_type": "refresh_token", - "refresh_token": refresh_token, - "client_id": CLIENT_ID, - } - - headers = { - "Content-Type": "application/x-www-form-urlencoded", - } - - async with aiohttp.ClientSession(connector=AIOHTTP_CONNECTOR_GETTER()) as session: - async with session.post(token_url, data=data, headers=headers) as resp: - resp.raise_for_status() - token_response = await resp.json() - return token_response - class TwikitAPI(TwitterAPI): """Twikit API client.""" diff --git a/extensions/twitter/bookmark.py b/extensions/twitter/bookmark.py index 0891891..edf5133 100644 --- a/extensions/twitter/bookmark.py +++ b/extensions/twitter/bookmark.py @@ -25,6 +25,7 @@ class CollectConfig(pydantic.BaseModel): full: bool = False result_limit: int = pydantic.Field(default=40, ge=5, le=100) + authorization_id: str = pydantic.Field(min_length=1) def _video_url(video) -> str | None: @@ -103,7 +104,9 @@ async def collect(self, job: JobModel, config: pydantic.BaseModel) -> None: page = job.state.get("page") if job.state else None - api_client = TwitterAPI.new() + api_client = TwitterAPI.new( + expected_authorization_id=collect_config.authorization_id, + ) bookmarks_res = await api_client.get_bookmarks(page=page, max_results=result_limit) # find new tweets start point diff --git a/extensions/twitter/pyproject.toml b/extensions/twitter/pyproject.toml index 80e6418..3e72116 100644 --- a/extensions/twitter/pyproject.toml +++ b/extensions/twitter/pyproject.toml @@ -4,13 +4,15 @@ build-backend = "setuptools.build_meta" [project] name = "inkcre-ext-twitter" -version = "0.1.1" +version = "0.2.0" description = "Twitter extension for InKCre" authors = [{ name = "Lan_zhijiang", email = "lanzhijiang@foxmail.com" }] dependencies = [ + "authlib>=1.7.2,<2.0.0", "aiohttp>=3.14.1,<4.0.0", "datadot>=1.0.0,<2.0.0", "fastapi>=0.139.2,<0.140.0", + "httpx>=0.28.1,<0.29.0", "sqlmodel>=0.0.24,<0.0.25", "twikit>=2.3.3,<3.0.0", ] @@ -24,7 +26,7 @@ twitter = "extensions.twitter:Extension" name = "inkcre/twitter" nickname = "Twitter" host-sdk = "core-py" -host-sdk-version = ">=0.1.0 <0.2.0" +host-sdk-version = ">=0.1.1 <0.2.0" [tool.setuptools] packages = ["extensions.twitter"] diff --git a/extensions/twitter/setup_flow.py b/extensions/twitter/setup_flow.py new file mode 100644 index 0000000..815176b --- /dev/null +++ b/extensions/twitter/setup_flow.py @@ -0,0 +1,928 @@ +"""Whole-Extension setup workflow owned by the Twitter Extension.""" + +from __future__ import annotations + +import base64 +import datetime +import hashlib +import html +import secrets +import typing +import uuid + +from authlib.integrations.base_client.errors import OAuthError # pyrefly: ignore[untyped-import] +from authlib.integrations.httpx_client import ( # pyrefly: ignore[untyped-import] + AsyncOAuth2Client, + OAuth2Client, +) +import fastapi +from fastapi.responses import HTMLResponse +import httpx +import pydantic +import sqlmodel + +from app.business.cron import CronManager +from app.business.job import JobManager +from app.business.peer import PeerHTTPInbound, PeerManager +from app.business.source import SOURCE_COLLECT_JOB_TYPE, SourceManager +from app.engine import SessionLocal +from app.middleware import require_peer_jwt +from app.schemas.cron import CronForm, CronModel +from app.schemas.source import SourceModel + + +if typing.TYPE_CHECKING: + from . import TwitterExtensionConfig + + +TWITTER_SETUP_CAPABILITY = "inkcre.twitter.setup.v1" +TWITTER_SETUP_INBOUND = PeerHTTPInbound( + capability=TWITTER_SETUP_CAPABILITY, + method="POST", + path="/twitter/setup", +) +AUTHORIZE_URL = "https://x.com/i/oauth2/authorize" +TOKEN_URL = "https://api.x.com/2/oauth2/token" # noqa: S105 +CURRENT_USER_URL = "https://api.x.com/2/users/me" +SCOPES = ("tweet.read", "users.read", "bookmark.read", "offline.access") +TRANSACTION_LIFETIME = datetime.timedelta(minutes=10) +TERMINAL_RETENTION = datetime.timedelta(minutes=10) +MAX_TRANSACTIONS = 8 +BOOKMARK_SOURCE_TYPE = "extensions.twitter.bookmark.Source" + + +def _now() -> datetime.datetime: + return datetime.datetime.now(datetime.UTC) + + +class TwitterAccount(pydantic.BaseModel): + token: dict[str, typing.Any] + user_id: str + handle: str + scopes: tuple[str, ...] = () + app_fingerprint: str + authorization_id: str + connected_at: datetime.datetime + reconnect_required: bool = False + + +class OAuthTransaction(pydantic.BaseModel): + status: typing.Literal["pending", "exchanging", "succeeded", "failed", "expired"] + provider_state: str | None = None + pkce_verifier: str | None = None + app_fingerprint: str + redirect_uri: str + created_at: datetime.datetime + expires_at: datetime.datetime + closed_at: datetime.datetime | None = None + error: str | None = None + + +class TwitterExtensionState(pydantic.BaseModel): + account: TwitterAccount | None = None + oauth_transactions: dict[str, OAuthTransaction] = pydantic.Field(default_factory=dict) + bookmark_source_id: int | None = None + bookmark_cron_id: int | None = None + + +class SetupCollectAt(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid", frozen=True) + + day_of_week: int | None = pydantic.Field(default=None, ge=0, le=6) + hour: int = pydantic.Field(default=0, ge=0, le=23) + minute: int = pydantic.Field(default=0, ge=0, le=59) + + def cron_schedule(self) -> str: + day = "*" if self.day_of_week is None else str(self.day_of_week) + return f"{self.minute} {self.hour} * * {day}" + + @classmethod + def from_cron_schedule(cls, schedule: str) -> SetupCollectAt | None: + parts = schedule.split() + if len(parts) != 5 or parts[2:4] != ["*", "*"]: + return None + try: + return cls( + minute=int(parts[0]), + hour=int(parts[1]), + day_of_week=None if parts[4] == "*" else int(parts[4]), + ) + except (ValueError, pydantic.ValidationError): + return None + + +class BookmarkSourceView(pydantic.BaseModel): + source_id: int + nickname: str + + +class OAuthTransactionView(pydantic.BaseModel): + id: str + status: str + authorize_url: str | None = None + expires_at: datetime.datetime + error: str | None = None + + +class TwitterSetupStatus(pydantic.BaseModel): + backend: str + callback_url: str + oauth_app_configured: bool + client_id: str | None = None + connected: bool + user_id: str | None = None + handle: str | None = None + scopes: tuple[str, ...] = () + reconnect_required: bool = False + bookmark_source_id: int | None = None + bookmark_cron_id: int | None = None + bookmark_sources: tuple[BookmarkSourceView, ...] = () + collect_at: SetupCollectAt = pydantic.Field(default_factory=SetupCollectAt) + bookmark_source_ready: bool = False + ready: bool = False + + +class GetStatusCommand(pydantic.BaseModel): + action: typing.Literal["get_status"] + + +class SaveOAuthAppCommand(pydantic.BaseModel): + action: typing.Literal["save_oauth_app"] + client_id: str = pydantic.Field(min_length=1, max_length=256) + client_secret: str = pydantic.Field(min_length=1, max_length=1024) + confirm_account_reset: bool = False + + +class BeginOAuthCommand(pydantic.BaseModel): + action: typing.Literal["begin_oauth"] + + +class GetOAuthTransactionCommand(pydantic.BaseModel): + action: typing.Literal["get_oauth_transaction"] + transaction_id: str + + +class DisconnectAccountCommand(pydantic.BaseModel): + action: typing.Literal["disconnect_account"] + + +class ConfigureBookmarkSourceCommand(pydantic.BaseModel): + action: typing.Literal["configure_bookmark_source"] + source_id: int | None = None + nickname: str = pydantic.Field(default="Twitter Bookmarks", min_length=1, max_length=120) + collect_at: SetupCollectAt = pydantic.Field(default_factory=SetupCollectAt) + + +class FinishSetupCommand(pydantic.BaseModel): + action: typing.Literal["finish"] + + +TwitterSetupCommand: typing.TypeAlias = typing.Annotated[ + GetStatusCommand + | SaveOAuthAppCommand + | BeginOAuthCommand + | GetOAuthTransactionCommand + | DisconnectAccountCommand + | ConfigureBookmarkSourceCommand + | FinishSetupCommand, + pydantic.Field(discriminator="action"), +] +TwitterSetupResult: typing.TypeAlias = TwitterSetupStatus | OAuthTransactionView + + +class TwitterSetupError(RuntimeError): ... + + +class TwitterSetupConflict(TwitterSetupError): ... + + +class TwitterProviderError(TwitterSetupError): ... + + +def _extension(): + from . import Extension + + return Extension + + +def _state() -> TwitterExtensionState: + return TwitterExtensionState.model_validate(_extension().get_state()) + + +def _config() -> TwitterExtensionConfig: + return _extension().get_config() + + +def _fingerprint(config: TwitterExtensionConfig) -> str: + material = ( + f"inkcre-twitter-oauth-app\0{config.client_id}\0{config.client_secret}".encode() + ) + return hashlib.sha256(material).hexdigest() + + +def _redirect_uri() -> str: + try: + base = PeerManager.get_current_config().http_public_base_url + except (RuntimeError, ValueError) as error: + raise TwitterSetupError("Core Peer public HTTP URL is unavailable") from error + if base is None: + raise TwitterSetupError("Core Peer public HTTP URL is not configured") + return f"{base.rstrip('/')}/twitter/auth/callback" + + +def _pkce_pair() -> tuple[str, str]: + verifier = secrets.token_urlsafe(48) + digest = hashlib.sha256(verifier.encode()).digest() + challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode() + return verifier, challenge + + +def _bounded_provider_error(error: BaseException | str) -> str: + value = str(error).replace("\r", " ").replace("\n", " ").strip() + return (value or "Twitter authorization failed")[:240] + + +def _transaction_view( + transaction_id: str, + transaction: OAuthTransaction, + *, + authorize_url: str | None = None, +) -> OAuthTransactionView: + return OAuthTransactionView( + id=transaction_id, + status=transaction.status, + authorize_url=authorize_url, + expires_at=transaction.expires_at, + error=transaction.error, + ) + + +def _terminal( + transaction: OAuthTransaction, + status: typing.Literal["succeeded", "failed", "expired"], + *, + error: str | None = None, +) -> OAuthTransaction: + return transaction.model_copy( + update={ + "status": status, + "provider_state": None, + "pkce_verifier": None, + "closed_at": _now(), + "error": error, + } + ) + + +def _invalidate_mismatched_oauth_state( + config: TwitterExtensionConfig, + state: TwitterExtensionState, + *, + reason: str, +) -> tuple[TwitterExtensionState, bool]: + """Invalidate authorization material that belongs to another OAuth App.""" + fingerprint = _fingerprint(config) + changed = state.account is not None and state.account.app_fingerprint != fingerprint + if changed: + state.account = None + transactions = { + key: _terminal(value, "expired", error=reason) + if value.status in {"pending", "exchanging"} and value.app_fingerprint != fingerprint + else value + for key, value in state.oauth_transactions.items() + } + if transactions != state.oauth_transactions: + state.oauth_transactions = transactions + changed = True + return state, changed + + +def _disable_bookmark_schedule(state: TwitterExtensionState) -> None: + """Stop setup-owned collection while retaining its reusable Source and Cron.""" + if state.bookmark_cron_id is None: + return + with SessionLocal() as db: + cron = db.get(CronModel, state.bookmark_cron_id) + if cron is None or not cron.enabled: + return + CronManager.update( + state.bookmark_cron_id, + CronForm( + schedule=cron.schedule, + enabled=False, + job_type=cron.job_type, + job_parameters=dict(cron.job_parameters), + job_timeout_seconds=cron.job_timeout_seconds, + ), + ) + + +def _reconcile_oauth_state() -> TwitterExtensionState: + """Reconcile direct deployment config writes before setup becomes reachable.""" + config = _config() + state = _state() + _, changed = _invalidate_mismatched_oauth_state( + config, + state.model_copy(deep=True), + reason="OAuth App changed", + ) + if not changed: + return state + _disable_bookmark_schedule(state) + + def reconcile(config_model, state_model): + from . import TwitterExtensionConfig + + current_config = TwitterExtensionConfig.model_validate(config_model) + current_state = TwitterExtensionState.model_validate(state_model) + reconciled, _ = _invalidate_mismatched_oauth_state( + current_config, + current_state, + reason="OAuth App changed", + ) + return current_config, reconciled + + _, reconciled = _extension().mutate_config_and_state(reconcile) + return TwitterExtensionState.model_validate(reconciled) + + +def _bookmark_source_status( + state: TwitterExtensionState, +) -> tuple[ + int | None, + int | None, + tuple[BookmarkSourceView, ...], + SetupCollectAt, + bool, +]: + with SessionLocal() as db: + sources = db.exec( + sqlmodel.select(SourceModel) + .where(SourceModel.type == BOOKMARK_SOURCE_TYPE) + .order_by(sqlmodel.col(SourceModel.id)) + ).all() + views = tuple( + BookmarkSourceView( + source_id=source.id, + nickname=source.nickname or "Twitter Bookmarks", + ) + for source in sources + if source.id is not None + ) + source = next( + (item for item in sources if item.id == state.bookmark_source_id), + None, + ) + cron = ( + None if state.bookmark_cron_id is None else db.get(CronModel, state.bookmark_cron_id) + ) + + collect_at = ( + SetupCollectAt.from_cron_schedule(cron.schedule) if cron is not None else None + ) or SetupCollectAt() + account = state.account + expected_parameters = ( + None + if source is None or account is None + else { + "source": source.id, + "config": { + "full": False, + "result_limit": 40, + "authorization_id": account.authorization_id, + }, + } + ) + ready = ( + cron is not None + and cron.enabled + and cron.job_type == SOURCE_COLLECT_JOB_TYPE + and cron.job_parameters == expected_parameters + ) + return ( + source.id if source is not None else None, + cron.id if cron is not None else None, + views, + collect_at, + ready, + ) + + +def get_setup_status() -> TwitterSetupStatus: + config = _config() + state = _reconcile_oauth_state() + source_id, cron_id, sources, collect_at, source_ready = _bookmark_source_status(state) + configured = bool(config.client_id and config.client_secret) + account = state.account + connected = ( + account is not None + and configured + and not account.reconnect_required + and account.app_fingerprint == _fingerprint(config) + ) + return TwitterSetupStatus( + backend=config.backend, + callback_url=_redirect_uri(), + oauth_app_configured=configured, + client_id=config.client_id or None, + connected=connected, + user_id=account.user_id if connected and account is not None else None, + handle=account.handle if connected and account is not None else None, + scopes=account.scopes if connected and account is not None else (), + reconnect_required=bool(account and account.reconnect_required), + bookmark_source_id=source_id, + bookmark_cron_id=cron_id, + bookmark_sources=sources, + collect_at=collect_at, + bookmark_source_ready=source_ready, + ready=connected and source_ready, + ) + + +def save_oauth_app(body: SaveOAuthAppCommand) -> TwitterSetupStatus: + current_config = _config() + current_state = _state() + next_config = current_config.model_copy( + update={ + "backend": "official", + "client_id": body.client_id.strip(), + "client_secret": body.client_secret, + } + ) + fingerprint_changed = _fingerprint(next_config) != _fingerprint(current_config) + has_live_setup = current_state.account is not None or any( + value.status in {"pending", "exchanging"} + for value in current_state.oauth_transactions.values() + ) + if fingerprint_changed and has_live_setup and not body.confirm_account_reset: + raise TwitterSetupConflict( + "Replacing the OAuth App requires confirmation because it disconnects the account" + ) + if fingerprint_changed: + _disable_bookmark_schedule(current_state) + + def update(config_model, state_model): + from . import TwitterExtensionConfig + + config = TwitterExtensionConfig.model_validate(config_model) + state = TwitterExtensionState.model_validate(state_model) + next_config = config.model_copy( + update={ + "backend": "official", + "client_id": body.client_id.strip(), + "client_secret": body.client_secret, + } + ) + changed = _fingerprint(next_config) != _fingerprint(config) + live_setup = state.account is not None or any( + value.status in {"pending", "exchanging"} + for value in state.oauth_transactions.values() + ) + if changed and live_setup and not body.confirm_account_reset: + raise TwitterSetupConflict( + "Replacing the OAuth App requires confirmation because it disconnects the account" + ) + if changed: + state, _ = _invalidate_mismatched_oauth_state( + next_config, + state, + reason="OAuth App changed", + ) + return next_config, state + + _extension().mutate_config_and_state(update) + return get_setup_status() + + +def begin_oauth() -> OAuthTransactionView: + config = _config() + if config.backend != "official" or not config.client_id or not config.client_secret: + raise TwitterSetupConflict("Configure the Twitter OAuth App first") + transaction_id = str(uuid.uuid4()) + provider_state = secrets.token_urlsafe(32) + verifier, challenge = _pkce_pair() + redirect_uri = _redirect_uri() + now = _now() + transaction = OAuthTransaction( + status="pending", + provider_state=provider_state, + pkce_verifier=verifier, + app_fingerprint=_fingerprint(config), + redirect_uri=redirect_uri, + created_at=now, + expires_at=now + TRANSACTION_LIFETIME, + ) + + def update(model: pydantic.BaseModel) -> pydantic.BaseModel: + state = TwitterExtensionState.model_validate(model) + current_time = _now() + transactions = { + key: _terminal(value, "expired", error="Superseded by a newer setup") + if value.status in {"pending", "exchanging"} + else value + for key, value in state.oauth_transactions.items() + if value.closed_at is None or value.closed_at + TERMINAL_RETENTION > current_time + } + transactions[transaction_id] = transaction + ordered = sorted( + transactions.items(), key=lambda item: item[1].created_at, reverse=True + )[:MAX_TRANSACTIONS] + state.oauth_transactions = dict(ordered) + return state + + _extension().mutate_state(update) + client = OAuth2Client( + client_id=config.client_id, + client_secret=config.client_secret, + scope=" ".join(SCOPES), + redirect_uri=redirect_uri, + token_endpoint_auth_method="client_secret_basic", + timeout=10, + ) + try: + authorize_url, _ = client.create_authorization_url( + AUTHORIZE_URL, + state=provider_state, + code_verifier=verifier, + code_challenge=challenge, + code_challenge_method="S256", + ) + finally: + typing.cast(httpx.Client, client).close() + return _transaction_view(transaction_id, transaction, authorize_url=authorize_url) + + +def get_oauth_transaction(transaction_id: str) -> OAuthTransactionView: + transaction = _state().oauth_transactions.get(transaction_id) + if transaction is None: + raise TwitterSetupError("OAuth transaction not found") + if transaction.status in {"pending", "exchanging"} and transaction.expires_at <= _now(): + + def expire(model: pydantic.BaseModel) -> pydantic.BaseModel: + state = TwitterExtensionState.model_validate(model) + current = state.oauth_transactions.get(transaction_id) + if current is not None and current.status in {"pending", "exchanging"}: + state.oauth_transactions[transaction_id] = _terminal( + current, "expired", error="OAuth transaction expired" + ) + return state + + state = TwitterExtensionState.model_validate(_extension().mutate_state(expire)) + transaction = state.oauth_transactions[transaction_id] + return _transaction_view(transaction_id, transaction) + + +async def _exchange_code( + config: TwitterExtensionConfig, + transaction: OAuthTransaction, + code: str, +) -> tuple[dict[str, typing.Any], str, str]: + client = AsyncOAuth2Client( + client_id=config.client_id, + client_secret=config.client_secret, + redirect_uri=transaction.redirect_uri, + token_endpoint_auth_method="client_secret_basic", + timeout=10, + ) + try: + try: + token = await client.fetch_token( + TOKEN_URL, + code=code, + code_verifier=transaction.pkce_verifier, + ) + response = await typing.cast(httpx.AsyncClient, client).get(CURRENT_USER_URL) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, dict) or not isinstance(payload.get("data"), dict): + raise TwitterProviderError("Twitter returned an invalid account response") + user = payload["data"] + user_id = user.get("id") + handle = user.get("username") + if not isinstance(user_id, str) or not isinstance(handle, str): + raise TwitterProviderError("Twitter user response is incomplete") + return dict(token), user_id, handle + except OAuthError: + raise TwitterProviderError("Twitter rejected the authorization exchange") from None + except httpx.TimeoutException: + raise TwitterProviderError("Twitter authorization timed out") from None + except httpx.HTTPError: + raise TwitterProviderError("Twitter authorization request failed") from None + except (TypeError, ValueError): + raise TwitterProviderError( + "Twitter returned an invalid authorization response" + ) from None + finally: + await typing.cast(httpx.AsyncClient, client).aclose() + + +def _claim_callback(provider_state: str) -> tuple[str, OAuthTransaction]: + box: dict[str, typing.Any] = {} + + def claim(model: pydantic.BaseModel) -> pydantic.BaseModel: + state = TwitterExtensionState.model_validate(model) + matched = next( + ( + (key, value) + for key, value in state.oauth_transactions.items() + if value.provider_state == provider_state + ), + None, + ) + if matched is None: + raise TwitterSetupConflict("OAuth transaction is unknown") + transaction_id, transaction = matched + if transaction.status != "pending" or transaction.expires_at <= _now(): + raise TwitterSetupConflict("OAuth transaction is no longer active") + box["id"] = transaction_id + box["transaction"] = transaction + state.oauth_transactions[transaction_id] = transaction.model_copy( + update={"status": "exchanging"} + ) + return state + + _extension().mutate_state(claim) + return typing.cast(str, box["id"]), typing.cast(OAuthTransaction, box["transaction"]) + + +def _finish_callback( + transaction_id: str, + transaction: OAuthTransaction, + *, + account: TwitterAccount | None = None, + error: str | None = None, +) -> None: + def finish(model: pydantic.BaseModel) -> pydantic.BaseModel: + state = TwitterExtensionState.model_validate(model) + current = state.oauth_transactions.get(transaction_id) + if ( + current is None + or current.status != "exchanging" + or current.provider_state != transaction.provider_state + or current.app_fingerprint != transaction.app_fingerprint + ): + raise TwitterSetupConflict("OAuth transaction was superseded") + if account is None: + state.oauth_transactions[transaction_id] = _terminal( + current, "failed", error=error or "Twitter authorization failed" + ) + else: + state.account = account + state.oauth_transactions[transaction_id] = _terminal(current, "succeeded") + return state + + _extension().mutate_state(finish) + + +def _callback_html(status_code: int, title: str, message: str) -> HTMLResponse: + safe_title = html.escape(title) + safe_message = html.escape(message) + return HTMLResponse( + "" + f"{safe_title}

{safe_title}

" + f"

{safe_message}

You can close this window.

", + status_code=status_code, + ) + + +async def oauth_callback( + code: str | None = None, + state: str | None = None, + error: str | None = None, + error_description: str | None = None, +) -> HTMLResponse: + del error_description + transaction_id: str | None = None + transaction: OAuthTransaction | None = None + if not state: + return _callback_html(400, "Twitter setup failed", "Missing OAuth state.") + try: + transaction_id, transaction = _claim_callback(state) + if error: + message = ( + "Twitter authorization was declined" + if error == "access_denied" + else "Twitter returned an authorization error" + ) + _finish_callback(transaction_id, transaction, error=message) + return _callback_html(400, "Twitter setup declined", message) + if not code: + raise TwitterSetupConflict("Missing authorization code") + config = _config() + if _fingerprint(config) != transaction.app_fingerprint: + raise TwitterSetupConflict("OAuth App changed during authorization") + token, user_id, handle = await _exchange_code(config, transaction, code) + raw_scope = token.get("scope", "") + scopes = tuple(raw_scope.split()) if isinstance(raw_scope, str) else SCOPES + account = TwitterAccount( + token=token, + user_id=user_id, + handle=handle, + scopes=scopes, + app_fingerprint=transaction.app_fingerprint, + authorization_id=str(uuid.uuid4()), + connected_at=_now(), + ) + _finish_callback(transaction_id, transaction, account=account) + except TwitterSetupConflict as failure: + message = _bounded_provider_error(failure) + try: + if transaction_id is not None and transaction is not None: + _finish_callback(transaction_id, transaction, error=message) + except TwitterSetupError: + pass + return _callback_html(400, "Twitter setup failed", message) + except (httpx.HTTPError, TwitterSetupError) as failure: + message = _bounded_provider_error(failure) + try: + if transaction_id is not None and transaction is not None: + _finish_callback(transaction_id, transaction, error=message) + except TwitterSetupError: + pass + return _callback_html(502, "Twitter is unavailable", message) + return _callback_html(200, "Twitter connected", "Authorization completed successfully.") + + +def disconnect_account() -> TwitterSetupStatus: + _disable_bookmark_schedule(_state()) + + def disconnect(model: pydantic.BaseModel) -> pydantic.BaseModel: + state = TwitterExtensionState.model_validate(model) + state.account = None + state.oauth_transactions = { + key: _terminal(value, "expired", error="Account disconnected") + if value.status in {"pending", "exchanging"} + else value + for key, value in state.oauth_transactions.items() + } + return state + + _extension().mutate_state(disconnect) + return get_setup_status() + + +def _bookmark_job_parameters( + source_id: int, authorization_id: str +) -> dict[str, typing.Any]: + return { + "source": source_id, + "config": { + "full": False, + "result_limit": 40, + "authorization_id": authorization_id, + }, + } + + +def configure_bookmark_source(body: ConfigureBookmarkSourceCommand) -> TwitterSetupStatus: + state = _state() + account = state.account + if account is None or account.reconnect_required: + raise TwitterSetupConflict("Connect a Twitter account first") + if body.source_id is not None: + with SessionLocal() as db: + source = db.get(SourceModel, body.source_id) + if source is None or source.type != BOOKMARK_SOURCE_TYPE: + raise TwitterSetupConflict("Bookmark Source does not exist") + else: + source = SourceManager.create( + BOOKMARK_SOURCE_TYPE, + nickname=body.nickname, + ) + source_id = source.id + if source_id is None: + raise TwitterSetupError("Bookmark Source has no identifier") + try: + form = CronForm( + schedule=body.collect_at.cron_schedule(), + enabled=False, + job_type=SOURCE_COLLECT_JOB_TYPE, + job_parameters=_bookmark_job_parameters(source_id, account.authorization_id), + ) + cron = ( + CronManager.create(form) + if state.bookmark_cron_id is None + else CronManager.update(state.bookmark_cron_id, form) + ) + except ValueError as error: + raise TwitterSetupConflict(str(error)) from error + if cron.id is None: + raise TwitterSetupError("Bookmark schedule has no identifier") + + def select(model: pydantic.BaseModel) -> pydantic.BaseModel: + selected = TwitterExtensionState.model_validate(model) + if ( + selected.account is None + or selected.account.authorization_id != account.authorization_id + ): + raise TwitterSetupConflict("Twitter account changed during setup") + selected.bookmark_source_id = source_id + selected.bookmark_cron_id = cron.id + return selected + + _extension().mutate_state(select) + return get_setup_status() + + +async def finish_setup() -> TwitterSetupStatus: + from .api import OfficialAPI + + state = _state() + account = state.account + if account is None or account.reconnect_required: + raise TwitterSetupConflict("Connect a Twitter account first") + if state.bookmark_source_id is None or state.bookmark_cron_id is None: + raise TwitterSetupConflict("Configure a Bookmark Source first") + api = OfficialAPI.from_extension(expected_authorization_id=account.authorization_id) + try: + user_id, handle = await api.get_user() + finally: + await api.close() + + with SessionLocal() as db: + source = db.get(SourceModel, state.bookmark_source_id) + cron = db.get(CronModel, state.bookmark_cron_id) + if source is None or source.type != BOOKMARK_SOURCE_TYPE or cron is None: + raise TwitterSetupConflict("Bookmark collection resources no longer exist") + source_id = source.id + if source_id is None: + raise TwitterSetupConflict("Bookmark Source has no identifier") + schedule = cron.schedule + rebound = CronManager.update( + state.bookmark_cron_id, + CronForm( + schedule=schedule, + enabled=True, + job_type=SOURCE_COLLECT_JOB_TYPE, + job_parameters=_bookmark_job_parameters(source_id, account.authorization_id), + job_timeout_seconds=cron.job_timeout_seconds, + ), + ) + + def update_identity(model: pydantic.BaseModel) -> pydantic.BaseModel: + current = TwitterExtensionState.model_validate(model) + if ( + current.account is None + or current.account.authorization_id != account.authorization_id + or current.bookmark_source_id != source_id + or current.bookmark_cron_id != rebound.id + ): + raise TwitterSetupConflict("Twitter setup changed during Finish") + current.account.user_id = user_id + current.account.handle = handle + return current + + _extension().mutate_state(update_identity) + if rebound.id is None: + raise TwitterSetupError("Bookmark schedule has no identifier") + job = CronManager.run_now(rebound.id) + if job.id is not None: + await JobManager.check() + return get_setup_status() + + +async def execute_setup_command(command: TwitterSetupCommand) -> TwitterSetupResult: + if isinstance(command, GetStatusCommand): + return get_setup_status() + if isinstance(command, SaveOAuthAppCommand): + return save_oauth_app(command) + if isinstance(command, BeginOAuthCommand): + return begin_oauth() + if isinstance(command, GetOAuthTransactionCommand): + return get_oauth_transaction(command.transaction_id) + if isinstance(command, DisconnectAccountCommand): + return disconnect_account() + if isinstance(command, ConfigureBookmarkSourceCommand): + return configure_bookmark_source(command) + if isinstance(command, FinishSetupCommand): + return await finish_setup() + typing.assert_never(command) + + +def _http_error(error: TwitterSetupError) -> typing.NoReturn: + if str(error) == "OAuth transaction not found": + status = fastapi.status.HTTP_404_NOT_FOUND + elif isinstance(error, TwitterProviderError): + status = fastapi.status.HTTP_502_BAD_GATEWAY + else: + status = fastapi.status.HTTP_409_CONFLICT + raise fastapi.HTTPException(status_code=status, detail=str(error)) from error + + +def register_setup_routes(router: fastapi.APIRouter) -> None: + protected = fastapi.APIRouter(dependencies=[fastapi.Depends(require_peer_jwt)]) + + @protected.post("/setup", response_model=TwitterSetupResult) + async def setup_command(command: TwitterSetupCommand): + try: + return await execute_setup_command(command) + except TwitterSetupError as failure: + _http_error(failure) + + router.include_router(protected) + router.add_api_route( + "/auth/callback", + oauth_callback, + methods=["GET"], + response_class=HTMLResponse, + ) diff --git a/migrations/revision-integrity.json b/migrations/revision-integrity.json index 3226a29..38dd29b 100644 --- a/migrations/revision-integrity.json +++ b/migrations/revision-integrity.json @@ -14,6 +14,7 @@ "b9c0d1e2f3a4_add_agent_definitions.py": "59529e759610193da3427c5b18e0c9be25e46e5e2419a3237bcf9ef88c263fa3", "c0d1e2f3a4b5_adopt_peer_capability_delegation.py": "7474fcda0b55fe2ce2047f761579459385aac9f43e7359ced881b8eb1fc6c265", "c4e8a7b6d5f0_converge_production_schema.py": "fb5687ad523c64297fe101d109918af2501bec26219de78353944a762eadfe4e", + "c6d7e8f9a0b1_add_extension_setup_state.py": "5aab0d1dda6932fbac53f5a6adf4b504f994e8c860833b64fc21fb9cb8d8fc3e", "c9d2e3f4a5b6_move_trigger_helper_internal.py": "6fab826c66cef1b16540142ae3d17ff953dc2ca49c79a4cffea95d4520701e6e", "d0e3f4a5b6c7_add_octet_stream_handler.py": "0d7127d340b878243d894e27f8b97d5982aa01c757703528d3982eb89ae9219e", "d9f4e2a1b7c3_adopt_peer_database_protocol.py": "9a163533b5a0619e51bfb94ba7ad0c5c3d168fba5808b08f67531fd8f7e5f263", diff --git a/migrations/versions/c6d7e8f9a0b1_add_extension_setup_state.py b/migrations/versions/c6d7e8f9a0b1_add_extension_setup_state.py new file mode 100644 index 0000000..ce13340 --- /dev/null +++ b/migrations/versions/c6d7e8f9a0b1_add_extension_setup_state.py @@ -0,0 +1,129 @@ +"""Add deployment-wide Extension setup state. + +Revision ID: c6d7e8f9a0b1 +Revises: 3f7a9c2d5e1b +Create Date: 2026-08-16 +""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from app.database_contract.constants import ( + CONTRACT_REVISION, + INTERNAL_SCHEMA, + PROTOCOL_SCHEMA, +) + + +revision: str = "c6d7e8f9a0b1" +down_revision: str | Sequence[str] | None = "3f7a9c2d5e1b" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _replace_state_guard(*, include_setup_state: bool) -> None: + state_insert_guard = ( + """ + IF TG_OP = 'INSERT' AND NEW.state <> '{}'::jsonb THEN + RAISE EXCEPTION 'extensions.state must be empty on insert' + USING ERRCODE = '23514'; + END IF; + """ + if include_setup_state + else "" + ) + state_version_guard = ( + """ + IF TG_OP = 'UPDATE' + AND OLD.state <> '{}'::jsonb + AND NEW.version IS DISTINCT FROM OLD.version + THEN + RAISE EXCEPTION 'cannot change extension version while state is not empty' + USING ERRCODE = '23514'; + END IF; + """ + if include_setup_state + else "" + ) + op.execute( + f""" + CREATE OR REPLACE FUNCTION {INTERNAL_SCHEMA}.enforce_extension_state_authority() + RETURNS trigger + LANGUAGE plpgsql + SET search_path = pg_catalog + AS $$ + BEGIN + IF TG_OP = 'INSERT' AND cardinality(NEW.enabled) <> 0 THEN + RAISE EXCEPTION 'extensions.enabled must be empty on insert' + USING ERRCODE = '23514'; + END IF; + {state_insert_guard} + IF TG_OP = 'UPDATE' + AND cardinality(OLD.enabled) <> 0 + AND NEW.version IS DISTINCT FROM OLD.version + THEN + RAISE EXCEPTION 'cannot change extension version while peers are enabled' + USING ERRCODE = '23514'; + END IF; + {state_version_guard} + IF TG_OP = 'DELETE' AND cardinality(OLD.enabled) <> 0 THEN + RAISE EXCEPTION 'cannot delete extension while peers are enabled' + USING ERRCODE = '23514'; + END IF; + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; + END + $$ + """ + ) + + +def _set_contract_revision(value: str) -> None: + op.execute( + sa.text( + f""" + UPDATE {INTERNAL_SCHEMA}.contract_state + SET contract_revision = :revision, + updated_at = CURRENT_TIMESTAMP + WHERE singleton + """ + ).bindparams(revision=value) + ) + + +def upgrade() -> None: + op.add_column( + "extensions", + sa.Column( + "state", + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("'{}'::jsonb"), + nullable=False, + ), + schema=PROTOCOL_SCHEMA, + ) + op.create_check_constraint( + "extensions_state_object", + "extensions", + "jsonb_typeof(state) = 'object'", + schema=PROTOCOL_SCHEMA, + ) + _replace_state_guard(include_setup_state=True) + _set_contract_revision(CONTRACT_REVISION) + + +def downgrade() -> None: + _replace_state_guard(include_setup_state=False) + op.drop_constraint( + "extensions_state_object", + "extensions", + schema=PROTOCOL_SCHEMA, + type_="check", + ) + op.drop_column("extensions", "state", schema=PROTOCOL_SCHEMA) + _set_contract_revision("extension-registry-feature-retrieval-v1") diff --git a/pyproject.toml b/pyproject.toml index d9ad111..48815e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,12 @@ [project] name = "inkcre-core" -version = "0.1.0" +version = "0.1.1" description = "Backend of InKCre." authors = [{ name = "Lan_zhijiang", email = "lanzhijiang@foxmail.com" }] readme = "README.md" requires-python = ">=3.12,<3.13" -dependencies = [ +dependencies = [ + "authlib>=1.7.2,<2.0.0", "pydantic (>=2.11.7,<3.0.0)", "pydantic-settings (>=2.14.2,<3.0.0)", "fastapi (>=0.139.2,<0.140.0)", diff --git a/tests/extensions/runtime_support.py b/tests/extensions/runtime_support.py index 5aff70e..cf1f5a1 100644 --- a/tests/extensions/runtime_support.py +++ b/tests/extensions/runtime_support.py @@ -29,14 +29,39 @@ def publish_extension( raise_server_exceptions: bool = True, ) -> PublishedExtension: runtime_app = app or fastapi.FastAPI() + runtime_config = dict(config or {}) + runtime_state: dict[str, typing.Any] = {} + + def persist_config(value: dict[str, typing.Any]) -> None: + runtime_config.clear() + runtime_config.update(value) + + def mutate_state(transform): + next_state = transform(dict(runtime_state)) + runtime_state.clear() + runtime_state.update(next_state) + return dict(runtime_state) + + def mutate_config_and_state(transform): + next_config, next_state = transform(dict(runtime_config), dict(runtime_state)) + runtime_config.clear() + runtime_config.update(next_config) + runtime_state.clear() + runtime_state.update(next_state) + return dict(runtime_config), dict(runtime_state) + extension.unpublish() extension.release_runtime() extension.on_start( runtime_app, ExtensionRuntimeRecord( extension_id=extension.__extid__, - config=config or {}, - persist_config=lambda _config: None, + config=dict(runtime_config), + read_config=lambda: dict(runtime_config), + persist_config=persist_config, + read_state=lambda: dict(runtime_state), + mutate_state=mutate_state, + mutate_config_and_state=mutate_config_and_state, persist_config_schema=lambda _schema: None, ), ) diff --git a/tests/migrations/test_extension_peer_enabled_rpc.py b/tests/migrations/test_extension_peer_enabled_rpc.py index 1ca2869..121d407 100644 --- a/tests/migrations/test_extension_peer_enabled_rpc.py +++ b/tests/migrations/test_extension_peer_enabled_rpc.py @@ -4,9 +4,12 @@ from collections.abc import Iterator import importlib +import os +from pathlib import Path import shutil import socket import subprocess +import sys import threading import typing import uuid @@ -14,10 +17,23 @@ import psycopg import pytest import sqlalchemy +from sqlalchemy.exc import ProgrammingError import sqlmodel +import app.business.cron as cron_module +import app.business.job as job_module +import app.business.source.job as source_job_module +import app.business.source.main as source_module +from app.business.cron import CronManager from app.business.extension.errors import ExtensionStateConflictError -from app.business.extension.state import SQLExtensionStateStore +from app.business.extension.state import SQLExtensionStore +from app.business.job import JobManager +from app.business.source import SOURCE_COLLECT_JOB_TYPE, SourceManager +from app.schemas.cron import CronForm +from extensions.twitter.bookmark import Source as BookmarkSource + + +PROJECT_ROOT = Path(__file__).resolve().parents[2] @pytest.fixture(scope="module") @@ -69,23 +85,40 @@ def postgres_connection_info(tmp_path_factory) -> Iterator[dict[str, object]]: @pytest.fixture(scope="module") def rpc_database(postgres_connection_info, monkeypatch_module): - migration = importlib.import_module( + native_migration = importlib.import_module( "migrations.versions.b8c1d2e3f4a5_native_extension_distribution_cutover" ) + merge_migration = importlib.import_module( + "migrations.versions.3f7a9c2d5e1b_merge_extension_registry_feature_retrieval" + ) + setup_migration = importlib.import_module( + "migrations.versions.c6d7e8f9a0b1_add_extension_setup_state" + ) guard_statements: list[str] = [] - monkeypatch_module.setattr(migration.op, "execute", guard_statements.append) - migration._create_extension_state_guard() + monkeypatch_module.setattr(native_migration.op, "execute", guard_statements.append) + native_migration._create_extension_state_guard() assert len(guard_statements) == 2 - statements: list[str] = [] - monkeypatch_module.setattr(migration.op, "execute", statements.append) - migration._create_peer_enable_rpc() - assert len(statements) == 2 + revoke_statements: list[str] = [] + monkeypatch_module.setattr(native_migration.op, "execute", revoke_statements.append) + native_migration._create_peer_enable_rpc() + assert len(revoke_statements) == 2 + merge_statements: list[object] = [] + monkeypatch_module.setattr(merge_migration.op, "execute", merge_statements.append) + merge_migration.upgrade() + setup_guard_statements: list[str] = [] + monkeypatch_module.setattr( + setup_migration.op, + "execute", + setup_guard_statements.append, + ) + setup_migration._replace_state_guard(include_setup_state=True) + assert len(setup_guard_statements) == 1 with psycopg.connect(**postgres_connection_info, autocommit=True) as connection: connection.execute("CREATE SCHEMA inkcre") connection.execute("CREATE SCHEMA inkcre_internal") connection.execute("CREATE ROLE authenticated NOLOGIN") connection.execute("CREATE ROLE anonymous NOLOGIN") - connection.execute("CREATE TABLE inkcre.clients (id uuid PRIMARY KEY)") + connection.execute("CREATE TABLE inkcre.peers (id uuid PRIMARY KEY)") connection.execute( """ CREATE TABLE inkcre.extensions ( @@ -94,16 +127,18 @@ def rpc_database(postgres_connection_info, monkeypatch_module): enabled uuid[] NOT NULL DEFAULT '{}', nickname text, config jsonb NOT NULL DEFAULT '{}', + state jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(state) = 'object'), config_schema jsonb ) """ ) for statement in guard_statements: connection.execute(typing.cast(typing.LiteralString, statement)) - for statement in statements: - connection.execute(typing.cast(typing.LiteralString, statement)) + connection.execute(typing.cast(typing.LiteralString, setup_guard_statements[0])) + connection.execute(typing.cast(typing.LiteralString, merge_statements[0])) + connection.execute(typing.cast(typing.LiteralString, revoke_statements[1])) connection.execute("GRANT USAGE ON SCHEMA inkcre TO authenticated") - connection.execute("GRANT SELECT ON inkcre.clients TO authenticated") + connection.execute("GRANT SELECT ON inkcre.peers TO authenticated") connection.execute("GRANT SELECT, INSERT, DELETE ON inkcre.extensions TO authenticated") connection.execute( "GRANT UPDATE (version, nickname, config, config_schema) " @@ -126,6 +161,47 @@ def monkeypatch_module(): patcher.undo() +@pytest.fixture(scope="module") +def setup_domain_engine(postgres_connection_info): + database = "extension_setup_domains" + with psycopg.connect(**postgres_connection_info, autocommit=True) as connection: + connection.execute(f"CREATE DATABASE {database}") + database_url = ( + f"postgresql+psycopg://127.0.0.1:{postgres_connection_info['port']}/{database}" + ) + environment = { + **os.environ, + "INKCRE_ENV_FILE": "", + "DATABASE_URL": database_url, + "MIGRATION_DATABASE_URL": database_url, + "CORE_DATABASE_PASSWORD": "core-runtime-contract-password-0001", + "POSTGREST_DATABASE_PASSWORD": "postgrest-contract-password-0001", + } + migrated = subprocess.run( + [sys.executable, "-m", "alembic", "upgrade", "head"], + cwd=PROJECT_ROOT, + env=environment, + check=False, + capture_output=True, + text=True, + ) + assert migrated.returncode == 0, migrated.stderr + provisioned = subprocess.run( + [sys.executable, "scripts/database.py", "provision-roles"], + cwd=PROJECT_ROOT, + env=environment, + check=False, + capture_output=True, + text=True, + ) + assert provisioned.returncode == 0, provisioned.stderr + engine = sqlalchemy.create_engine(database_url) + try: + yield engine + finally: + engine.dispose() + + def test_rpc_rejects_unknown_peer_without_mutating_row(rpc_database): missing_peer = uuid.uuid4() with psycopg.connect(**rpc_database, autocommit=True) as connection: @@ -149,7 +225,7 @@ def test_rpc_rejects_unknown_peer_without_mutating_row(rpc_database): def test_rpc_atomically_preserves_concurrent_distinct_peer_additions(rpc_database): peers = (uuid.uuid4(), uuid.uuid4()) with psycopg.connect(**rpc_database, autocommit=True) as connection: - connection.execute("INSERT INTO inkcre.clients (id) VALUES (%s), (%s)", peers) + connection.execute("INSERT INTO inkcre.peers (id) VALUES (%s), (%s)", peers) barrier = threading.Barrier(2) failures: list[Exception] = [] @@ -195,7 +271,7 @@ def enable(peer_id: uuid.UUID) -> None: def test_authenticated_can_execute_but_anonymous_cannot_mutate(rpc_database): peer = uuid.uuid4() with psycopg.connect(**rpc_database, autocommit=True) as connection: - connection.execute("INSERT INTO inkcre.clients (id) VALUES (%s)", (peer,)) + connection.execute("INSERT INTO inkcre.peers (id) VALUES (%s)", (peer,)) connection.execute("SET ROLE authenticated") connection.execute( "SELECT * FROM inkcre.set_extension_peer_enabled(%s, %s, false)", @@ -221,7 +297,7 @@ def test_authenticated_can_execute_but_anonymous_cannot_mutate(rpc_database): def test_authenticated_cannot_bypass_rpc_or_insert_enabled_intent(rpc_database): peer = uuid.uuid4() with psycopg.connect(**rpc_database, autocommit=True) as connection: - connection.execute("INSERT INTO inkcre.clients (id) VALUES (%s)", (peer,)) + connection.execute("INSERT INTO inkcre.peers (id) VALUES (%s)", (peer,)) connection.execute("SET ROLE authenticated") with pytest.raises(psycopg.errors.InsufficientPrivilege): connection.execute( @@ -239,13 +315,18 @@ def test_authenticated_cannot_bypass_rpc_or_insert_enabled_intent(rpc_database): "UPDATE inkcre.extensions SET config = '{\"safe\": true}'::jsonb " "WHERE name = 'inkcre/test'" ) + with pytest.raises(psycopg.errors.InsufficientPrivilege): + connection.execute( + "UPDATE inkcre.extensions SET state = '{\"unsafe\": true}'::jsonb " + "WHERE name = 'inkcre/test'" + ) connection.execute("RESET ROLE") def test_database_blocks_version_change_and_delete_while_enabled(rpc_database): peer = uuid.uuid4() with psycopg.connect(**rpc_database, autocommit=True) as connection: - connection.execute("INSERT INTO inkcre.clients (id) VALUES (%s)", (peer,)) + connection.execute("INSERT INTO inkcre.peers (id) VALUES (%s)", (peer,)) connection.execute( "SELECT * FROM inkcre.set_extension_peer_enabled(%s, %s, true)", ("inkcre/test", peer), @@ -262,6 +343,31 @@ def test_database_blocks_version_change_and_delete_while_enabled(rpc_database): ) +def test_database_state_gate_and_uninstall_boundary(rpc_database): + with psycopg.connect(**rpc_database, autocommit=True) as connection: + with pytest.raises(psycopg.errors.CheckViolation, match="state must be empty"): + connection.execute( + "INSERT INTO inkcre.extensions (name, version, state) " + "VALUES ('inkcre/injected-state', '1.0.0', '{\"step\": 1}'::jsonb)" + ) + connection.execute( + "INSERT INTO inkcre.extensions (name, version) VALUES ('inkcre/stateful', '1.0.0')" + ) + connection.execute( + "UPDATE inkcre.extensions SET state = '{\"step\": 1}'::jsonb " + "WHERE name = 'inkcre/stateful'" + ) + with pytest.raises(psycopg.errors.CheckViolation, match="state is not empty"): + connection.execute( + "UPDATE inkcre.extensions SET version = '2.0.0' WHERE name = 'inkcre/stateful'" + ) + connection.execute("DELETE FROM inkcre.extensions WHERE name = 'inkcre/stateful'") + + assert connection.execute( + "SELECT count(*) FROM inkcre.extensions WHERE name = 'inkcre/stateful'" + ).fetchone() == (0,) + + def test_internal_guard_is_not_in_the_postgrest_protocol_schema(rpc_database): with psycopg.connect(**rpc_database, autocommit=True) as connection: functions = connection.execute( @@ -288,7 +394,7 @@ def test_concurrent_first_install_returns_semantic_conflict(rpc_database): def make_session() -> sqlmodel.Session: return sqlmodel.Session(engine) - store = SQLExtensionStateStore(make_session) + store = SQLExtensionStore(make_session) with psycopg.connect(**rpc_database) as first: first.execute( "SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))", @@ -306,3 +412,88 @@ def make_session() -> sqlmodel.Session: state = store.install(name, "1.0.0", "First") assert state.version == "1.0.0" engine.dispose() + + +def test_setup_source_and_cron_use_simple_core_owned_operations( + setup_domain_engine, + monkeypatch, +): + def make_session() -> sqlmodel.Session: + return sqlmodel.Session(setup_domain_engine) + + for module in (source_module, cron_module, job_module, source_job_module): + monkeypatch.setattr(module, "SessionLocal", make_session) + + source_type = f"{BookmarkSource.__module__}.{BookmarkSource.__qualname__}" + SourceManager.sync_source_types({source_type: BookmarkSource}) + JobManager.sync_job_types() + + source = SourceManager.create(source_type, nickname="Twitter Bookmarks") + source_id = source.id + assert source_id is not None + + form = CronForm( + schedule="0 6 * * *", + job_type=SOURCE_COLLECT_JOB_TYPE, + job_parameters={ + "source": source_id, + "config": { + "full": False, + "result_limit": 40, + "authorization_id": "authorization-1", + }, + }, + ) + cron = CronManager.create(form) + assert cron.id is not None + first_job = CronManager.run_now(cron.id) + repeated_job = CronManager.run_now(cron.id) + assert repeated_job.id != first_job.id + + rebound = CronManager.update( + cron.id, + form.model_copy( + update={ + "job_parameters": { + **form.job_parameters, + "config": { + **form.job_parameters["config"], + "authorization_id": "authorization-2", + }, + } + } + ), + ) + assert rebound.job_parameters["config"]["authorization_id"] == "authorization-2" + + +def test_core_runtime_owns_state_writes_without_exposing_them_to_browser_peers( + setup_domain_engine, +): + name = "inkcre/state-authority" + with setup_domain_engine.connect().execution_options( + isolation_level="AUTOCOMMIT" + ) as connection: + connection.exec_driver_sql( + "INSERT INTO inkcre.extensions (name, version) VALUES (%s, '1.0.0')", + (name,), + ) + connection.exec_driver_sql("SET ROLE authenticated") + with pytest.raises(ProgrammingError): + connection.exec_driver_sql( + "UPDATE inkcre.extensions SET state = '{\"unsafe\": true}'::jsonb WHERE name = %s", + (name,), + ) + connection.exec_driver_sql("RESET ROLE") + connection.exec_driver_sql("SET ROLE inkcre_core") + connection.exec_driver_sql( + "UPDATE inkcre.extensions SET state = '{\"ready\": true}'::jsonb WHERE name = %s", + (name,), + ) + connection.exec_driver_sql("RESET ROLE") + state = connection.exec_driver_sql( + "SELECT state FROM inkcre.extensions WHERE name = %s", + (name,), + ).scalar_one() + + assert state == {"ready": True} diff --git a/tests/test_extension_registry_config.py b/tests/test_extension_registry_config.py new file mode 100644 index 0000000..2ee6f35 --- /dev/null +++ b/tests/test_extension_registry_config.py @@ -0,0 +1,94 @@ +import pytest + +from app.business.deployment_config import DeploymentConfigManager +from app.business.extension.config import ( + EXTENSION_REGISTRY_CONFIG_KEY, + ExtensionRegistryDeploymentConfig, + normalize_registry_origin, + resolve_extension_registry_origin, +) +from app.business.peer import PeerManager +from app.schemas.peer import CorePeerConfig +from app.settings import settings + + +@pytest.mark.parametrize( + "value", + [ + "ftp://registry.test", + "https://user@registry.test", + "https://registry.test/simple/", + "https://registry.test?channel=preview", + "https://registry.test#fragment", + ], +) +def test_registry_origin_rejects_non_origin_values(value: str): + with pytest.raises(ValueError, match="one HTTP\\(S\\) origin"): + normalize_registry_origin(value) + + +def test_registry_origin_normalizes_one_trailing_slash(): + assert normalize_registry_origin(" https://registry.test/ ") == "https://registry.test" + + +def test_executing_peer_registry_override_wins(monkeypatch): + monkeypatch.setattr( + PeerManager, + "get_current_config", + lambda: CorePeerConfig(extension_registry_url="https://peer.registry.test"), + ) + monkeypatch.setattr( + DeploymentConfigManager, + "get", + lambda key: pytest.fail(f"deployment config must not be read: {key}"), + ) + + assert resolve_extension_registry_origin() == "https://peer.registry.test" + + +def test_deployment_registry_default_precedes_process_fallback(monkeypatch): + monkeypatch.setattr( + PeerManager, + "get_current_config", + lambda: CorePeerConfig(), + ) + monkeypatch.setattr( + DeploymentConfigManager, + "get", + lambda key: ( + {"extension_registry_url": "https://deployment.registry.test/"} + if key == EXTENSION_REGISTRY_CONFIG_KEY + else None + ), + ) + + assert resolve_extension_registry_origin() == "https://deployment.registry.test" + + +def test_process_registry_origin_is_the_final_fallback(monkeypatch): + monkeypatch.setattr( + PeerManager, + "get_current_config", + lambda: CorePeerConfig(), + ) + monkeypatch.setattr(DeploymentConfigManager, "get", lambda _key: None) + + assert resolve_extension_registry_origin() == normalize_registry_origin( + settings.extension_registry_url + ) + + +def test_deployment_registry_contract_is_strict(): + assert ( + ExtensionRegistryDeploymentConfig.model_validate( + {"extension_registry_url": "https://registry.test/"} + ).extension_registry_url + == "https://registry.test" + ) + with pytest.raises(ValueError): + ExtensionRegistryDeploymentConfig.model_validate( + { + "extension_registry_url": "https://registry.test", + "unexpected": True, + } + ) From ae1aa7fa9c3da7c3cd79e230e229575162a9ddee Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 17 Aug 2026 17:10:29 +0800 Subject: [PATCH 02/15] fix(twitter): expose safe OAuth failure stages - distinguish token exchange from current-user lookup failures - report safe provider HTTP status without leaking response bodies --- extensions/twitter/setup_flow.py | 43 +++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/extensions/twitter/setup_flow.py b/extensions/twitter/setup_flow.py index 815176b..da901c1 100644 --- a/extensions/twitter/setup_flow.py +++ b/extensions/twitter/setup_flow.py @@ -591,26 +591,51 @@ async def _exchange_code( code=code, code_verifier=transaction.pkce_verifier, ) + except OAuthError: + raise TwitterProviderError("Twitter rejected the token exchange") from None + except httpx.TimeoutException: + raise TwitterProviderError("Twitter token exchange timed out") from None + except httpx.HTTPStatusError as failure: + raise TwitterProviderError( + f"Twitter token exchange failed (HTTP {failure.response.status_code})" + ) from None + except httpx.HTTPError: + raise TwitterProviderError("Twitter token exchange request failed") from None + + try: response = await typing.cast(httpx.AsyncClient, client).get(CURRENT_USER_URL) response.raise_for_status() + except httpx.TimeoutException: + raise TwitterProviderError("Twitter current-user lookup timed out") from None + except httpx.HTTPStatusError as failure: + status_code = failure.response.status_code + if status_code == 402: + message = ( + "Twitter current-user lookup requires X API credits or project access (HTTP 402)" + ) + else: + message = f"Twitter current-user lookup failed (HTTP {status_code})" + raise TwitterProviderError(message) from None + except httpx.HTTPError: + raise TwitterProviderError("Twitter current-user lookup request failed") from None + + try: payload = response.json() if not isinstance(payload, dict) or not isinstance(payload.get("data"), dict): - raise TwitterProviderError("Twitter returned an invalid account response") + raise TwitterProviderError( + "Twitter current-user lookup returned an invalid response" + ) user = payload["data"] user_id = user.get("id") handle = user.get("username") if not isinstance(user_id, str) or not isinstance(handle, str): - raise TwitterProviderError("Twitter user response is incomplete") + raise TwitterProviderError( + "Twitter current-user lookup returned an incomplete response" + ) return dict(token), user_id, handle - except OAuthError: - raise TwitterProviderError("Twitter rejected the authorization exchange") from None - except httpx.TimeoutException: - raise TwitterProviderError("Twitter authorization timed out") from None - except httpx.HTTPError: - raise TwitterProviderError("Twitter authorization request failed") from None except (TypeError, ValueError): raise TwitterProviderError( - "Twitter returned an invalid authorization response" + "Twitter current-user lookup returned an invalid response" ) from None finally: await typing.cast(httpx.AsyncClient, client).aclose() From 59125b37a1baa04aa3f07ac55b6fe1cd2a184785 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 17 Aug 2026 17:21:25 +0800 Subject: [PATCH 03/15] docs(twitter): record setup wizard release - declare the 0.2.0 guided OAuth and Bookmark Source setup flow --- .changes/twitter/0.2.0.md | 3 +++ extensions/twitter/CHANGELOG.md | 4 ++++ 2 files changed, 7 insertions(+) create mode 100644 .changes/twitter/0.2.0.md diff --git a/.changes/twitter/0.2.0.md b/.changes/twitter/0.2.0.md new file mode 100644 index 0000000..7c311f1 --- /dev/null +++ b/.changes/twitter/0.2.0.md @@ -0,0 +1,3 @@ +## 0.2.0 - 2026-08-17 +### Added +* Added the guided OAuth and Bookmark Source setup wizard. diff --git a/extensions/twitter/CHANGELOG.md b/extensions/twitter/CHANGELOG.md index c1e6ac1..51ae2f2 100644 --- a/extensions/twitter/CHANGELOG.md +++ b/extensions/twitter/CHANGELOG.md @@ -4,6 +4,10 @@ This changelog records notable changes to this first-party Python Distribution association. It is generated by [Changie](https://changie.dev/). +## 0.2.0 - 2026-08-17 +### Added +* Added the guided OAuth and Bookmark Source setup wizard. + ## 0.1.1 - 2026-08-17 ### Changed From 31e7fd056622928ac1a5acdc6a5b0a95f5b3ecd0 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 17 Aug 2026 17:30:47 +0800 Subject: [PATCH 04/15] fix(preview): allow extension capabilities during convergence - require exact built-in Core advertisements - preserve independently published Extension capabilities --- scripts/configure_peer_runtime.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/configure_peer_runtime.py b/scripts/configure_peer_runtime.py index 1e5aa39..c256456 100644 --- a/scripts/configure_peer_runtime.py +++ b/scripts/configure_peer_runtime.py @@ -53,7 +53,14 @@ def snapshot_is_ready( lease_is_live: bool, expected: list[dict[str, typing.Any]], ) -> bool: - return lease_is_live and capabilities == expected + if not lease_is_live or not isinstance(capabilities, list): + return False + published = { + capability.get("id"): capability + for capability in capabilities + if isinstance(capability, dict) and isinstance(capability.get("id"), str) + } + return all(published.get(capability["id"]) == capability for capability in expected) def configure_peer_runtime( From ec28f067ee5b7eefa754bcef8a41c47f820ed03f Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 17 Aug 2026 20:03:43 +0800 Subject: [PATCH 05/15] ref(twitter): narrow setup to OAuth authority - replace the setup action bus with fixed semantic Peer capabilities - keep Source, Cron, and Job composition outside the Core Distribution - publish the immutable Python Distribution as 0.2.1 --- .changes/twitter/0.2.1.md | 5 + extensions/twitter/CHANGELOG.md | 5 + extensions/twitter/__init__.py | 4 +- extensions/twitter/bookmark.py | 5 +- extensions/twitter/pyproject.toml | 2 +- extensions/twitter/setup_flow.py | 382 +++--------------- .../test_extension_peer_enabled_rpc.py | 18 +- 7 files changed, 69 insertions(+), 352 deletions(-) create mode 100644 .changes/twitter/0.2.1.md diff --git a/.changes/twitter/0.2.1.md b/.changes/twitter/0.2.1.md new file mode 100644 index 0000000..1590a72 --- /dev/null +++ b/.changes/twitter/0.2.1.md @@ -0,0 +1,5 @@ +## 0.2.1 - 2026-08-17 + +### Changed + +* Limited the Core setup protocol to OAuth and account operations; Bookmark Source scheduling now uses ordinary deployment resources. diff --git a/extensions/twitter/CHANGELOG.md b/extensions/twitter/CHANGELOG.md index 51ae2f2..a9773f8 100644 --- a/extensions/twitter/CHANGELOG.md +++ b/extensions/twitter/CHANGELOG.md @@ -3,6 +3,11 @@ This changelog records notable changes to this first-party Python Distribution association. It is generated by [Changie](https://changie.dev/). +## 0.2.1 - 2026-08-17 + +### Changed + +* Limited the Core setup protocol to OAuth and account operations; Bookmark Source scheduling now uses ordinary deployment resources. ## 0.2.0 - 2026-08-17 ### Added diff --git a/extensions/twitter/__init__.py b/extensions/twitter/__init__.py index ebd73c3..c074b6d 100644 --- a/extensions/twitter/__init__.py +++ b/extensions/twitter/__init__.py @@ -75,9 +75,9 @@ def api_dependencies(cls) -> list[typing.Any]: @classmethod def peer_inbounds(cls) -> tuple[typing.Any, ...]: - from .setup_flow import TWITTER_SETUP_INBOUND + from .setup_flow import TWITTER_SETUP_INBOUNDS - return (TWITTER_SETUP_INBOUND,) + return TWITTER_SETUP_INBOUNDS @classmethod def public_http_routes(cls) -> tuple[PublicHTTPRoute, ...]: diff --git a/extensions/twitter/bookmark.py b/extensions/twitter/bookmark.py index edf5133..0891891 100644 --- a/extensions/twitter/bookmark.py +++ b/extensions/twitter/bookmark.py @@ -25,7 +25,6 @@ class CollectConfig(pydantic.BaseModel): full: bool = False result_limit: int = pydantic.Field(default=40, ge=5, le=100) - authorization_id: str = pydantic.Field(min_length=1) def _video_url(video) -> str | None: @@ -104,9 +103,7 @@ async def collect(self, job: JobModel, config: pydantic.BaseModel) -> None: page = job.state.get("page") if job.state else None - api_client = TwitterAPI.new( - expected_authorization_id=collect_config.authorization_id, - ) + api_client = TwitterAPI.new() bookmarks_res = await api_client.get_bookmarks(page=page, max_results=result_limit) # find new tweets start point diff --git a/extensions/twitter/pyproject.toml b/extensions/twitter/pyproject.toml index 3e72116..8c43f93 100644 --- a/extensions/twitter/pyproject.toml +++ b/extensions/twitter/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "inkcre-ext-twitter" -version = "0.2.0" +version = "0.2.1" description = "Twitter extension for InKCre" authors = [{ name = "Lan_zhijiang", email = "lanzhijiang@foxmail.com" }] dependencies = [ diff --git a/extensions/twitter/setup_flow.py b/extensions/twitter/setup_flow.py index da901c1..0e0b053 100644 --- a/extensions/twitter/setup_flow.py +++ b/extensions/twitter/setup_flow.py @@ -19,27 +19,33 @@ from fastapi.responses import HTMLResponse import httpx import pydantic -import sqlmodel - -from app.business.cron import CronManager -from app.business.job import JobManager from app.business.peer import PeerHTTPInbound, PeerManager -from app.business.source import SOURCE_COLLECT_JOB_TYPE, SourceManager -from app.engine import SessionLocal from app.middleware import require_peer_jwt -from app.schemas.cron import CronForm, CronModel -from app.schemas.source import SourceModel if typing.TYPE_CHECKING: from . import TwitterExtensionConfig -TWITTER_SETUP_CAPABILITY = "inkcre.twitter.setup.v1" -TWITTER_SETUP_INBOUND = PeerHTTPInbound( - capability=TWITTER_SETUP_CAPABILITY, - method="POST", - path="/twitter/setup", +TWITTER_SETUP_STATUS_CAPABILITY = "inkcre.twitter.setup.status.v1" +TWITTER_OAUTH_APP_CONFIGURE_CAPABILITY = "inkcre.twitter.oauth-app.configure.v1" +TWITTER_OAUTH_BEGIN_CAPABILITY = "inkcre.twitter.oauth.begin.v1" +TWITTER_OAUTH_TRANSACTION_READ_CAPABILITY = "inkcre.twitter.oauth.transaction.read.v1" +TWITTER_OAUTH_DISCONNECT_CAPABILITY = "inkcre.twitter.oauth.disconnect.v1" +TWITTER_SETUP_INBOUNDS = ( + PeerHTTPInbound(TWITTER_SETUP_STATUS_CAPABILITY, "GET", "/twitter/setup"), + PeerHTTPInbound( + TWITTER_OAUTH_APP_CONFIGURE_CAPABILITY, "PUT", "/twitter/setup/oauth-app" + ), + PeerHTTPInbound( + TWITTER_OAUTH_BEGIN_CAPABILITY, "POST", "/twitter/setup/oauth-transactions" + ), + PeerHTTPInbound( + TWITTER_OAUTH_TRANSACTION_READ_CAPABILITY, + "POST", + "/twitter/setup/oauth-transaction", + ), + PeerHTTPInbound(TWITTER_OAUTH_DISCONNECT_CAPABILITY, "DELETE", "/twitter/setup/account"), ) AUTHORIZE_URL = "https://x.com/i/oauth2/authorize" TOKEN_URL = "https://api.x.com/2/oauth2/token" # noqa: S105 @@ -48,7 +54,6 @@ TRANSACTION_LIFETIME = datetime.timedelta(minutes=10) TERMINAL_RETENTION = datetime.timedelta(minutes=10) MAX_TRANSACTIONS = 8 -BOOKMARK_SOURCE_TYPE = "extensions.twitter.bookmark.Source" def _now() -> datetime.datetime: @@ -81,39 +86,6 @@ class OAuthTransaction(pydantic.BaseModel): class TwitterExtensionState(pydantic.BaseModel): account: TwitterAccount | None = None oauth_transactions: dict[str, OAuthTransaction] = pydantic.Field(default_factory=dict) - bookmark_source_id: int | None = None - bookmark_cron_id: int | None = None - - -class SetupCollectAt(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid", frozen=True) - - day_of_week: int | None = pydantic.Field(default=None, ge=0, le=6) - hour: int = pydantic.Field(default=0, ge=0, le=23) - minute: int = pydantic.Field(default=0, ge=0, le=59) - - def cron_schedule(self) -> str: - day = "*" if self.day_of_week is None else str(self.day_of_week) - return f"{self.minute} {self.hour} * * {day}" - - @classmethod - def from_cron_schedule(cls, schedule: str) -> SetupCollectAt | None: - parts = schedule.split() - if len(parts) != 5 or parts[2:4] != ["*", "*"]: - return None - try: - return cls( - minute=int(parts[0]), - hour=int(parts[1]), - day_of_week=None if parts[4] == "*" else int(parts[4]), - ) - except (ValueError, pydantic.ValidationError): - return None - - -class BookmarkSourceView(pydantic.BaseModel): - source_id: int - nickname: str class OAuthTransactionView(pydantic.BaseModel): @@ -134,62 +106,18 @@ class TwitterSetupStatus(pydantic.BaseModel): handle: str | None = None scopes: tuple[str, ...] = () reconnect_required: bool = False - bookmark_source_id: int | None = None - bookmark_cron_id: int | None = None - bookmark_sources: tuple[BookmarkSourceView, ...] = () - collect_at: SetupCollectAt = pydantic.Field(default_factory=SetupCollectAt) - bookmark_source_ready: bool = False - ready: bool = False - -class GetStatusCommand(pydantic.BaseModel): - action: typing.Literal["get_status"] - -class SaveOAuthAppCommand(pydantic.BaseModel): - action: typing.Literal["save_oauth_app"] +class SaveOAuthAppRequest(pydantic.BaseModel): client_id: str = pydantic.Field(min_length=1, max_length=256) client_secret: str = pydantic.Field(min_length=1, max_length=1024) confirm_account_reset: bool = False -class BeginOAuthCommand(pydantic.BaseModel): - action: typing.Literal["begin_oauth"] - - -class GetOAuthTransactionCommand(pydantic.BaseModel): - action: typing.Literal["get_oauth_transaction"] +class OAuthTransactionRequest(pydantic.BaseModel): transaction_id: str -class DisconnectAccountCommand(pydantic.BaseModel): - action: typing.Literal["disconnect_account"] - - -class ConfigureBookmarkSourceCommand(pydantic.BaseModel): - action: typing.Literal["configure_bookmark_source"] - source_id: int | None = None - nickname: str = pydantic.Field(default="Twitter Bookmarks", min_length=1, max_length=120) - collect_at: SetupCollectAt = pydantic.Field(default_factory=SetupCollectAt) - - -class FinishSetupCommand(pydantic.BaseModel): - action: typing.Literal["finish"] - - -TwitterSetupCommand: typing.TypeAlias = typing.Annotated[ - GetStatusCommand - | SaveOAuthAppCommand - | BeginOAuthCommand - | GetOAuthTransactionCommand - | DisconnectAccountCommand - | ConfigureBookmarkSourceCommand - | FinishSetupCommand, - pydantic.Field(discriminator="action"), -] -TwitterSetupResult: typing.TypeAlias = TwitterSetupStatus | OAuthTransactionView - - class TwitterSetupError(RuntimeError): ... @@ -297,26 +225,6 @@ def _invalidate_mismatched_oauth_state( return state, changed -def _disable_bookmark_schedule(state: TwitterExtensionState) -> None: - """Stop setup-owned collection while retaining its reusable Source and Cron.""" - if state.bookmark_cron_id is None: - return - with SessionLocal() as db: - cron = db.get(CronModel, state.bookmark_cron_id) - if cron is None or not cron.enabled: - return - CronManager.update( - state.bookmark_cron_id, - CronForm( - schedule=cron.schedule, - enabled=False, - job_type=cron.job_type, - job_parameters=dict(cron.job_parameters), - job_timeout_seconds=cron.job_timeout_seconds, - ), - ) - - def _reconcile_oauth_state() -> TwitterExtensionState: """Reconcile direct deployment config writes before setup becomes reachable.""" config = _config() @@ -328,7 +236,6 @@ def _reconcile_oauth_state() -> TwitterExtensionState: ) if not changed: return state - _disable_bookmark_schedule(state) def reconcile(config_model, state_model): from . import TwitterExtensionConfig @@ -346,72 +253,9 @@ def reconcile(config_model, state_model): return TwitterExtensionState.model_validate(reconciled) -def _bookmark_source_status( - state: TwitterExtensionState, -) -> tuple[ - int | None, - int | None, - tuple[BookmarkSourceView, ...], - SetupCollectAt, - bool, -]: - with SessionLocal() as db: - sources = db.exec( - sqlmodel.select(SourceModel) - .where(SourceModel.type == BOOKMARK_SOURCE_TYPE) - .order_by(sqlmodel.col(SourceModel.id)) - ).all() - views = tuple( - BookmarkSourceView( - source_id=source.id, - nickname=source.nickname or "Twitter Bookmarks", - ) - for source in sources - if source.id is not None - ) - source = next( - (item for item in sources if item.id == state.bookmark_source_id), - None, - ) - cron = ( - None if state.bookmark_cron_id is None else db.get(CronModel, state.bookmark_cron_id) - ) - - collect_at = ( - SetupCollectAt.from_cron_schedule(cron.schedule) if cron is not None else None - ) or SetupCollectAt() - account = state.account - expected_parameters = ( - None - if source is None or account is None - else { - "source": source.id, - "config": { - "full": False, - "result_limit": 40, - "authorization_id": account.authorization_id, - }, - } - ) - ready = ( - cron is not None - and cron.enabled - and cron.job_type == SOURCE_COLLECT_JOB_TYPE - and cron.job_parameters == expected_parameters - ) - return ( - source.id if source is not None else None, - cron.id if cron is not None else None, - views, - collect_at, - ready, - ) - - def get_setup_status() -> TwitterSetupStatus: config = _config() state = _reconcile_oauth_state() - source_id, cron_id, sources, collect_at, source_ready = _bookmark_source_status(state) configured = bool(config.client_id and config.client_secret) account = state.account connected = ( @@ -430,16 +274,10 @@ def get_setup_status() -> TwitterSetupStatus: handle=account.handle if connected and account is not None else None, scopes=account.scopes if connected and account is not None else (), reconnect_required=bool(account and account.reconnect_required), - bookmark_source_id=source_id, - bookmark_cron_id=cron_id, - bookmark_sources=sources, - collect_at=collect_at, - bookmark_source_ready=source_ready, - ready=connected and source_ready, ) -def save_oauth_app(body: SaveOAuthAppCommand) -> TwitterSetupStatus: +def save_oauth_app(body: SaveOAuthAppRequest) -> TwitterSetupStatus: current_config = _config() current_state = _state() next_config = current_config.model_copy( @@ -458,8 +296,6 @@ def save_oauth_app(body: SaveOAuthAppCommand) -> TwitterSetupStatus: raise TwitterSetupConflict( "Replacing the OAuth App requires confirmation because it disconnects the account" ) - if fingerprint_changed: - _disable_bookmark_schedule(current_state) def update(config_model, state_model): from . import TwitterExtensionConfig @@ -769,8 +605,6 @@ async def oauth_callback( def disconnect_account() -> TwitterSetupStatus: - _disable_bookmark_schedule(_state()) - def disconnect(model: pydantic.BaseModel) -> pydantic.BaseModel: state = TwitterExtensionState.model_validate(model) state.account = None @@ -786,144 +620,6 @@ def disconnect(model: pydantic.BaseModel) -> pydantic.BaseModel: return get_setup_status() -def _bookmark_job_parameters( - source_id: int, authorization_id: str -) -> dict[str, typing.Any]: - return { - "source": source_id, - "config": { - "full": False, - "result_limit": 40, - "authorization_id": authorization_id, - }, - } - - -def configure_bookmark_source(body: ConfigureBookmarkSourceCommand) -> TwitterSetupStatus: - state = _state() - account = state.account - if account is None or account.reconnect_required: - raise TwitterSetupConflict("Connect a Twitter account first") - if body.source_id is not None: - with SessionLocal() as db: - source = db.get(SourceModel, body.source_id) - if source is None or source.type != BOOKMARK_SOURCE_TYPE: - raise TwitterSetupConflict("Bookmark Source does not exist") - else: - source = SourceManager.create( - BOOKMARK_SOURCE_TYPE, - nickname=body.nickname, - ) - source_id = source.id - if source_id is None: - raise TwitterSetupError("Bookmark Source has no identifier") - try: - form = CronForm( - schedule=body.collect_at.cron_schedule(), - enabled=False, - job_type=SOURCE_COLLECT_JOB_TYPE, - job_parameters=_bookmark_job_parameters(source_id, account.authorization_id), - ) - cron = ( - CronManager.create(form) - if state.bookmark_cron_id is None - else CronManager.update(state.bookmark_cron_id, form) - ) - except ValueError as error: - raise TwitterSetupConflict(str(error)) from error - if cron.id is None: - raise TwitterSetupError("Bookmark schedule has no identifier") - - def select(model: pydantic.BaseModel) -> pydantic.BaseModel: - selected = TwitterExtensionState.model_validate(model) - if ( - selected.account is None - or selected.account.authorization_id != account.authorization_id - ): - raise TwitterSetupConflict("Twitter account changed during setup") - selected.bookmark_source_id = source_id - selected.bookmark_cron_id = cron.id - return selected - - _extension().mutate_state(select) - return get_setup_status() - - -async def finish_setup() -> TwitterSetupStatus: - from .api import OfficialAPI - - state = _state() - account = state.account - if account is None or account.reconnect_required: - raise TwitterSetupConflict("Connect a Twitter account first") - if state.bookmark_source_id is None or state.bookmark_cron_id is None: - raise TwitterSetupConflict("Configure a Bookmark Source first") - api = OfficialAPI.from_extension(expected_authorization_id=account.authorization_id) - try: - user_id, handle = await api.get_user() - finally: - await api.close() - - with SessionLocal() as db: - source = db.get(SourceModel, state.bookmark_source_id) - cron = db.get(CronModel, state.bookmark_cron_id) - if source is None or source.type != BOOKMARK_SOURCE_TYPE or cron is None: - raise TwitterSetupConflict("Bookmark collection resources no longer exist") - source_id = source.id - if source_id is None: - raise TwitterSetupConflict("Bookmark Source has no identifier") - schedule = cron.schedule - rebound = CronManager.update( - state.bookmark_cron_id, - CronForm( - schedule=schedule, - enabled=True, - job_type=SOURCE_COLLECT_JOB_TYPE, - job_parameters=_bookmark_job_parameters(source_id, account.authorization_id), - job_timeout_seconds=cron.job_timeout_seconds, - ), - ) - - def update_identity(model: pydantic.BaseModel) -> pydantic.BaseModel: - current = TwitterExtensionState.model_validate(model) - if ( - current.account is None - or current.account.authorization_id != account.authorization_id - or current.bookmark_source_id != source_id - or current.bookmark_cron_id != rebound.id - ): - raise TwitterSetupConflict("Twitter setup changed during Finish") - current.account.user_id = user_id - current.account.handle = handle - return current - - _extension().mutate_state(update_identity) - if rebound.id is None: - raise TwitterSetupError("Bookmark schedule has no identifier") - job = CronManager.run_now(rebound.id) - if job.id is not None: - await JobManager.check() - return get_setup_status() - - -async def execute_setup_command(command: TwitterSetupCommand) -> TwitterSetupResult: - if isinstance(command, GetStatusCommand): - return get_setup_status() - if isinstance(command, SaveOAuthAppCommand): - return save_oauth_app(command) - if isinstance(command, BeginOAuthCommand): - return begin_oauth() - if isinstance(command, GetOAuthTransactionCommand): - return get_oauth_transaction(command.transaction_id) - if isinstance(command, DisconnectAccountCommand): - return disconnect_account() - if isinstance(command, ConfigureBookmarkSourceCommand): - return configure_bookmark_source(command) - if isinstance(command, FinishSetupCommand): - return await finish_setup() - typing.assert_never(command) - - def _http_error(error: TwitterSetupError) -> typing.NoReturn: if str(error) == "OAuth transaction not found": status = fastapi.status.HTTP_404_NOT_FOUND @@ -937,10 +633,38 @@ def _http_error(error: TwitterSetupError) -> typing.NoReturn: def register_setup_routes(router: fastapi.APIRouter) -> None: protected = fastapi.APIRouter(dependencies=[fastapi.Depends(require_peer_jwt)]) - @protected.post("/setup", response_model=TwitterSetupResult) - async def setup_command(command: TwitterSetupCommand): + @protected.get("/setup", response_model=TwitterSetupStatus) + async def setup_status(): + try: + return get_setup_status() + except TwitterSetupError as failure: + _http_error(failure) + + @protected.put("/setup/oauth-app", response_model=TwitterSetupStatus) + async def configure_oauth_app(body: SaveOAuthAppRequest): + try: + return save_oauth_app(body) + except TwitterSetupError as failure: + _http_error(failure) + + @protected.post("/setup/oauth-transactions", response_model=OAuthTransactionView) + async def create_oauth_transaction(): + try: + return begin_oauth() + except TwitterSetupError as failure: + _http_error(failure) + + @protected.post("/setup/oauth-transaction", response_model=OAuthTransactionView) + async def read_oauth_transaction(body: OAuthTransactionRequest): + try: + return get_oauth_transaction(body.transaction_id) + except TwitterSetupError as failure: + _http_error(failure) + + @protected.delete("/setup/account", response_model=TwitterSetupStatus) + async def delete_account(): try: - return await execute_setup_command(command) + return disconnect_account() except TwitterSetupError as failure: _http_error(failure) diff --git a/tests/migrations/test_extension_peer_enabled_rpc.py b/tests/migrations/test_extension_peer_enabled_rpc.py index 121d407..ed2f66c 100644 --- a/tests/migrations/test_extension_peer_enabled_rpc.py +++ b/tests/migrations/test_extension_peer_enabled_rpc.py @@ -440,7 +440,6 @@ def make_session() -> sqlmodel.Session: "config": { "full": False, "result_limit": 40, - "authorization_id": "authorization-1", }, }, ) @@ -450,21 +449,8 @@ def make_session() -> sqlmodel.Session: repeated_job = CronManager.run_now(cron.id) assert repeated_job.id != first_job.id - rebound = CronManager.update( - cron.id, - form.model_copy( - update={ - "job_parameters": { - **form.job_parameters, - "config": { - **form.job_parameters["config"], - "authorization_id": "authorization-2", - }, - } - } - ), - ) - assert rebound.job_parameters["config"]["authorization_id"] == "authorization-2" + rebound = CronManager.update(cron.id, form.model_copy(update={"enabled": False})) + assert rebound.enabled is False def test_core_runtime_owns_state_writes_without_exposing_them_to_browser_peers( From edd0200bda63833823f9a08ace1cca948225c50e Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 17 Aug 2026 20:08:41 +0800 Subject: [PATCH 06/15] fix(twitter): align generated changelog - preserve the exact Changie formatting required by release-intent checks --- .changes/twitter/0.2.1.md | 2 -- extensions/twitter/CHANGELOG.md | 2 -- 2 files changed, 4 deletions(-) diff --git a/.changes/twitter/0.2.1.md b/.changes/twitter/0.2.1.md index 1590a72..409b80d 100644 --- a/.changes/twitter/0.2.1.md +++ b/.changes/twitter/0.2.1.md @@ -1,5 +1,3 @@ ## 0.2.1 - 2026-08-17 - ### Changed - * Limited the Core setup protocol to OAuth and account operations; Bookmark Source scheduling now uses ordinary deployment resources. diff --git a/extensions/twitter/CHANGELOG.md b/extensions/twitter/CHANGELOG.md index a9773f8..ee69b84 100644 --- a/extensions/twitter/CHANGELOG.md +++ b/extensions/twitter/CHANGELOG.md @@ -4,9 +4,7 @@ This changelog records notable changes to this first-party Python Distribution association. It is generated by [Changie](https://changie.dev/). ## 0.2.1 - 2026-08-17 - ### Changed - * Limited the Core setup protocol to OAuth and account operations; Bookmark Source scheduling now uses ordinary deployment resources. ## 0.2.0 - 2026-08-17 From a1e73dd95953de0895422e9a984530e57baa3db2 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 17 Aug 2026 20:12:13 +0800 Subject: [PATCH 07/15] fix(twitter): match generated changelog - preserve the exact Changie merge spacing required by CI --- extensions/twitter/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/extensions/twitter/CHANGELOG.md b/extensions/twitter/CHANGELOG.md index ee69b84..e74abba 100644 --- a/extensions/twitter/CHANGELOG.md +++ b/extensions/twitter/CHANGELOG.md @@ -3,6 +3,7 @@ This changelog records notable changes to this first-party Python Distribution association. It is generated by [Changie](https://changie.dev/). + ## 0.2.1 - 2026-08-17 ### Changed * Limited the Core setup protocol to OAuth and account operations; Bookmark Source scheduling now uses ordinary deployment resources. From 6ee4ba807a29156755f4f743f9a47185f9ee4259 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 24 Aug 2026 10:57:43 +0800 Subject: [PATCH 08/15] ref(extension): consume released core runtime - compose Core Host through the ext-reg Runtime wheel facades - attach setup migration to the current production head - preserve Twitter OAuth setup across the consumer cutover --- app/business/extension/distribution.py | 511 +----------------- app/business/extension/errors.py | 56 +- app/business/extension/main.py | 324 ++--------- app/business/extension/release.py | 168 +----- app/business/extension/runtime.py | 273 +--------- deploy/profiles/production.json | 4 +- extensions/twitter/__init__.py | 6 +- migrations/revision-integrity.json | 2 +- .../c6d7e8f9a0b1_add_extension_setup_state.py | 4 +- pdm.lock | 199 ++++--- pyproject.toml | 1 + tests/extensions/runtime_support.py | 65 +-- 12 files changed, 312 insertions(+), 1301 deletions(-) diff --git a/app/business/extension/distribution.py b/app/business/extension/distribution.py index ca954f2..de3f128 100644 --- a/app/business/extension/distribution.py +++ b/app/business/extension/distribution.py @@ -1,38 +1,16 @@ -"""Native pip acquisition into Core's interpreter and entry-point discovery.""" +"""Core facade for Distribution acquisition owned by the Extension Runtime.""" -from __future__ import annotations - -from collections.abc import Callable -from email.parser import Parser -import importlib -import importlib.metadata -import json -import os -from pathlib import Path, PurePosixPath -import stat -import subprocess -import sys -import sysconfig -import tempfile import typing -import zipfile - -from packaging.utils import canonicalize_name, canonicalize_version -from packaging.version import InvalidVersion, Version -from .errors import ( - ExtensionAcquisitionError, - ExtensionEntryPointError, - ExtensionRestartRequiredError, -) -from .release import ( - ExtensionReleaseDescriptor, - PythonReleaseDescriptor, - simple_project_and_index_urls, +from inkcre_extension_runtime_core_py.distribution import ( + AcquiredDistribution, + PipDistributionConsumer as RuntimePipDistributionConsumer, ) +from inkcre_extension_runtime_core_py.modules import DistributionModules +from app.version import CORE_VERSION -CommandRunner = Callable[[list[str]], subprocess.CompletedProcess[str]] +from .release import ExtensionReleaseDescriptor, PythonReleaseDescriptor class DistributionConsumer(typing.Protocol): @@ -43,478 +21,21 @@ def acquire( ) -> AcquiredDistribution: ... -def _run_pip(arguments: list[str]) -> subprocess.CompletedProcess[str]: - environment = os.environ.copy() - environment.update( - { - "PIP_CONFIG_FILE": os.devnull, - "PIP_DISABLE_PIP_VERSION_CHECK": "1", - "PIP_NO_INPUT": "1", - "PYTHONNOUSERSITE": "1", - } - ) - return subprocess.run( # noqa: S603 -- fixed interpreter and Host-owned arguments - [sys.executable, "-m", "pip", *arguments], - check=False, - capture_output=True, - env=environment, - text=True, - ) - - -def _require_success(result: subprocess.CompletedProcess[str], operation: str) -> None: - if result.returncode == 0: - return - detail = (result.stderr or result.stdout).strip().splitlines() - suffix = f": {detail[-1]}" if detail else "" - raise ExtensionAcquisitionError(f"pip {operation} failed{suffix}") - - -def _extension_package_name(entry_point_name: str, entry_point_object: str) -> str: - module_name = entry_point_object.partition(":")[0] - module_parts = module_name.split(".") - if ( - len(module_parts) < 2 - or module_parts[0] != "extensions" - or module_parts[1] != entry_point_name - ): - raise ExtensionEntryPointError( - "Core Extension entry point must live in its declared extensions. package" - ) - return ".".join(module_parts[:2]) - - -def _canonical_archive_path(name: str) -> PurePosixPath: - if not name or "\\" in name: - raise ExtensionAcquisitionError("Extension wheel contains a non-canonical path") - path = PurePosixPath(name) - if ( - path.is_absolute() - or any(part in {"", ".", ".."} for part in path.parts) - or path.as_posix() != name.rstrip("/") - ): - raise ExtensionAcquisitionError("Extension wheel contains a non-canonical path") - return path - - -def _installed_file_owners(excluded_project: str) -> dict[Path, str]: - owners: dict[Path, str] = {} - for distribution in importlib.metadata.distributions(): - distribution_name = distribution.metadata["Name"] or "" - if canonicalize_name(distribution_name) == excluded_project: - continue - for file in distribution.files or (): - owners[Path(str(distribution.locate_file(file))).resolve()] = distribution_name - return owners - - -def _validate_extension_wheel( - wheel: Path, - release: ExtensionReleaseDescriptor, - association: PythonReleaseDescriptor, -) -> None: - """Reject a wheel that can write outside its one namespace contribution.""" - package_name = _extension_package_name( - association.entry_point.name, - association.entry_point.object, - ) - package_parts = tuple(package_name.split(".")) - project = canonicalize_name(association.project) - try: - with zipfile.ZipFile(wheel) as archive: - entries = archive.infolist() - paths = [_canonical_archive_path(entry.filename) for entry in entries] - if any(stat.S_ISLNK(entry.external_attr >> 16) for entry in entries): - raise ExtensionAcquisitionError("Extension wheel contains a symbolic link") - folded = [path.as_posix().casefold() for path in paths] - if len(folded) != len(set(folded)): - raise ExtensionAcquisitionError("Extension wheel contains colliding archive paths") - metadata_paths = [ - path - for path, entry in zip(paths, entries, strict=True) - if not entry.is_dir() - and len(path.parts) == 2 - and path.parts[0].endswith(".dist-info") - and path.parts[1] == "METADATA" - ] - if len(metadata_paths) != 1: - raise ExtensionAcquisitionError( - "Extension wheel does not contain exactly one Core Metadata record" - ) - dist_info = metadata_paths[0].parts[0] - expected_dist_info = ( - f"{project.replace('-', '_')}-" - f"{canonicalize_version(release.version, strip_trailing_zero=False)}.dist-info" - ) - dist_info_directories = { - path.parts[0] for path in paths if path.parts[0].endswith(".dist-info") - } - if dist_info != expected_dist_info or dist_info_directories != {dist_info}: - raise ExtensionAcquisitionError( - "Extension wheel dist-info identity differs from its Project and Release" - ) - metadata = Parser().parsestr( - archive.read(metadata_paths[0].as_posix()).decode("utf-8") - ) - wheel_paths = [ - path - for path, entry in zip(paths, entries, strict=True) - if not entry.is_dir() - and len(path.parts) == 2 - and path.parts[0] == dist_info - and path.parts[1] == "WHEEL" - ] - if len(wheel_paths) != 1: - raise ExtensionAcquisitionError( - "Extension wheel does not contain exactly one wheel metadata record" - ) - wheel_metadata = Parser().parsestr( - archive.read(wheel_paths[0].as_posix()).decode("utf-8") - ) - if wheel_metadata.get("Root-Is-Purelib", "").lower() != "true": - raise ExtensionAcquisitionError( - "Extension wheel must install entirely into Core purelib" - ) - if canonicalize_name(metadata.get("Name", "")) != project: - raise ExtensionAcquisitionError( - "Extension wheel Project differs from the Registry association" - ) - try: - version_matches = Version(metadata.get("Version", "")) == Version(release.version) - except InvalidVersion as error: - raise ExtensionAcquisitionError( - "Extension wheel contains an invalid Project version" - ) from error - if not version_matches: - raise ExtensionAcquisitionError( - "Extension wheel version differs from the Registry Release" - ) - - files: list[PurePosixPath] = [] - for path, entry in zip(paths, entries, strict=True): - if entry.is_dir(): - continue - if path.suffix.casefold() == ".pth" or path.parts[0].casefold().endswith(".data"): - raise ExtensionAcquisitionError( - "Extension wheel contains an executable or redirected install path" - ) - in_package = path.parts[:2] == package_parts and len(path.parts) >= 3 - in_dist_info = path.parts[0] == dist_info and len(path.parts) >= 2 - if not in_package and not in_dist_info: - raise ExtensionAcquisitionError( - "Extension wheel writes outside its declared package and dist-info" - ) - files.append(path) - except (OSError, UnicodeError, zipfile.BadZipFile) as error: - raise ExtensionAcquisitionError("Extension wheel archive is invalid") from error - - purelib = Path(sysconfig.get_path("purelib")).resolve() - installed_owners = _installed_file_owners(project) - for relative in files: - target = (purelib / relative.as_posix()).resolve() - if not target.is_relative_to(purelib): - raise ExtensionAcquisitionError("Extension wheel escapes Core site-packages") - owner = installed_owners.get(target) - if owner is not None: - raise ExtensionAcquisitionError( - f"Extension wheel would overwrite a file owned by Distribution {owner}" - ) - - -class AcquiredDistribution: - """One exact Project installed in the Core interpreter's site-packages.""" - - def __init__( - self, - distribution: importlib.metadata.Distribution, - release: ExtensionReleaseDescriptor, - association: PythonReleaseDescriptor, - ) -> None: - self.distribution = distribution - self.release = release - self.association = association - self.entry_point = self._find_entry_point() - files = distribution.files - if files is None: - raise ExtensionEntryPointError("Installed Project does not expose a file record") - self.owned_files = { - Path(str(distribution.locate_file(file))).resolve() - for file in files - if not str(file).endswith("/") - } - - @classmethod - def discover( - cls, - release: ExtensionReleaseDescriptor, - association: PythonReleaseDescriptor, - ) -> AcquiredDistribution: - expected = canonicalize_name(association.project) - matches = [ - distribution - for distribution in importlib.metadata.distributions() - if canonicalize_name(distribution.metadata["Name"] or "") == expected - ] - if len(matches) != 1: - raise ExtensionEntryPointError( - "Core interpreter does not contain exactly one declared Python Project" - ) - distribution = matches[0] - try: - congruent = Version(distribution.version) == Version(release.version) - except InvalidVersion as error: - raise ExtensionEntryPointError("Installed Project version is invalid") from error - if not congruent: - raise ExtensionEntryPointError( - "Installed Project version differs from the Extension Release" - ) - return cls(distribution, release, association) - - def _find_entry_point(self) -> importlib.metadata.EntryPoint: - declared = self.association.entry_point - matches = [ - entry_point - for entry_point in self.distribution.entry_points - if entry_point.group == declared.group and entry_point.name == declared.name - ] - if len(matches) != 1 or matches[0].value != declared.object: - raise ExtensionEntryPointError( - "Installed Project entry point differs from the Registry association" - ) - return matches[0] - - -class DistributionModules: - """Load and later release one installed Extension package subtree.""" - - def __init__(self, acquired: AcquiredDistribution) -> None: - self.acquired = acquired - self.package_name = _extension_package_name( - acquired.entry_point.name, - acquired.entry_point.value, - ) - self._previous_modules: dict[str, typing.Any] = {} - self._active = False - - def _module_names(self) -> tuple[str, ...]: - prefix = f"{self.package_name}." - return tuple( - name for name in sys.modules if name == self.package_name or name.startswith(prefix) - ) - - def _is_distribution_file(self, module: typing.Any) -> bool: - origins = ( - getattr(module, "__file__", None), - getattr(getattr(module, "__spec__", None), "origin", None), - ) - concrete = [Path(origin).resolve() for origin in origins if isinstance(origin, str)] - return bool(concrete) and all( - origin in self.acquired.owned_files for origin in concrete - ) - - def assert_origins(self) -> None: - names = self._module_names() - if self.package_name not in names: - raise ExtensionEntryPointError("Extension entry-point package was not loaded") - invalid = [name for name in names if not self._is_distribution_file(sys.modules[name])] - if invalid: - raise ExtensionEntryPointError( - "Extension package did not originate from its installed wheel: " - + ", ".join(sorted(invalid)) - ) - - def load(self, extension_base: type[typing.Any]) -> type[typing.Any]: - if self._active: - raise ExtensionEntryPointError("Extension Distribution is already loaded") - self._previous_modules = {name: sys.modules[name] for name in self._module_names()} - for name in self._previous_modules: - sys.modules.pop(name, None) - importlib.invalidate_caches() - try: - extension_class = self.acquired.entry_point.load() - self._active = True - self.assert_origins() - if not isinstance(extension_class, type) or not issubclass( - extension_class, extension_base - ): - raise ExtensionEntryPointError( - "Core Extension entry point does not yield an ExtensionBase subclass" - ) - return extension_class - except Exception: - self.abort() - raise - - def abort(self) -> None: - for name in self._module_names(): - sys.modules.pop(name, None) - sys.modules.update(self._previous_modules) - importlib.invalidate_caches() - self._active = False - - def unload(self) -> None: - if not self._active: - return - self.assert_origins() - self.abort() - - class PipDistributionConsumer: - """Select a Registry wheel, preflight pip, then install into Core itself.""" - - def __init__( - self, - registry_origin: str, - runner: CommandRunner = _run_pip, - ) -> None: - self.registry_origin = registry_origin - self._runner = runner - self._restart_required_reason: str | None = None - - @staticmethod - def _installed_versions() -> dict[str, str]: - return { - canonicalize_name(distribution.metadata["Name"] or ""): distribution.version - for distribution in importlib.metadata.distributions() - if distribution.metadata["Name"] - } - - @staticmethod - def _report_requirements(report: dict[str, typing.Any]) -> list[dict[str, typing.Any]]: - installs = report.get("install") - if not isinstance(installs, list): - raise ExtensionAcquisitionError("pip produced an invalid install plan") - if any(not isinstance(item, dict) for item in installs): - raise ExtensionAcquisitionError("pip install plan has an invalid shape") - return installs - - def _reject_replacements( - self, - installs: list[dict[str, typing.Any]], - extension_project: str, - ) -> None: - installed_versions = self._installed_versions() - for item in installs: - metadata = item.get("metadata") - if not isinstance(metadata, dict): - raise ExtensionAcquisitionError("pip install plan omits Core Metadata") - name = metadata.get("name") - version = metadata.get("version") - if not isinstance(name, str) or not isinstance(version, str): - raise ExtensionAcquisitionError("pip install plan has invalid Core Metadata") - installed = installed_versions.get(canonicalize_name(name)) - if ( - installed is not None - and Version(installed) != Version(version) - and canonicalize_name(name) != extension_project - ): - raise ExtensionAcquisitionError( - f"pip plan would replace loaded Distribution {name} {installed} with {version}" - ) + def __init__(self, origin: str) -> None: + self._runtime = RuntimePipDistributionConsumer(origin) def acquire( self, release: ExtensionReleaseDescriptor, association: PythonReleaseDescriptor, ) -> AcquiredDistribution: - project = canonicalize_name(association.project) - if self._restart_required_reason is not None: - raise ExtensionRestartRequiredError(self._restart_required_reason) - _extension_package_name( - association.entry_point.name, - association.entry_point.object, - ) - installed_before = self._installed_versions().get(project) - try: - current = AcquiredDistribution.discover(release, association) - except ExtensionEntryPointError: - current = None - if current is not None: - return current - - version = str(Version(release.version)) - _, simple_index_url = simple_project_and_index_urls( - self.registry_origin, - association, - ) - with tempfile.TemporaryDirectory(prefix="inkcre-extension-") as temp_directory: - temporary = Path(temp_directory) - acquisition = temporary / "acquisition" - acquisition.mkdir() - report_path = temporary / "pip-report.json" - - download = self._runner( - [ - "download", - "--only-binary=:all:", - "--no-deps", - "--dest", - str(acquisition), - "--index-url", - simple_index_url, - f"{association.project}=={version}", - ] - ) - _require_success(download, "wheel acquisition") - wheels = sorted(acquisition.glob("*.whl")) - if len(wheels) != 1: - raise ExtensionAcquisitionError( - "Registry Simple index did not yield exactly one compatible wheel" - ) - _validate_extension_wheel(wheels[0], release, association) - extension_wheel = wheels[0] + return self._runtime.acquire(release, association, CORE_VERSION) - plan = self._runner( - [ - "install", - "--dry-run", - "--only-binary=:all:", - "--report", - str(report_path), - "--no-index", - "--find-links", - str(acquisition), - str(extension_wheel), - ] - ) - _require_success(plan, "dependency preflight") - try: - report = json.loads(report_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as error: - raise ExtensionAcquisitionError("pip dependency report is invalid") from error - if not isinstance(report, dict): - raise ExtensionAcquisitionError("pip dependency report is not an object") - installs = self._report_requirements(report) - self._reject_replacements(installs, project) - planned_projects = { - canonicalize_name(typing.cast(dict[str, typing.Any], item["metadata"])["name"]) - for item in installs - } - if project not in planned_projects: - raise ExtensionAcquisitionError( - "pip did not plan the declared exact Extension Project" - ) - self._restart_required_reason = ( - "Core site-packages mutation began; restart Core before loading Extensions" - ) - install = self._runner( - [ - "install", - "--no-compile", - "--only-binary=:all:", - "--no-index", - "--find-links", - str(acquisition), - str(extension_wheel), - ] - ) - _require_success(install, "installation") - importlib.invalidate_caches() - acquired = AcquiredDistribution.discover(release, association) - if installed_before is not None: - raise ExtensionRestartRequiredError( - f"{association.project} was replaced; restart Core before loading it" - ) - self._restart_required_reason = None - return acquired +__all__ = [ + "AcquiredDistribution", + "DistributionConsumer", + "DistributionModules", + "PipDistributionConsumer", +] diff --git a/app/business/extension/errors.py b/app/business/extension/errors.py index 03bfc14..f1b3b00 100644 --- a/app/business/extension/errors.py +++ b/app/business/extension/errors.py @@ -1,34 +1,32 @@ -class ExtensionHostError(RuntimeError): - """Base error for the Core Extension Host.""" +"""Core facade for the independently released Runtime error taxonomy.""" +from inkcre_extension_runtime_core_py.errors import ( + ExtensionAcquisitionError, + ExtensionCompatibilityError, + ExtensionEntryPointError, + ExtensionLifecycleError, + ExtensionNotInstalledError, + ExtensionRegistryError, + ExtensionRuntimeError, + ExtensionStateConflictError, +) -class ExtensionNotInstalledError(ExtensionHostError): - pass - - -class ExtensionStateConflictError(ExtensionHostError): - pass - - -class ExtensionRegistryError(ExtensionHostError): - pass - - -class ExtensionCompatibilityError(ExtensionHostError): - pass - - -class ExtensionAcquisitionError(ExtensionHostError): - pass - - -class ExtensionEntryPointError(ExtensionHostError): - pass - - -class ExtensionRuntimeError(ExtensionHostError): - pass +ExtensionHostError = ExtensionRuntimeError class ExtensionRestartRequiredError(ExtensionStateConflictError): - pass + """The loaded Distribution can only be replaced after process restart.""" + + +__all__ = [ + "ExtensionAcquisitionError", + "ExtensionCompatibilityError", + "ExtensionEntryPointError", + "ExtensionHostError", + "ExtensionLifecycleError", + "ExtensionNotInstalledError", + "ExtensionRegistryError", + "ExtensionRestartRequiredError", + "ExtensionRuntimeError", + "ExtensionStateConflictError", +] diff --git a/app/business/extension/main.py b/app/business/extension/main.py index c43e241..0d5b433 100644 --- a/app/business/extension/main.py +++ b/app/business/extension/main.py @@ -2,13 +2,14 @@ from __future__ import annotations -import abc import asyncio import contextlib from dataclasses import dataclass import typing import fastapi +from inkcre_extension_runtime_core_py import EmptyConfig +from inkcre_extension_runtime_core_py import ExtensionBase as RuntimeExtensionBase import pydantic import sqlmodel @@ -21,7 +22,6 @@ ) from app.schemas.peer import PeerProtocolRequest, PeerProtocolResponse, PeerRef from app.settings import settings -from app.middleware import require_peer_jwt from libs.obsrv.main import get_logger from .distribution import ( @@ -42,18 +42,15 @@ from .release import ( PythonReleaseDescriptor, RegistryReleaseClient, + ReleaseState, ReleaseResolver, require_python_association, validate_coordinate, ) from .runtime import ( - ExtensionPublication, - ExtensionPublicationSnapshot, PublicHTTPRoute, - PublicHTTPRouteClaim, ExtensionRuntimeClaim, ExtensionRuntimeClaimConflictError, - ExtensionRuntimeRecord, ) from .state import ExtensionStore, InstalledExtension, SQLExtensionStore @@ -66,234 +63,66 @@ class ExtensionDelegationError(RuntimeError): """The selected Peer did not return the Extension management contract.""" -class EmptyConfig(sqlmodel.SQLModel): ... +class ExtensionBase(RuntimeExtensionBase, ext_id="_facade"): + """Core import facade retaining the established one-parameter type spelling.""" - -class EmptyState(pydantic.BaseModel): ... - - -ConfigTV = typing.TypeVar("ConfigTV", bound=pydantic.BaseModel) - - -class ExtensionBase(abc.ABC, typing.Generic[ConfigTV]): - """Extension-facing lifecycle and validated configuration model.""" - - config: ConfigTV - - def __init_subclass__( - cls, - ext_id: str, - config_cls: type[ConfigTV], - state_cls: type[pydantic.BaseModel] = EmptyState, - **kwargs: typing.Any, - ) -> None: - cls.__extid__ = ext_id - cls.__configcls__ = config_cls # pyrefly: ignore[no-access] - cls.__configschema__ = config_cls.model_json_schema() - cls.__statecls__ = state_cls - super().__init_subclass__(**kwargs) + config: typing.Any @classmethod - def on_start( - cls, - app: fastapi.FastAPI, - extension: ExtensionRuntimeRecord, - publication_snapshot: ExtensionPublicationSnapshot | None = None, - ) -> None: - """Validate config and atomically publish routes, sources, and resolvers.""" - snapshot = publication_snapshot or ExtensionPublicationSnapshot.capture(app) - if cls.runtime_active(): - snapshot.rollback() - raise ExtensionRuntimeError(f"Extension runtime {cls.__extid__} is already active") - if extension.extension_id != cls.__extid__: - snapshot.rollback() - raise ExtensionRuntimeError( - f"Runtime record {extension.extension_id} does not belong to {cls.__extid__}" - ) - - publication: ExtensionPublication | None = None - setattr(cls, "__runtime_record__", extension) - try: - validated_config = cls.__configcls__( # pyrefly: ignore[missing-attribute] - **(extension.config or {}) - ) - setattr(cls, "config", validated_config) - router = fastapi.APIRouter( - prefix=f"/{cls.__extid__}", - dependencies=cls.api_dependencies(), - ) - cls._register_apis(router) - registered_routes = tuple(router.routes) - app.include_router(router, tags=["extension", cls.__extid__]) - cls._init_sources() - cls._init_resolvers() - for inbound in cls.peer_inbounds(): - PeerManager.register_inbound(inbound) - publication = snapshot.finish() - publication.public_http_claim = PublicHTTPRouteClaim.acquire( - cls.__extid__, - cls.public_http_routes(), - registered_routes, - ) - publication.activate_source_types() - extension.persist_config_schema(dict(cls.__configschema__)) - except Exception: - if publication is None: - snapshot.rollback() - else: - publication.restore() - raise - - setattr(cls, "__runtime_publication__", publication) - LOGGER.info("Extension %s started", cls.__extid__) - - @classmethod - def _init_resolvers(cls) -> None: ... - - @classmethod - def load_decoders(cls) -> None: - """Publish persisted-content decoders without starting routes or Sources.""" - cls._init_resolvers() - - @classmethod - def _init_sources(cls) -> None: ... - - @classmethod - def api_dependencies(cls) -> list[typing.Any]: - """Return root API dependencies; override with [] to compose product auth.""" - return [fastapi.Depends(require_peer_jwt)] + def __class_getitem__(cls, _item: typing.Any) -> type[ExtensionBase]: + return cls @classmethod - def peer_inbounds(cls) -> tuple[typing.Any, ...]: - """Return exact Peer inbounds published while this Extension is running.""" - return () + def on_start(cls, app: typing.Any) -> None: + super().on_start(app) + cls.config = cls.get_config() @classmethod - def public_http_routes(cls) -> tuple[PublicHTTPRoute, ...]: - return () + def validate_config(cls, config: dict[str, typing.Any]) -> typing.Any: + return cls.__configcls__.model_validate(config) - @classmethod - async def on_close(cls) -> None: - record = typing.cast( - ExtensionRuntimeRecord | None, - cls.__dict__.get("__runtime_record__"), - ) - if record is None: - raise ExtensionRuntimeError(f"Extension runtime {cls.__extid__} has no state") - LOGGER.info("Extension %s closed", cls.__extid__) - - @classmethod - def runtime_active(cls) -> bool: - publication = typing.cast( - ExtensionPublication | None, - cls.__dict__.get("__runtime_publication__"), - ) - return publication is not None and not publication.restored - - @classmethod - def unpublish(cls) -> None: - publication = typing.cast( - ExtensionPublication | None, - cls.__dict__.get("__runtime_publication__"), - ) - if publication is not None: - publication.restore() - @classmethod - def release_runtime(cls) -> None: - for attribute in ("__runtime_publication__", "__runtime_record__"): - if attribute in cls.__dict__: - delattr(cls, attribute) +@dataclass +class _ActiveExtensionModel: + """Bind the Core store to the Runtime's rich active-record interface.""" - @classmethod - @abc.abstractmethod - def _register_apis(cls, router: fastapi.APIRouter) -> None: - """Register Extension-owned API endpoints.""" + name: str + config: dict[str, typing.Any] + store: ExtensionStore + persist_schema: bool = True - @classmethod - def _runtime_record(cls) -> ExtensionRuntimeRecord: - record = typing.cast( - ExtensionRuntimeRecord | None, - cls.__dict__.get("__runtime_record__"), - ) - if record is None: - raise ExtensionRuntimeError(f"Extension runtime {cls.__extid__} has no state") - return record + def _refresh(self) -> _ActiveExtensionModel: + self.config = self.store.read_config(self.name) + return self - @classmethod - def get_config(cls) -> ConfigTV: - validated = cls.__configcls__( # pyrefly: ignore[missing-attribute] - **cls._runtime_record().read_config() - ) - setattr(cls, "config", validated) - return validated + def update_config(self, config: dict[str, typing.Any]) -> _ActiveExtensionModel: + self.store.update_config(self.name, config) + return self._refresh() - @classmethod - def update_config(cls, new_config: dict[str, typing.Any] | ConfigTV) -> ConfigTV: - if isinstance(new_config, dict): - validated = cls.__configcls__(**new_config) # pyrefly: ignore[missing-attribute] - else: - validated = new_config - cls._runtime_record().persist_config(validated.model_dump(mode="json")) - setattr(cls, "config", validated) - return validated + def update_config_schema(self, schema: dict[str, typing.Any]) -> _ActiveExtensionModel: + if self.persist_schema: + self.store.update_config_schema(self.name, schema) + return self._refresh() - @classmethod - def get_state(cls) -> pydantic.BaseModel: - return cls.__statecls__( # pyrefly: ignore[missing-attribute] - **cls._runtime_record().read_state() - ) + def read_state(self) -> dict[str, typing.Any]: + return self.store.read_state(self.name) - @classmethod def mutate_state( - cls, - transform: typing.Callable[[pydantic.BaseModel], pydantic.BaseModel], - ) -> pydantic.BaseModel: - state_cls = cls.__statecls__ # pyrefly: ignore[missing-attribute] - - def mutate(raw: dict[str, typing.Any]) -> dict[str, typing.Any]: - current = state_cls(**raw) - updated = transform(current) - if not isinstance(updated, state_cls): - raise TypeError("Extension state transform returned the wrong model") - return updated.model_dump(mode="json") - - return state_cls(**cls._runtime_record().mutate_state(mutate)) + self, + transform: typing.Callable[[dict[str, typing.Any]], dict[str, typing.Any]], + ) -> dict[str, typing.Any]: + return self.store.mutate_state(self.name, transform) - @classmethod def mutate_config_and_state( - cls, + self, transform: typing.Callable[ - [ConfigTV, pydantic.BaseModel], tuple[ConfigTV, pydantic.BaseModel] + [dict[str, typing.Any], dict[str, typing.Any]], + tuple[dict[str, typing.Any], dict[str, typing.Any]], ], - ) -> tuple[ConfigTV, pydantic.BaseModel]: - config_cls = cls.__configcls__ # pyrefly: ignore[missing-attribute] - state_cls = cls.__statecls__ # pyrefly: ignore[missing-attribute] - - def mutate( - raw_config: dict[str, typing.Any], - raw_state: dict[str, typing.Any], - ) -> tuple[dict[str, typing.Any], dict[str, typing.Any]]: - config, state = transform(config_cls(**raw_config), state_cls(**raw_state)) - if not isinstance(config, config_cls) or not isinstance(state, state_cls): - raise TypeError("Extension config/state transform returned the wrong models") - return config.model_dump(mode="json"), state.model_dump(mode="json") - - raw_config, raw_state = cls._runtime_record().mutate_config_and_state(mutate) - config = config_cls(**raw_config) - state = state_cls(**raw_state) - setattr(cls, "config", config) - return config, state - - @classmethod - def validate_config(cls, config: dict[str, typing.Any]) -> ConfigTV: - """Validate one persisted configuration through the Extension-owned model.""" - return cls.__configcls__(**config) # pyrefly: ignore[missing-attribute, bad-return] - - @classmethod - def config_schema(cls) -> dict[str, typing.Any]: - """Return the Extension-owned schema projected to deployment state.""" - return dict(cls.__configschema__) + ) -> tuple[dict[str, typing.Any], dict[str, typing.Any]]: + result = self.store.mutate_config_and_state(self.name, transform) + self._refresh() + return result @dataclass @@ -392,9 +221,9 @@ def _resolve( release_client: ReleaseResolver, ): release = release_client.get(name, version) - if release.state == "yanked" and allow_yanked: + if release.state is ReleaseState.yanked and allow_yanked: LOGGER.warning("Using yanked exact installed Release %s@%s", name, version) - elif release.state != "published": + elif release.state is not ReleaseState.published: raise ExtensionCompatibilityError( f"{name}@{version} is not available for this operation" ) @@ -511,33 +340,6 @@ async def _start_acquired( claim.release() raise extension_class: type[ExtensionBase] | None = None - snapshot = ExtensionPublicationSnapshot.capture(app) - schema_box: dict[str, dict[str, typing.Any]] = {} - - def persist_config(config: dict[str, typing.Any]) -> None: - self.store.update_config(state.name, config) - - def read_config() -> dict[str, typing.Any]: - return self.store.read_config(state.name) - - def read_state() -> dict[str, typing.Any]: - return self.store.read_state(state.name) - - def mutate_state( - transform: typing.Callable[[dict[str, typing.Any]], dict[str, typing.Any]], - ) -> dict[str, typing.Any]: - return self.store.mutate_state(state.name, transform) - - def mutate_config_and_state( - transform: typing.Callable[ - [dict[str, typing.Any], dict[str, typing.Any]], - tuple[dict[str, typing.Any], dict[str, typing.Any]], - ], - ) -> tuple[dict[str, typing.Any], dict[str, typing.Any]]: - return self.store.mutate_config_and_state(state.name, transform) - - def stage_schema(schema: dict[str, typing.Any]) -> None: - schema_box["value"] = schema try: extension_class = typing.cast(type[ExtensionBase], modules.load(ExtensionBase)) @@ -545,37 +347,26 @@ def stage_schema(schema: dict[str, typing.Any]) -> None: raise ExtensionCompatibilityError( "ExtensionBase identity differs from the declared entry point" ) - runtime_record = ExtensionRuntimeRecord( - extension_id=association.entry_point.name, - config=dict(state.config), - read_config=read_config, - persist_config=persist_config, - read_state=read_state, - mutate_state=mutate_state, - mutate_config_and_state=mutate_config_and_state, - persist_config_schema=stage_schema, - ) - extension_class.on_start( - app, - runtime_record, - publication_snapshot=snapshot, + extension_class.bind( + _ActiveExtensionModel( + name=state.name, + config=dict(state.config), + store=self.store, + persist_schema=persist_schema, + ) ) + extension_class.on_start(app) modules.assert_origins() - schema = schema_box.get("value") - if schema is None: - raise ExtensionRuntimeError("Extension did not publish a config schema") - if persist_schema: - self.store.update_config_schema(state.name, schema) except Exception: if extension_class is not None: with contextlib.suppress(Exception): await extension_class.on_close() with contextlib.suppress(Exception): extension_class.unpublish() + with contextlib.suppress(Exception): + extension_class.unbind() with contextlib.suppress(Exception): extension_class.release_runtime() - with contextlib.suppress(Exception): - snapshot.rollback() with contextlib.suppress(Exception): modules.abort() claim.release() @@ -597,6 +388,7 @@ def stage_schema(schema: dict[str, typing.Any]) -> None: async def _stop(self, running: RunningExtension) -> None: await running.extension_class.on_close() running.extension_class.unpublish() + running.extension_class.unbind() running.modules.unload() running.extension_class.release_runtime() running.claim.release() @@ -612,6 +404,10 @@ async def _force_stop(self, running: RunningExtension) -> typing.List[Exception] running.extension_class.unpublish() except Exception as error: failures.append(error) + try: + running.extension_class.unbind() + except Exception as error: + failures.append(error) try: running.modules.abort() except Exception as error: diff --git a/app/business/extension/release.py b/app/business/extension/release.py index 3cb9957..584d864 100644 --- a/app/business/extension/release.py +++ b/app/business/extension/release.py @@ -1,157 +1,39 @@ -"""Exact Extension Release reader and Core Host compatibility precheck.""" +"""Core facade for exact Release resolution owned by the Extension Runtime.""" -from __future__ import annotations - -import re import typing -from urllib.parse import urljoin, urlsplit, urlunsplit - -from packaging.utils import canonicalize_name -import pydantic -import requests # pyrefly: ignore[untyped-import] -from semantic_version import NpmSpec, Version - -from app.version import CORE_VERSION - -from .errors import ExtensionCompatibilityError, ExtensionRegistryError - -CORE_HOST_SDK = "core-py" -CORE_HOST_SDK_VERSION = CORE_VERSION -ENTRY_POINT_GROUP = "inkcre.core.extensions" -_SEGMENT_PATTERN = re.compile(r"^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$") -_SEMVER_PATTERN = re.compile( - r"^(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)" - r"(-(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" - r"([.](0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?$" +from inkcre_extension_runtime_core_py.contracts import ( + ExtensionReleaseDescriptor, + PythonReleaseDescriptor, + ReleaseState, +) +from inkcre_extension_runtime_core_py.release import ( + RegistryReleaseClient, + require_python_association as _require_python_association, + simple_project_and_index_urls, + validate_coordinate, ) - -class EntryPointDescriptor(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid") - - group: str - name: str - object: str - - -class PythonReleaseDescriptor(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid") - - project: str - simple_url: str - host_sdk: str - host_sdk_version: str - entry_point: EntryPointDescriptor - - -class ExtensionReleaseDescriptor(pydantic.BaseModel): - model_config = pydantic.ConfigDict(extra="forbid") - - name: str - nickname: str - version: str - state: str - python: PythonReleaseDescriptor | None = None - module_federation: dict | None = None +from app.version import CORE_VERSION class ReleaseResolver(typing.Protocol): def get(self, name: str, version: str) -> ExtensionReleaseDescriptor: ... -def validate_coordinate(name: str, version: str | None = None) -> tuple[str, str]: - parts = name.split("/") - if len(parts) != 2 or any(_SEGMENT_PATTERN.fullmatch(part) is None for part in parts): - raise ExtensionCompatibilityError("Extension Name is not canonical") - if version is not None and _SEMVER_PATTERN.fullmatch(version) is None: - raise ExtensionCompatibilityError("Extension Release version is not strict SemVer") - return parts[0], parts[1] - - -class RegistryReleaseClient: - def __init__(self, origin: str, timeout: float) -> None: - self.origin = origin.rstrip("/") + "/" - self.timeout = timeout - - def get(self, name: str, version: str) -> ExtensionReleaseDescriptor: - namespace, extension = validate_coordinate(name, version) - url = urljoin( - self.origin, - f"v1/extensions/{namespace}/{extension}/releases/{version}", - ) - try: - response = requests.get(url, timeout=self.timeout) - response.raise_for_status() - release = ExtensionReleaseDescriptor.model_validate(response.json()) - except (requests.RequestException, ValueError, pydantic.ValidationError) as error: - raise ExtensionRegistryError( - f"Registry could not resolve {name}@{version}" - ) from error - if release.name != name or release.version != version: - raise ExtensionRegistryError("Registry returned a different exact Release") - return release - - def require_python_association( release: ExtensionReleaseDescriptor, ) -> PythonReleaseDescriptor: - association = release.python - if association is None: - raise ExtensionCompatibilityError( - f"{release.name}@{release.version} has no Core Python Distribution" - ) - if association.host_sdk != CORE_HOST_SDK: - raise ExtensionCompatibilityError("Python Distribution targets another Host SDK") - try: - compatible = NpmSpec(association.host_sdk_version).match(Version(CORE_HOST_SDK_VERSION)) - except ValueError as error: - raise ExtensionCompatibilityError( - "Python Distribution declares an invalid Core Host SDK range" - ) from error - if not compatible: - raise ExtensionCompatibilityError( - f"Python Distribution does not support {CORE_HOST_SDK}@{CORE_HOST_SDK_VERSION}" - ) - entry_point = association.entry_point - if entry_point.group != ENTRY_POINT_GROUP: - raise ExtensionCompatibilityError("Python Distribution entry-point group is invalid") - if not entry_point.name or not entry_point.object or ":" not in entry_point.object: - raise ExtensionCompatibilityError("Python Distribution entry point is invalid") - return association - - -def simple_project_and_index_urls( - origin: str, - association: PythonReleaseDescriptor, -) -> tuple[str, str]: - project_url = urljoin(origin.rstrip("/") + "/", association.simple_url) - configured = urlsplit(origin) - parsed = urlsplit(project_url) - if ( - configured.scheme not in {"http", "https"} - or not configured.netloc - or configured.username is not None - or configured.password is not None - or configured.query - or configured.fragment - ): - raise ExtensionCompatibilityError("Configured Registry origin is invalid") - if ( - parsed.scheme != configured.scheme - or parsed.netloc.lower() != configured.netloc.lower() - or parsed.username is not None - or parsed.password is not None - or parsed.query - or parsed.fragment - ): - raise ExtensionCompatibilityError("Registry Simple URL is not same-origin") - if parsed.scheme not in {"http", "https"} or not parsed.netloc: - raise ExtensionCompatibilityError("Registry Simple URL is not HTTP(S)") - expected_path = f"/simple/{canonicalize_name(association.project)}/" - if parsed.path != expected_path: - raise ExtensionCompatibilityError( - "Registry Simple URL does not use the declared Project path" - ) - index_url = urlunsplit((parsed.scheme, parsed.netloc, "/simple/", "", "")) - return project_url, index_url + return _require_python_association(release, CORE_VERSION) + + +__all__ = [ + "ExtensionReleaseDescriptor", + "PythonReleaseDescriptor", + "RegistryReleaseClient", + "ReleaseResolver", + "ReleaseState", + "require_python_association", + "simple_project_and_index_urls", + "validate_coordinate", +] diff --git a/app/business/extension/runtime.py b/app/business/extension/runtime.py index ca5b75c..34561ea 100644 --- a/app/business/extension/runtime.py +++ b/app/business/extension/runtime.py @@ -1,254 +1,19 @@ -"""Reversible publication primitives shared by legacy and Registry extensions.""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass -import threading -import typing - -import fastapi - -from app.business.info_base.resolver.main import Resolver, ResolverManager -from app.business.peer import PeerManager -from app.business.peer.contracts import PeerInbound -from app.business.source.main import SourceBase, SourceManager -from app.schemas.peer import CapabilityID -from app.schemas.info_base.block import ResolverType - - -@dataclass(frozen=True) -class PublicHTTPRoute: - """One exact Extension route intentionally published without Peer JWT.""" - - method: typing.Literal["GET", "POST"] - path: str - - def __post_init__(self) -> None: - if ( - not self.path.startswith("/") - or self.path == "/" - or "{" in self.path - or "}" in self.path - or "*" in self.path - or "?" in self.path - or "#" in self.path - ): - raise ValueError("Public Extension route must be an exact relative path") - - -class PublicHTTPRouteClaim: - """Process authority for exact public routes contributed by a runtime.""" - - _lock = threading.Lock() - _owners: dict[tuple[str, str], object] = {} - - def __init__(self, routes: frozenset[tuple[str, str]], token: object) -> None: - self.routes = routes - self._token = token - self._released = False - - @classmethod - def acquire( - cls, - extension_id: str, - declarations: tuple[PublicHTTPRoute, ...], - published_routes: tuple[typing.Any, ...], - ) -> PublicHTTPRouteClaim | None: - if not declarations: - return None - available = { - (method, route.path) - for route in published_routes - for method in (getattr(route, "methods", None) or ()) - if isinstance(getattr(route, "path", None), str) - } - absolute = frozenset( - (declaration.method, f"/{extension_id}{declaration.path}") - for declaration in declarations - ) - missing = absolute - available - if missing: - raise ValueError(f"Public Extension routes were not published: {sorted(missing)}") - token = object() - with cls._lock: - conflicts = absolute & cls._owners.keys() - if conflicts: - raise ExtensionRuntimeClaimConflictError( - f"Public Extension route already claimed: {sorted(conflicts)}" - ) - for route in absolute: - cls._owners[route] = token - return cls(absolute, token) - - @classmethod - def permits(cls, method: str, path: str) -> bool: - with cls._lock: - return (method.upper(), path) in cls._owners - - def release(self) -> None: - if self._released: - return - with self._lock: - for route in self.routes: - if self._owners.get(route) is self._token: - self._owners.pop(route) - self._released = True - - -class ExtensionRuntimeClaimConflictError(RuntimeError): - """Raised when another manager already owns an Extension runtime ID.""" - - -class ExtensionRuntimeClaim: - """An atomic, process-local claim for one canonical Extension runtime ID.""" - - _lock = threading.Lock() - _owners: dict[str, object] = {} - - def __init__(self, extension_id: str, token: object) -> None: - self.extension_id = extension_id - self._token = token - self._released = False - - @classmethod - def acquire(cls, extension_id: str) -> ExtensionRuntimeClaim: - token = object() - with cls._lock: - if extension_id in cls._owners: - raise ExtensionRuntimeClaimConflictError( - f"Extension runtime {extension_id} already owns the canonical module" - ) - cls._owners[extension_id] = token - return cls(extension_id, token) - - def release(self) -> None: - """Release this exact claim; repeated cleanup is intentionally harmless.""" - if self._released: - return - with self._lock: - if self._owners.get(self.extension_id) is self._token: - self._owners.pop(self.extension_id) - self._released = True - - -@dataclass(frozen=True) -class ExtensionRuntimeRecord: - """The narrow deployment state an Extension class needs at runtime.""" - - extension_id: str - config: dict[str, typing.Any] - read_config: Callable[[], dict[str, typing.Any]] - persist_config: Callable[[dict[str, typing.Any]], None] - read_state: Callable[[], dict[str, typing.Any]] - mutate_state: Callable[ - [Callable[[dict[str, typing.Any]], dict[str, typing.Any]]], - dict[str, typing.Any], - ] - mutate_config_and_state: Callable[ - [ - Callable[ - [dict[str, typing.Any], dict[str, typing.Any]], - tuple[dict[str, typing.Any], dict[str, typing.Any]], - ] - ], - tuple[dict[str, typing.Any], dict[str, typing.Any]], - ] - persist_config_schema: Callable[[dict[str, typing.Any]], None] - - -@dataclass -class ExtensionPublication: - """The observable side effects contributed by one Extension startup.""" - - app: fastapi.FastAPI - routes: tuple[typing.Any, ...] - source_types_before: dict[str, type[SourceBase]] - source_types_published: dict[str, type[SourceBase]] - resolvers_before: dict[ResolverType, type[Resolver]] - resolvers_published: dict[ResolverType, type[Resolver]] - peer_inbounds_before: dict[CapabilityID, PeerInbound] - peer_inbounds_published: dict[CapabilityID, PeerInbound] - public_http_claim: PublicHTTPRouteClaim | None = None - restored: bool = False - - def _contributed_source_types(self) -> dict[str, type[SourceBase]]: - return { - source_type: source_class - for source_type, source_class in self.source_types_published.items() - if self.source_types_before.get(source_type) is not source_class - } - - def activate_source_types(self) -> None: - """Persist only the source types published by this runtime.""" - contributed = self._contributed_source_types() - if not contributed: - return - SourceManager.sync_source_types(contributed) - - def restore(self) -> None: - """Withdraw this publication without disturbing unrelated later routes.""" - if self.restored: - return - - route_ids = {id(route) for route in self.routes} - self.app.router.routes[:] = [ - route for route in self.app.router.routes if id(route) not in route_ids - ] - PeerManager.restore_inbounds( - self.peer_inbounds_before, - self.peer_inbounds_published, - ) - if self.public_http_claim is not None: - self.public_http_claim.release() - self.public_http_claim = None - SourceManager.restore_source_types( - self.source_types_before, - self.source_types_published, - ) - ResolverManager.restore_resolvers( - self.resolvers_before, - self.resolvers_published, - ) - self.app.openapi_schema = None - self.restored = True - - -@dataclass(frozen=True) -class ExtensionPublicationSnapshot: - """Before-state used to finalize or roll back one startup publication.""" - - app: fastapi.FastAPI - route_ids: frozenset[int] - source_types: dict[str, type[SourceBase]] - resolvers: dict[ResolverType, type[Resolver]] - peer_inbounds: dict[CapabilityID, PeerInbound] - - @classmethod - def capture(cls, app: fastapi.FastAPI) -> ExtensionPublicationSnapshot: - return cls( - app=app, - route_ids=frozenset(id(route) for route in app.router.routes), - source_types=SourceManager.snapshot_source_types(), - resolvers=ResolverManager.snapshot_resolvers(), - peer_inbounds=PeerManager.snapshot_inbounds(), - ) - - def finish(self) -> ExtensionPublication: - publication = ExtensionPublication( - app=self.app, - routes=tuple( - route for route in self.app.router.routes if id(route) not in self.route_ids - ), - source_types_before=self.source_types, - source_types_published=SourceManager.snapshot_source_types(), - resolvers_before=self.resolvers, - resolvers_published=ResolverManager.snapshot_resolvers(), - peer_inbounds_before=self.peer_inbounds, - peer_inbounds_published=PeerManager.snapshot_inbounds(), - ) - self.app.openapi_schema = None - return publication - - def rollback(self) -> None: - self.finish().restore() +"""Core facade for the independently released Extension Runtime primitives.""" + +from inkcre_extension_runtime_core_py.publication import ( + ExtensionPublication, + ExtensionPublicationSnapshot, + ExtensionRuntimeClaim, + ExtensionRuntimeClaimConflictError, + PublicHTTPRoute, + PublicHTTPRouteClaim, +) + +__all__ = [ + "ExtensionPublication", + "ExtensionPublicationSnapshot", + "ExtensionRuntimeClaim", + "ExtensionRuntimeClaimConflictError", + "PublicHTTPRoute", + "PublicHTTPRouteClaim", +] diff --git a/deploy/profiles/production.json b/deploy/profiles/production.json index 61f7bfe..6a8ec38 100644 --- a/deploy/profiles/production.json +++ b/deploy/profiles/production.json @@ -2,8 +2,8 @@ "format": 1, "environment": "production", "database_contract": { - "revision": "extension-registry-feature-retrieval-v1", - "migration_head": "50b2c08dd267", + "revision": "peer-extension-setup-v1", + "migration_head": "c6d7e8f9a0b1", "protocol_schema": "inkcre" }, "peer": { diff --git a/extensions/twitter/__init__.py b/extensions/twitter/__init__.py index c074b6d..4bbd424 100644 --- a/extensions/twitter/__init__.py +++ b/extensions/twitter/__init__.py @@ -86,15 +86,13 @@ def public_http_routes(cls) -> tuple[PublicHTTPRoute, ...]: @classmethod def update_config( cls, - new_config: dict[str, typing.Any] | TwitterExtensionConfig, + value: dict[str, typing.Any] | TwitterExtensionConfig, ) -> TwitterExtensionConfig: """Keep generic config writes consistent with setup-owned OAuth state.""" from .setup_flow import _fingerprint, _invalidate_mismatched_oauth_state validated = ( - TwitterExtensionConfig.model_validate(new_config) - if isinstance(new_config, dict) - else new_config + TwitterExtensionConfig.model_validate(value) if isinstance(value, dict) else value ) def update(config_model, state_model): diff --git a/migrations/revision-integrity.json b/migrations/revision-integrity.json index 38dd29b..71b908b 100644 --- a/migrations/revision-integrity.json +++ b/migrations/revision-integrity.json @@ -14,7 +14,7 @@ "b9c0d1e2f3a4_add_agent_definitions.py": "59529e759610193da3427c5b18e0c9be25e46e5e2419a3237bcf9ef88c263fa3", "c0d1e2f3a4b5_adopt_peer_capability_delegation.py": "7474fcda0b55fe2ce2047f761579459385aac9f43e7359ced881b8eb1fc6c265", "c4e8a7b6d5f0_converge_production_schema.py": "fb5687ad523c64297fe101d109918af2501bec26219de78353944a762eadfe4e", - "c6d7e8f9a0b1_add_extension_setup_state.py": "5aab0d1dda6932fbac53f5a6adf4b504f994e8c860833b64fc21fb9cb8d8fc3e", + "c6d7e8f9a0b1_add_extension_setup_state.py": "d55bbb8eb18d4ffac58d2813a7a67f33790640a8d3a24b6c30ddcd825cc36b41", "c9d2e3f4a5b6_move_trigger_helper_internal.py": "6fab826c66cef1b16540142ae3d17ff953dc2ca49c79a4cffea95d4520701e6e", "d0e3f4a5b6c7_add_octet_stream_handler.py": "0d7127d340b878243d894e27f8b97d5982aa01c757703528d3982eb89ae9219e", "d9f4e2a1b7c3_adopt_peer_database_protocol.py": "9a163533b5a0619e51bfb94ba7ad0c5c3d168fba5808b08f67531fd8f7e5f263", diff --git a/migrations/versions/c6d7e8f9a0b1_add_extension_setup_state.py b/migrations/versions/c6d7e8f9a0b1_add_extension_setup_state.py index ce13340..2cdfb01 100644 --- a/migrations/versions/c6d7e8f9a0b1_add_extension_setup_state.py +++ b/migrations/versions/c6d7e8f9a0b1_add_extension_setup_state.py @@ -1,7 +1,7 @@ """Add deployment-wide Extension setup state. Revision ID: c6d7e8f9a0b1 -Revises: 3f7a9c2d5e1b +Revises: 50b2c08dd267 Create Date: 2026-08-16 """ @@ -19,7 +19,7 @@ revision: str = "c6d7e8f9a0b1" -down_revision: str | Sequence[str] | None = "3f7a9c2d5e1b" +down_revision: str | Sequence[str] | None = "50b2c08dd267" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None diff --git a/pdm.lock b/pdm.lock index a3aead8..a27f867 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev", "extension-preview", "extension-publisher"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:8d0bbdbea4f86221f6bec2534ef497777b44fd422bf75bcbeb5597a30b40aa72" +content_hash = "sha256:18a7f7dfd1c8866e76a62b38d2850198faa8df8af446f47b7046c838c650b7a8" [[metadata.targets]] requires_python = ">=3.12,<3.13" @@ -189,6 +189,21 @@ files = [ {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, ] +[[package]] +name = "authlib" +version = "1.7.2" +requires_python = ">=3.10" +summary = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." +groups = ["default"] +dependencies = [ + "cryptography", + "joserfc>=1.6.0", +] +files = [ + {file = "authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f"}, + {file = "authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231"}, +] + [[package]] name = "av" version = "18.1.0" @@ -558,13 +573,13 @@ files = [ [[package]] name = "filelock" -version = "3.32.3" +version = "3.32.4" requires_python = ">=3.10" summary = "A platform independent file lock." groups = ["dev"] files = [ - {file = "filelock-3.32.3-py3-none-any.whl", hash = "sha256:7f0ca4bcc0e181c60dbbd8aa9ab5b120ebb99e4e064e83636340056f833a1f09"}, - {file = "filelock-3.32.3.tar.gz", hash = "sha256:0ffa185a3540854c95caa7fa76b76cb219d907415e2c5dc9af25fd970563487f"}, + {file = "filelock-3.32.4-py3-none-any.whl", hash = "sha256:22e58ca3b1ae3b98993b762d7338367ae64fe50252bf78d59da3bfebcdf1cedd"}, + {file = "filelock-3.32.4.tar.gz", hash = "sha256:2bde2e4cf732e0153406d8a7bc80620ecf5e621fe0d25e41143c4e3b4733ff30"}, ] [[package]] @@ -768,6 +783,24 @@ files = [ {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, ] +[[package]] +name = "inkcre-extension-runtime-core-py" +version = "0.1.0" +requires_python = "<3.14,>=3.12" +url = "https://github.com/InKCre/ext-reg/releases/download/runtime-core-py-v0.1.0/inkcre_extension_runtime_core_py-0.1.0-py3-none-any.whl" +summary = "InKCre Core Python Extension Host Runtime" +groups = ["default"] +dependencies = [ + "fastapi<0.142,>=0.139.2", + "packaging<27,>=25", + "pydantic<3,>=2.10", + "requests<3,>=2.32", + "semantic-version<3,>=2.10", +] +files = [ + {file = "inkcre_extension_runtime_core_py-0.1.0-py3-none-any.whl", hash = "sha256:a50f9e316132204810270f16d1600553fd37c9b78df6c39826b3afce857b1c55"}, +] + [[package]] name = "inkcre-extension-toolkit" version = "0.1.0" @@ -825,6 +858,20 @@ files = [ {file = "jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c"}, ] +[[package]] +name = "joserfc" +version = "1.7.4" +requires_python = ">=3.10" +summary = "The ultimate Python library for JOSE RFCs, including JWS, JWE, JWK, JWA, JWT" +groups = ["default"] +dependencies = [ + "cryptography>=45.0.1", +] +files = [ + {file = "joserfc-1.7.4-py3-none-any.whl", hash = "sha256:32d46c2cd5e3203c13e87a6c61333cab310b1ba80cd54b4c4f386a848a122463"}, + {file = "joserfc-1.7.4.tar.gz", hash = "sha256:b3bc561672ae541b17a9237053b48a03dacddd92d68047b3ecdfb4b5714a88ed"}, +] + [[package]] name = "js2py-3-13" version = "0.74.1" @@ -902,30 +949,30 @@ files = [ [[package]] name = "lxml" -version = "6.1.1" +version = "6.1.2" requires_python = ">=3.8" summary = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." groups = ["default"] files = [ - {file = "lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7"}, - {file = "lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1"}, - {file = "lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc"}, - {file = "lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc"}, - {file = "lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383"}, - {file = "lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b"}, - {file = "lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818"}, - {file = "lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740"}, - {file = "lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f"}, - {file = "lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2"}, - {file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635"}, - {file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf"}, - {file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc"}, - {file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955"}, - {file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a"}, - {file = "lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a"}, - {file = "lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77"}, - {file = "lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f"}, - {file = "lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40"}, + {file = "lxml-6.1.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7d506bdba580ecb1a6ad2e2b5c49445e66d3e1f95894885739094393a1aad237"}, + {file = "lxml-6.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12acd337d2821cb8b9247dfe4b7aa2f2769a3df5ae8511b7e550df42b8f4d3c3"}, + {file = "lxml-6.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5078ff51e6316c0f75ea8127c2cd24374747fb351f62fb93d1761f8ae5a04a40"}, + {file = "lxml-6.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9477e14217c212e6023c994a71a1a349db19b0e10fd5bf189666b281ae63b1fd"}, + {file = "lxml-6.1.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:261d98065326676d7253882db0198d0aa06748d7ee0443367acf10b148273f99"}, + {file = "lxml-6.1.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0666943ee1576fa890a6dc6316ef42e8241b5dd56f67bc5475acb2ac298c6ca9"}, + {file = "lxml-6.1.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04cf9e3f4ee9cab9d9ba05401bef8668840fa9620fcd4d8e85a2d2fd0b0fa960"}, + {file = "lxml-6.1.2-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:9429d2371d406344ed1da5b5686d9412e74137c07b0171278368ff704f470ed5"}, + {file = "lxml-6.1.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:eff128ffdc093cc6317955934ad9751105d37ed8dbca3ff4ccd751af6be37185"}, + {file = "lxml-6.1.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ba58574d710b82ead7cbedea01cac3e110bc3ef82d4731519b74a2c11f7cf5e9"}, + {file = "lxml-6.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:52f6d4dff133c9778a24e9a2cfc1608930b15869866171aacc5131b5a418a003"}, + {file = "lxml-6.1.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:8807998c1023d1e9d60e02500f90e85a0752dbc0b670989806bba87b82dd5b42"}, + {file = "lxml-6.1.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2170d0a280c877b6e2dc6738217db947be35dd8cf09ca458b355aa1bab2a9e70"}, + {file = "lxml-6.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c67f3c1278f942e97d8665c2a690324aaea5137de16f056583a21f0ac706177f"}, + {file = "lxml-6.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:093fbf547d0f3ca02705381f795a050fbb58988be4aac7f79f99f280c4082313"}, + {file = "lxml-6.1.2-cp312-cp312-win32.whl", hash = "sha256:be365ce8d2d411cf2fb573747684b4fd470fa6224e0094d9d5a21155acc369d3"}, + {file = "lxml-6.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:b97153ca609b434b712ddfb92cd6af101a7045a7724c542258bd4727a344472f"}, + {file = "lxml-6.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:7feb72424f19a893ae4f3373c7aae821b1aacb6076b708915c651f0683a97c49"}, + {file = "lxml-6.1.2.tar.gz", hash = "sha256:1055241852f2b02068af4a625a5d32c087db193c12251928af2562ecd2239f18"}, ] [[package]] @@ -943,35 +990,35 @@ files = [ [[package]] name = "lxml" -version = "6.1.1" +version = "6.1.2" extras = ["html_clean"] requires_python = ">=3.8" summary = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." groups = ["default"] dependencies = [ "lxml-html-clean", - "lxml==6.1.1", -] -files = [ - {file = "lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7"}, - {file = "lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1"}, - {file = "lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc"}, - {file = "lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc"}, - {file = "lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383"}, - {file = "lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b"}, - {file = "lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818"}, - {file = "lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740"}, - {file = "lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f"}, - {file = "lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2"}, - {file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635"}, - {file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf"}, - {file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc"}, - {file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955"}, - {file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a"}, - {file = "lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a"}, - {file = "lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77"}, - {file = "lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f"}, - {file = "lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40"}, + "lxml==6.1.2", +] +files = [ + {file = "lxml-6.1.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7d506bdba580ecb1a6ad2e2b5c49445e66d3e1f95894885739094393a1aad237"}, + {file = "lxml-6.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12acd337d2821cb8b9247dfe4b7aa2f2769a3df5ae8511b7e550df42b8f4d3c3"}, + {file = "lxml-6.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5078ff51e6316c0f75ea8127c2cd24374747fb351f62fb93d1761f8ae5a04a40"}, + {file = "lxml-6.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9477e14217c212e6023c994a71a1a349db19b0e10fd5bf189666b281ae63b1fd"}, + {file = "lxml-6.1.2-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:261d98065326676d7253882db0198d0aa06748d7ee0443367acf10b148273f99"}, + {file = "lxml-6.1.2-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0666943ee1576fa890a6dc6316ef42e8241b5dd56f67bc5475acb2ac298c6ca9"}, + {file = "lxml-6.1.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04cf9e3f4ee9cab9d9ba05401bef8668840fa9620fcd4d8e85a2d2fd0b0fa960"}, + {file = "lxml-6.1.2-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:9429d2371d406344ed1da5b5686d9412e74137c07b0171278368ff704f470ed5"}, + {file = "lxml-6.1.2-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:eff128ffdc093cc6317955934ad9751105d37ed8dbca3ff4ccd751af6be37185"}, + {file = "lxml-6.1.2-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ba58574d710b82ead7cbedea01cac3e110bc3ef82d4731519b74a2c11f7cf5e9"}, + {file = "lxml-6.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:52f6d4dff133c9778a24e9a2cfc1608930b15869866171aacc5131b5a418a003"}, + {file = "lxml-6.1.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:8807998c1023d1e9d60e02500f90e85a0752dbc0b670989806bba87b82dd5b42"}, + {file = "lxml-6.1.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2170d0a280c877b6e2dc6738217db947be35dd8cf09ca458b355aa1bab2a9e70"}, + {file = "lxml-6.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c67f3c1278f942e97d8665c2a690324aaea5137de16f056583a21f0ac706177f"}, + {file = "lxml-6.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:093fbf547d0f3ca02705381f795a050fbb58988be4aac7f79f99f280c4082313"}, + {file = "lxml-6.1.2-cp312-cp312-win32.whl", hash = "sha256:be365ce8d2d411cf2fb573747684b4fd470fa6224e0094d9d5a21155acc369d3"}, + {file = "lxml-6.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:b97153ca609b434b712ddfb92cd6af101a7045a7724c542258bd4727a344472f"}, + {file = "lxml-6.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:7feb72424f19a893ae4f3373c7aae821b1aacb6076b708915c651f0683a97c49"}, + {file = "lxml-6.1.2.tar.gz", hash = "sha256:1055241852f2b02068af4a625a5d32c087db193c12251928af2562ecd2239f18"}, ] [[package]] @@ -1425,8 +1472,8 @@ files = [ [[package]] name = "pygithub" -version = "2.9.1" -requires_python = ">=3.9" +version = "2.10.0" +requires_python = ">=3.10" summary = "Use the full Github API v3" groups = ["default"] dependencies = [ @@ -1437,8 +1484,8 @@ dependencies = [ "urllib3>=1.26.0", ] files = [ - {file = "pygithub-2.9.1-py3-none-any.whl", hash = "sha256:2ec78fca30092d51a42d76f4ddb02131b6f0c666a35dfdf364cf302cdda115b9"}, - {file = "pygithub-2.9.1.tar.gz", hash = "sha256:59771d7ff63d54d427be2e7d0dad2208dfffc2b0a045fec959263787739b611c"}, + {file = "pygithub-2.10.0-py3-none-any.whl", hash = "sha256:192ada2a76e4afc7d6b37e500c9bfeba1731e6506697445a5ba1c4af8bf0b924"}, + {file = "pygithub-2.10.0.tar.gz", hash = "sha256:90ff24ef1cd1bd57124c2a3869cafee9d7b066909129ecdaba2c2d1903bc118d"}, ] [[package]] @@ -1530,7 +1577,7 @@ files = [ [[package]] name = "pypdf" -version = "6.16.1" +version = "6.16.2" requires_python = ">=3.9" summary = "A pure-python PDF library capable of splitting, merging, cropping, and transforming PDF files" groups = ["default"] @@ -1538,8 +1585,8 @@ dependencies = [ "typing-extensions>=4.0; python_version < \"3.11\"", ] files = [ - {file = "pypdf-6.16.1-py3-none-any.whl", hash = "sha256:63fec31c4092ae50b6729beedcb469055b60d20c834bde1c402df241f371f644"}, - {file = "pypdf-6.16.1.tar.gz", hash = "sha256:c4d1b43ddae921387321cf63936cd16a7743b91d2da92f165c149a195c972ba9"}, + {file = "pypdf-6.16.2-py3-none-any.whl", hash = "sha256:c8b09a59399062fb45a1b8156c18a787a10a3dae03ac9674397a226712c94604"}, + {file = "pypdf-6.16.2.tar.gz", hash = "sha256:595647f6191de6f402cfde1d0c455d6cbccbd509aac32b34783009c032de5d6e"}, ] [[package]] @@ -1795,29 +1842,29 @@ files = [ [[package]] name = "ruff" -version = "0.16.3" +version = "0.16.4" requires_python = ">=3.7" summary = "An extremely fast Python linter and code formatter, written in Rust." groups = ["dev"] files = [ - {file = "ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7"}, - {file = "ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081"}, - {file = "ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9"}, - {file = "ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84"}, - {file = "ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870"}, - {file = "ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b"}, - {file = "ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413"}, - {file = "ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82"}, - {file = "ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb"}, - {file = "ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474"}, - {file = "ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da"}, - {file = "ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50"}, - {file = "ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506"}, - {file = "ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d"}, - {file = "ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a"}, - {file = "ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948"}, - {file = "ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a"}, - {file = "ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2"}, + {file = "ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7"}, + {file = "ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604"}, + {file = "ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255"}, + {file = "ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade"}, + {file = "ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff"}, + {file = "ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80"}, + {file = "ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07"}, + {file = "ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7"}, + {file = "ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3"}, + {file = "ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454"}, + {file = "ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b"}, + {file = "ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d"}, + {file = "ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0"}, + {file = "ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d"}, + {file = "ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e"}, + {file = "ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c"}, + {file = "ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21"}, + {file = "ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc"}, ] [[package]] @@ -1952,15 +1999,15 @@ files = [ [[package]] name = "tencentcloud-sdk-python-common" -version = "3.1.160" +version = "3.1.163" summary = "Tencent Cloud Common SDK for Python" groups = ["default"] dependencies = [ "requests>=2.16.0", ] files = [ - {file = "tencentcloud_sdk_python_common-3.1.160-py2.py3-none-any.whl", hash = "sha256:38993ee9468159d7fe2b139bc55a59455c67b97823206ff77d91762dd06f392e"}, - {file = "tencentcloud_sdk_python_common-3.1.160.tar.gz", hash = "sha256:a5fdd420f12df77e1a65c4fe3ae0a2b58840dce697557f13d3f17f58be2f7dec"}, + {file = "tencentcloud_sdk_python_common-3.1.163-py2.py3-none-any.whl", hash = "sha256:e57f94f870a4b5d3275f0407dadf857599da539647e1fd359a87232bcb414e79"}, + {file = "tencentcloud_sdk_python_common-3.1.163.tar.gz", hash = "sha256:9673a0758e2e9abe5353140c990c4a5cdfd5e440b7bf244a64d86b70b463a052"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 48815e9..c7aaef8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ authors = [{ name = "Lan_zhijiang", email = "lanzhijiang@foxmail.com" }] readme = "README.md" requires-python = ">=3.12,<3.13" dependencies = [ + "inkcre-extension-runtime-core-py @ https://github.com/InKCre/ext-reg/releases/download/runtime-core-py-v0.1.0/inkcre_extension_runtime_core_py-0.1.0-py3-none-any.whl", "authlib>=1.7.2,<2.0.0", "pydantic (>=2.11.7,<3.0.0)", "pydantic-settings (>=2.14.2,<3.0.0)", diff --git a/tests/extensions/runtime_support.py b/tests/extensions/runtime_support.py index cf1f5a1..50c29c5 100644 --- a/tests/extensions/runtime_support.py +++ b/tests/extensions/runtime_support.py @@ -7,7 +7,31 @@ from fastapi.testclient import TestClient from app.business.extension import ExtensionBase -from app.business.extension.runtime import ExtensionRuntimeRecord + + +@dataclass +class _TestActiveModel: + name: str + config: dict[str, typing.Any] + state: dict[str, typing.Any] + + def update_config(self, value): + self.config = dict(value) + return self + + def update_config_schema(self, _schema): + return self + + def read_state(self): + return dict(self.state) + + def mutate_state(self, transform): + self.state = transform(dict(self.state)) + return dict(self.state) + + def mutate_config_and_state(self, transform): + self.config, self.state = transform(dict(self.config), dict(self.state)) + return dict(self.config), dict(self.state) @dataclass @@ -18,6 +42,7 @@ class PublishedExtension: def unpublish(self) -> None: self.extension.unpublish() + self.extension.unbind() self.extension.release_runtime() @@ -32,39 +57,17 @@ def publish_extension( runtime_config = dict(config or {}) runtime_state: dict[str, typing.Any] = {} - def persist_config(value: dict[str, typing.Any]) -> None: - runtime_config.clear() - runtime_config.update(value) - - def mutate_state(transform): - next_state = transform(dict(runtime_state)) - runtime_state.clear() - runtime_state.update(next_state) - return dict(runtime_state) - - def mutate_config_and_state(transform): - next_config, next_state = transform(dict(runtime_config), dict(runtime_state)) - runtime_config.clear() - runtime_config.update(next_config) - runtime_state.clear() - runtime_state.update(next_state) - return dict(runtime_config), dict(runtime_state) - extension.unpublish() + extension.unbind() extension.release_runtime() - extension.on_start( - runtime_app, - ExtensionRuntimeRecord( - extension_id=extension.__extid__, - config=dict(runtime_config), - read_config=lambda: dict(runtime_config), - persist_config=persist_config, - read_state=lambda: dict(runtime_state), - mutate_state=mutate_state, - mutate_config_and_state=mutate_config_and_state, - persist_config_schema=lambda _schema: None, - ), + extension.bind( + _TestActiveModel( + name=f"inkcre/{extension.__extid__}", + config=runtime_config, + state=runtime_state, + ) ) + extension.on_start(runtime_app) return PublishedExtension( runtime_app, extension, From 9dbd838ed069800c33a201b11541b988b230b35e Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 24 Aug 2026 11:13:04 +0800 Subject: [PATCH 09/15] fix(extension): preserve setup migration history - restore the delivered setup revision bytes and integrity digest - converge setup and current main through an append-only merge revision - refresh the compatibility config attribute after Runtime mutations --- app/business/extension/main.py | 12 +++++++++ deploy/profiles/production.json | 2 +- migrations/revision-integrity.json | 3 ++- .../c6d7e8f9a0b1_add_extension_setup_state.py | 4 +-- ...d4e6f8a0b2c3_merge_extension_setup_main.py | 25 +++++++++++++++++++ 5 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 migrations/versions/d4e6f8a0b2c3_merge_extension_setup_main.py diff --git a/app/business/extension/main.py b/app/business/extension/main.py index 0d5b433..86bbcb9 100644 --- a/app/business/extension/main.py +++ b/app/business/extension/main.py @@ -77,6 +77,18 @@ def on_start(cls, app: typing.Any) -> None: super().on_start(app) cls.config = cls.get_config() + @classmethod + def update_config(cls, value: typing.Any) -> typing.Any: + config = super().update_config(value) + cls.config = config + return config + + @classmethod + def mutate_config_and_state(cls, transform: typing.Any) -> tuple[typing.Any, typing.Any]: + config, state = super().mutate_config_and_state(transform) + cls.config = config + return config, state + @classmethod def validate_config(cls, config: dict[str, typing.Any]) -> typing.Any: return cls.__configcls__.model_validate(config) diff --git a/deploy/profiles/production.json b/deploy/profiles/production.json index 6a8ec38..20a7a42 100644 --- a/deploy/profiles/production.json +++ b/deploy/profiles/production.json @@ -3,7 +3,7 @@ "environment": "production", "database_contract": { "revision": "peer-extension-setup-v1", - "migration_head": "c6d7e8f9a0b1", + "migration_head": "d4e6f8a0b2c3", "protocol_schema": "inkcre" }, "peer": { diff --git a/migrations/revision-integrity.json b/migrations/revision-integrity.json index 71b908b..e762d84 100644 --- a/migrations/revision-integrity.json +++ b/migrations/revision-integrity.json @@ -14,9 +14,10 @@ "b9c0d1e2f3a4_add_agent_definitions.py": "59529e759610193da3427c5b18e0c9be25e46e5e2419a3237bcf9ef88c263fa3", "c0d1e2f3a4b5_adopt_peer_capability_delegation.py": "7474fcda0b55fe2ce2047f761579459385aac9f43e7359ced881b8eb1fc6c265", "c4e8a7b6d5f0_converge_production_schema.py": "fb5687ad523c64297fe101d109918af2501bec26219de78353944a762eadfe4e", - "c6d7e8f9a0b1_add_extension_setup_state.py": "d55bbb8eb18d4ffac58d2813a7a67f33790640a8d3a24b6c30ddcd825cc36b41", + "c6d7e8f9a0b1_add_extension_setup_state.py": "5aab0d1dda6932fbac53f5a6adf4b504f994e8c860833b64fc21fb9cb8d8fc3e", "c9d2e3f4a5b6_move_trigger_helper_internal.py": "6fab826c66cef1b16540142ae3d17ff953dc2ca49c79a4cffea95d4520701e6e", "d0e3f4a5b6c7_add_octet_stream_handler.py": "0d7127d340b878243d894e27f8b97d5982aa01c757703528d3982eb89ae9219e", + "d4e6f8a0b2c3_merge_extension_setup_main.py": "2073996504ae5d5cb5d8cc0d468d3c9e93870a44bd5f454986662c22da48fba8", "d9f4e2a1b7c3_adopt_peer_database_protocol.py": "9a163533b5a0619e51bfb94ba7ad0c5c3d168fba5808b08f67531fd8f7e5f263", "e1f4a5b6c7d8_migrate_memos_attachment_v2.py": "5f3691149e06b28378f79eed7eaacd1c415ee6c92fb83b3b5745a429346d5e01", "e5a01f9e69ef_init.py": "a10de6c0818abc593951a34e51a974ffcd999f60ba5afc441f87ac6cef8d869e", diff --git a/migrations/versions/c6d7e8f9a0b1_add_extension_setup_state.py b/migrations/versions/c6d7e8f9a0b1_add_extension_setup_state.py index 2cdfb01..ce13340 100644 --- a/migrations/versions/c6d7e8f9a0b1_add_extension_setup_state.py +++ b/migrations/versions/c6d7e8f9a0b1_add_extension_setup_state.py @@ -1,7 +1,7 @@ """Add deployment-wide Extension setup state. Revision ID: c6d7e8f9a0b1 -Revises: 50b2c08dd267 +Revises: 3f7a9c2d5e1b Create Date: 2026-08-16 """ @@ -19,7 +19,7 @@ revision: str = "c6d7e8f9a0b1" -down_revision: str | Sequence[str] | None = "50b2c08dd267" +down_revision: str | Sequence[str] | None = "3f7a9c2d5e1b" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None diff --git a/migrations/versions/d4e6f8a0b2c3_merge_extension_setup_main.py b/migrations/versions/d4e6f8a0b2c3_merge_extension_setup_main.py new file mode 100644 index 0000000..d711b8b --- /dev/null +++ b/migrations/versions/d4e6f8a0b2c3_merge_extension_setup_main.py @@ -0,0 +1,25 @@ +"""Merge Extension setup and current main migration histories. + +Revision ID: d4e6f8a0b2c3 +Revises: 50b2c08dd267, c6d7e8f9a0b1 +Create Date: 2026-08-24 +""" + +from collections.abc import Sequence + + +revision: str = "d4e6f8a0b2c3" +down_revision: str | Sequence[str] | None = ( + "50b2c08dd267", + "c6d7e8f9a0b1", +) +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Converge the two already-complete histories without another data operation.""" + + +def downgrade() -> None: + """Remove only the merge marker while retaining both parent revisions.""" From 0deacf1bedff46bc2d3676f91a161a22faa0cd4f Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 24 Aug 2026 11:40:29 +0800 Subject: [PATCH 10/15] fix(preview): consume static Registry route support - upgrade the frozen Extension Toolkit to 0.2.1\n- rebuild the PDM lock from the independent release --- pdm.lock | 31 +++++++++++++++++++++++-------- pyproject.toml | 2 +- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/pdm.lock b/pdm.lock index a27f867..93ea857 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev", "extension-preview", "extension-publisher"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:18a7f7dfd1c8866e76a62b38d2850198faa8df8af446f47b7046c838c650b7a8" +content_hash = "sha256:0e7a3e5db7a0654f7f67b0b114f3f8bf166f8417dd011ccda93f5913181e72f0" [[metadata.targets]] requires_python = ">=3.12,<3.13" @@ -803,9 +803,9 @@ files = [ [[package]] name = "inkcre-extension-toolkit" -version = "0.1.0" +version = "0.2.1" requires_python = "<3.14,>=3.12" -url = "https://github.com/InKCre/ext-reg/releases/download/toolkit-v0.1.0/inkcre_extension_toolkit-0.1.0-py3-none-any.whl" +url = "https://github.com/InKCre/ext-reg/releases/download/toolkit-v0.2.1/inkcre_extension_toolkit-0.2.1-py3-none-any.whl" summary = "Developer and delivery tooling for InKCre Extensions" groups = ["extension-preview"] dependencies = [ @@ -814,24 +814,25 @@ dependencies = [ "semantic-version<3,>=2.10", ] files = [ - {file = "inkcre_extension_toolkit-0.1.0-py3-none-any.whl", hash = "sha256:7ab1de5160d0bafbdf9f99b6b2a2b156a406b6f6afe14dc0342073620299bd64"}, + {file = "inkcre_extension_toolkit-0.2.1-py3-none-any.whl", hash = "sha256:fffaad86db43d998731bfee69e992fa02d18ea2b428fdd00bda5ed6e311bf3de"}, ] [[package]] name = "inkcre-extension-toolkit" -version = "0.1.0" +version = "0.2.1" extras = ["cli"] requires_python = "<3.14,>=3.12" -url = "https://github.com/InKCre/ext-reg/releases/download/toolkit-v0.1.0/inkcre_extension_toolkit-0.1.0-py3-none-any.whl" +url = "https://github.com/InKCre/ext-reg/releases/download/toolkit-v0.2.1/inkcre_extension_toolkit-0.2.1-py3-none-any.whl" summary = "Developer and delivery tooling for InKCre Extensions" groups = ["extension-preview"] dependencies = [ "httpx<0.29,>=0.28", - "inkcre-extension-toolkit @ https://github.com/InKCre/ext-reg/releases/download/toolkit-v0.1.0/inkcre_extension_toolkit-0.1.0-py3-none-any.whl", + "inkcre-extension-toolkit @ https://github.com/InKCre/ext-reg/releases/download/toolkit-v0.2.1/inkcre_extension_toolkit-0.2.1-py3-none-any.whl", "typer<0.22,>=0.21", + "wheel<0.48,>=0.46", ] files = [ - {file = "inkcre_extension_toolkit-0.1.0-py3-none-any.whl", hash = "sha256:7ab1de5160d0bafbdf9f99b6b2a2b156a406b6f6afe14dc0342073620299bd64"}, + {file = "inkcre_extension_toolkit-0.2.1-py3-none-any.whl", hash = "sha256:fffaad86db43d998731bfee69e992fa02d18ea2b428fdd00bda5ed6e311bf3de"}, ] [[package]] @@ -2242,6 +2243,20 @@ files = [ {file = "webvtt_py-0.5.1-py3-none-any.whl", hash = "sha256:9d517d286cfe7fc7825e9d4e2079647ce32f5678eb58e39ef544ffbb932610b7"}, ] +[[package]] +name = "wheel" +version = "0.47.0" +requires_python = ">=3.9" +summary = "Command line tool for manipulating wheel files" +groups = ["extension-preview"] +dependencies = [ + "packaging>=24.0", +] +files = [ + {file = "wheel-0.47.0-py3-none-any.whl", hash = "sha256:212281cab4dff978f6cedd499cd893e1f620791ca6ff7107cf270781e587eced"}, + {file = "wheel-0.47.0.tar.gz", hash = "sha256:cc72bd1009ba0cf63922e28f94d9d83b920aa2bb28f798a31d0691b02fa3c9b3"}, +] + [[package]] name = "yarl" version = "1.24.5" diff --git a/pyproject.toml b/pyproject.toml index c7aaef8..5b83c89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,7 @@ dependencies = [ [dependency-groups] extension-preview = [ "build>=1.4.0,<2.0.0", - "inkcre-extension-toolkit[cli] @ https://github.com/InKCre/ext-reg/releases/download/toolkit-v0.1.0/inkcre_extension_toolkit-0.1.0-py3-none-any.whl", + "inkcre-extension-toolkit[cli] @ https://github.com/InKCre/ext-reg/releases/download/toolkit-v0.2.1/inkcre_extension_toolkit-0.2.1-py3-none-any.whl", "setuptools>=80.0.0,<81.0.0", ] extension-publisher = [ From c7154588a969d70bebfdd764915e6a53b078dba1 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 24 Aug 2026 14:15:00 +0800 Subject: [PATCH 11/15] fix(twitter): restore setup release version - keep the unreleased OAuth refactor within Twitter 0.2.0\n- align the Core wheel with the Web Distribution and deployment installation --- .changes/twitter/0.2.0.md | 3 +++ .changes/twitter/0.2.1.md | 3 --- extensions/twitter/CHANGELOG.md | 8 +++----- extensions/twitter/pyproject.toml | 2 +- 4 files changed, 7 insertions(+), 9 deletions(-) delete mode 100644 .changes/twitter/0.2.1.md diff --git a/.changes/twitter/0.2.0.md b/.changes/twitter/0.2.0.md index 7c311f1..295dbea 100644 --- a/.changes/twitter/0.2.0.md +++ b/.changes/twitter/0.2.0.md @@ -1,3 +1,6 @@ ## 0.2.0 - 2026-08-17 ### Added * Added the guided OAuth and Bookmark Source setup wizard. + +### Changed +* Limited the Core setup protocol to OAuth and account operations; Bookmark Source scheduling now uses ordinary deployment resources. diff --git a/.changes/twitter/0.2.1.md b/.changes/twitter/0.2.1.md deleted file mode 100644 index 409b80d..0000000 --- a/.changes/twitter/0.2.1.md +++ /dev/null @@ -1,3 +0,0 @@ -## 0.2.1 - 2026-08-17 -### Changed -* Limited the Core setup protocol to OAuth and account operations; Bookmark Source scheduling now uses ordinary deployment resources. diff --git a/extensions/twitter/CHANGELOG.md b/extensions/twitter/CHANGELOG.md index e74abba..11680d3 100644 --- a/extensions/twitter/CHANGELOG.md +++ b/extensions/twitter/CHANGELOG.md @@ -3,15 +3,13 @@ This changelog records notable changes to this first-party Python Distribution association. It is generated by [Changie](https://changie.dev/). - -## 0.2.1 - 2026-08-17 -### Changed -* Limited the Core setup protocol to OAuth and account operations; Bookmark Source scheduling now uses ordinary deployment resources. - ## 0.2.0 - 2026-08-17 ### Added * Added the guided OAuth and Bookmark Source setup wizard. +### Changed +* Limited the Core setup protocol to OAuth and account operations; Bookmark Source scheduling now uses ordinary deployment resources. + ## 0.1.1 - 2026-08-17 ### Changed diff --git a/extensions/twitter/pyproject.toml b/extensions/twitter/pyproject.toml index 8c43f93..3e72116 100644 --- a/extensions/twitter/pyproject.toml +++ b/extensions/twitter/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "inkcre-ext-twitter" -version = "0.2.1" +version = "0.2.0" description = "Twitter extension for InKCre" authors = [{ name = "Lan_zhijiang", email = "lanzhijiang@foxmail.com" }] dependencies = [ From 3d3605c98a3d6a9131466c459dad70bca9199b08 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 24 Aug 2026 14:25:01 +0800 Subject: [PATCH 12/15] fix(twitter): match generated 0.2.0 changelog --- extensions/twitter/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/extensions/twitter/CHANGELOG.md b/extensions/twitter/CHANGELOG.md index 11680d3..67f7a1e 100644 --- a/extensions/twitter/CHANGELOG.md +++ b/extensions/twitter/CHANGELOG.md @@ -3,6 +3,7 @@ This changelog records notable changes to this first-party Python Distribution association. It is generated by [Changie](https://changie.dev/). + ## 0.2.0 - 2026-08-17 ### Added * Added the guided OAuth and Bookmark Source setup wizard. From ab18ceefc62db75e99cae42e3d5a06489ea2586f Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 24 Aug 2026 17:03:08 +0800 Subject: [PATCH 13/15] fix(extension): consume finalized Python distributions - update the Core Python Host Runtime to released 0.1.1 - finalize preview wheels through the Extension Developer Toolkit - keep the sibling Registry inventory limited to consumable distributions --- pdm.lock | 8 ++++---- pyproject.toml | 2 +- scripts/build_extension_preview.py | 12 ++++++++---- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/pdm.lock b/pdm.lock index 93ea857..8f8f505 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev", "extension-preview", "extension-publisher"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:0e7a3e5db7a0654f7f67b0b114f3f8bf166f8417dd011ccda93f5913181e72f0" +content_hash = "sha256:81275b4646d18bd62ccb1f7cac4848e178ed7ef33fcaf47a35aaa3bda691c1e0" [[metadata.targets]] requires_python = ">=3.12,<3.13" @@ -785,9 +785,9 @@ files = [ [[package]] name = "inkcre-extension-runtime-core-py" -version = "0.1.0" +version = "0.1.1" requires_python = "<3.14,>=3.12" -url = "https://github.com/InKCre/ext-reg/releases/download/runtime-core-py-v0.1.0/inkcre_extension_runtime_core_py-0.1.0-py3-none-any.whl" +url = "https://github.com/InKCre/ext-reg/releases/download/runtime-core-py-v0.1.1/inkcre_extension_runtime_core_py-0.1.1-py3-none-any.whl" summary = "InKCre Core Python Extension Host Runtime" groups = ["default"] dependencies = [ @@ -798,7 +798,7 @@ dependencies = [ "semantic-version<3,>=2.10", ] files = [ - {file = "inkcre_extension_runtime_core_py-0.1.0-py3-none-any.whl", hash = "sha256:a50f9e316132204810270f16d1600553fd37c9b78df6c39826b3afce857b1c55"}, + {file = "inkcre_extension_runtime_core_py-0.1.1-py3-none-any.whl", hash = "sha256:968eadadf79ab15fe793fb8077cc20fd6debd9b5467c427c71a97f267dd157a7"}, ] [[package]] diff --git a/pyproject.toml b/pyproject.toml index 5b83c89..d8bff4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ authors = [{ name = "Lan_zhijiang", email = "lanzhijiang@foxmail.com" }] readme = "README.md" requires-python = ">=3.12,<3.13" dependencies = [ - "inkcre-extension-runtime-core-py @ https://github.com/InKCre/ext-reg/releases/download/runtime-core-py-v0.1.0/inkcre_extension_runtime_core_py-0.1.0-py3-none-any.whl", + "inkcre-extension-runtime-core-py @ https://github.com/InKCre/ext-reg/releases/download/runtime-core-py-v0.1.1/inkcre_extension_runtime_core_py-0.1.1-py3-none-any.whl", "authlib>=1.7.2,<2.0.0", "pydantic (>=2.11.7,<3.0.0)", "pydantic-settings (>=2.14.2,<3.0.0)", diff --git a/scripts/build_extension_preview.py b/scripts/build_extension_preview.py index 2371e39..3640bd7 100644 --- a/scripts/build_extension_preview.py +++ b/scripts/build_extension_preview.py @@ -9,8 +9,8 @@ import subprocess import sys -from extension_distribution import read_project as read_distribution_project -from extension_distribution import verify_wheel +from inkcre_extension_toolkit.python_distribution import finalize_wheel + from extension_release import PROJECT_ROOT, discover_projects @@ -61,8 +61,12 @@ def build_preview_inputs(output_directory: Path) -> Path: output_directory.mkdir(parents=True) distributions: list[dict[str, str]] = [] for project in discover_projects(): - wheel = _build_wheel(project.directory, output_directory / "wheels" / project.key) - verify_wheel(read_distribution_project(project.directory), wheel) + raw_wheel = _build_wheel(project.directory, output_directory / "raw" / project.key) + wheel = finalize_wheel( + project.directory / "pyproject.toml", + raw_wheel, + output_directory / "wheels" / project.key, + ) distributions.append( { "kind": "python", From 4fd3dd0b97dbe10263939192b6beb37ee70a7c03 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Mon, 24 Aug 2026 17:05:40 +0800 Subject: [PATCH 14/15] fix(preview): finalize wheels through Toolkit CLI - keep preview tooling out of the ordinary Core development dependency surface - consume the published inkcre-ext command contract for Distribution finalization --- scripts/build_extension_preview.py | 39 +++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/scripts/build_extension_preview.py b/scripts/build_extension_preview.py index 3640bd7..0e10104 100644 --- a/scripts/build_extension_preview.py +++ b/scripts/build_extension_preview.py @@ -9,8 +9,6 @@ import subprocess import sys -from inkcre_extension_toolkit.python_distribution import finalize_wheel - from extension_release import PROJECT_ROOT, discover_projects @@ -52,6 +50,39 @@ def _build_wheel(project_directory: Path, output_directory: Path) -> Path: return wheels[0] +def _finalize_wheel(project_directory: Path, wheel: Path, output_directory: Path) -> Path: + result = subprocess.run( # noqa: S603 -- arguments are structured and never use a shell + [ + "inkcre-ext", + "python", + "wheel", + "finalize", + "--project", + str(project_directory / "pyproject.toml"), + "--wheel", + str(wheel), + "--output-dir", + str(output_directory), + ], + cwd=PROJECT_ROOT, + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip() + raise PreviewBuildError( + f"could not finalize {project_directory.name}: " + f"{detail or f'exit {result.returncode}'}" + ) + wheels = tuple(sorted(output_directory.glob("*.whl"))) + if len(wheels) != 1: + raise PreviewBuildError( + f"{project_directory.name} finalized {len(wheels)} wheels instead of exactly one" + ) + return wheels[0] + + def build_preview_inputs(output_directory: Path) -> Path: """Build the discovered producer set into a fresh explicit Python inventory.""" @@ -62,8 +93,8 @@ def build_preview_inputs(output_directory: Path) -> Path: distributions: list[dict[str, str]] = [] for project in discover_projects(): raw_wheel = _build_wheel(project.directory, output_directory / "raw" / project.key) - wheel = finalize_wheel( - project.directory / "pyproject.toml", + wheel = _finalize_wheel( + project.directory, raw_wheel, output_directory / "wheels" / project.key, ) From f6c5b1229aa2770e63ecdab5dcd5d085d0082909 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Tue, 25 Aug 2026 14:01:23 +0800 Subject: [PATCH 15/15] =?UTF-8?q?fix(twitter):=20=E6=94=B6=E6=95=9B=20setu?= =?UTF-8?q?p=20runtime=20=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 从 setup response 删除 deployment config 投影 - 通过 Changie 发布 Python Distribution 0.2.1 --- .changes/twitter/0.2.1.md | 3 +++ extensions/twitter/CHANGELOG.md | 4 ++++ extensions/twitter/pyproject.toml | 2 +- extensions/twitter/setup_flow.py | 6 ------ 4 files changed, 8 insertions(+), 7 deletions(-) create mode 100644 .changes/twitter/0.2.1.md diff --git a/.changes/twitter/0.2.1.md b/.changes/twitter/0.2.1.md new file mode 100644 index 0000000..e40c06c --- /dev/null +++ b/.changes/twitter/0.2.1.md @@ -0,0 +1,3 @@ +## 0.2.1 - 2026-08-25 +### Fixed +* Kept OAuth App credentials visible in setup and narrowed Core status to runtime-owned state. diff --git a/extensions/twitter/CHANGELOG.md b/extensions/twitter/CHANGELOG.md index 67f7a1e..4495fe6 100644 --- a/extensions/twitter/CHANGELOG.md +++ b/extensions/twitter/CHANGELOG.md @@ -4,6 +4,10 @@ This changelog records notable changes to this first-party Python Distribution association. It is generated by [Changie](https://changie.dev/). +## 0.2.1 - 2026-08-25 +### Fixed +* Kept OAuth App credentials visible in setup and narrowed Core status to runtime-owned state. + ## 0.2.0 - 2026-08-17 ### Added * Added the guided OAuth and Bookmark Source setup wizard. diff --git a/extensions/twitter/pyproject.toml b/extensions/twitter/pyproject.toml index 3e72116..8c43f93 100644 --- a/extensions/twitter/pyproject.toml +++ b/extensions/twitter/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "inkcre-ext-twitter" -version = "0.2.0" +version = "0.2.1" description = "Twitter extension for InKCre" authors = [{ name = "Lan_zhijiang", email = "lanzhijiang@foxmail.com" }] dependencies = [ diff --git a/extensions/twitter/setup_flow.py b/extensions/twitter/setup_flow.py index 0e0b053..e424504 100644 --- a/extensions/twitter/setup_flow.py +++ b/extensions/twitter/setup_flow.py @@ -97,10 +97,7 @@ class OAuthTransactionView(pydantic.BaseModel): class TwitterSetupStatus(pydantic.BaseModel): - backend: str callback_url: str - oauth_app_configured: bool - client_id: str | None = None connected: bool user_id: str | None = None handle: str | None = None @@ -265,10 +262,7 @@ def get_setup_status() -> TwitterSetupStatus: and account.app_fingerprint == _fingerprint(config) ) return TwitterSetupStatus( - backend=config.backend, callback_url=_redirect_uri(), - oauth_app_configured=configured, - client_id=config.client_id or None, connected=connected, user_id=account.user_id if connected and account is not None else None, handle=account.handle if connected and account is not None else None,