diff --git a/.changes/twitter/0.2.0.md b/.changes/twitter/0.2.0.md new file mode 100644 index 0000000..295dbea --- /dev/null +++ b/.changes/twitter/0.2.0.md @@ -0,0 +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/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/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 1cdd851..86bbcb9 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 ( @@ -30,6 +30,7 @@ DistributionModules, PipDistributionConsumer, ) +from .config import resolve_extension_registry_origin from .errors import ( ExtensionCompatibilityError, ExtensionHostError, @@ -41,18 +42,17 @@ from .release import ( PythonReleaseDescriptor, RegistryReleaseClient, + ReleaseState, ReleaseResolver, require_python_association, validate_coordinate, ) from .runtime import ( - ExtensionPublication, - ExtensionPublicationSnapshot, + PublicHTTPRoute, ExtensionRuntimeClaim, ExtensionRuntimeClaimConflictError, - ExtensionRuntimeRecord, ) -from .state import ExtensionState, ExtensionStateStore, SQLExtensionStateStore +from .state import ExtensionStore, InstalledExtension, SQLExtensionStore LOGGER = get_logger().getChild(__name__) @@ -63,154 +63,78 @@ 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.""" - -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], - **kwargs: typing.Any, - ) -> None: - cls.__extid__ = ext_id - cls.__configcls__ = config_cls # pyrefly: ignore[no-access] - cls.__configschema__ = config_cls.model_json_schema() - 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) - 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.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__) + def __class_getitem__(cls, _item: typing.Any) -> type[ExtensionBase]: + return cls @classmethod - def _init_resolvers(cls) -> None: ... + def on_start(cls, app: typing.Any) -> None: + super().on_start(app) + cls.config = cls.get_config() @classmethod - def load_decoders(cls) -> None: - """Publish persisted-content decoders without starting routes or Sources.""" - cls._init_resolvers() + def update_config(cls, value: typing.Any) -> typing.Any: + config = super().update_config(value) + cls.config = config + return config @classmethod - def _init_sources(cls) -> None: ... + 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 api_dependencies(cls) -> list[typing.Any]: - """Return root API dependencies; override with [] to compose product auth.""" - return [fastapi.Depends(require_peer_jwt)] + def validate_config(cls, config: dict[str, typing.Any]) -> typing.Any: + return cls.__configcls__.model_validate(config) - @classmethod - def peer_inbounds(cls) -> tuple[typing.Any, ...]: - """Return exact Peer inbounds published while this Extension is running.""" - return () - @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") - config = typing.cast(sqlmodel.SQLModel, getattr(cls, "config")) - record.persist_config(config.model_dump()) - LOGGER.info("Extension %s closed", cls.__extid__) +@dataclass +class _ActiveExtensionModel: + """Bind the Core store to the Runtime's rich active-record interface.""" - @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 + name: str + config: dict[str, typing.Any] + store: ExtensionStore + persist_schema: bool = True - @classmethod - def unpublish(cls) -> None: - publication = typing.cast( - ExtensionPublication | None, - cls.__dict__.get("__runtime_publication__"), - ) - if publication is not None: - publication.restore() + def _refresh(self) -> _ActiveExtensionModel: + self.config = self.store.read_config(self.name) + return self - @classmethod - def release_runtime(cls) -> None: - for attribute in ("__runtime_publication__", "__runtime_record__"): - if attribute in cls.__dict__: - delattr(cls, attribute) + def update_config(self, config: dict[str, typing.Any]) -> _ActiveExtensionModel: + self.store.update_config(self.name, config) + return self._refresh() - @classmethod - @abc.abstractmethod - def _register_apis(cls, router: fastapi.APIRouter) -> None: - """Register Extension-owned API endpoints.""" + 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 update_config(cls, new_config: dict[str, typing.Any] | ConfigTV) -> None: - if isinstance(new_config, dict): - validated = cls.__configcls__(**new_config) # pyrefly: ignore[missing-attribute] - else: - validated = new_config - setattr(cls, "config", validated) + def read_state(self) -> dict[str, typing.Any]: + return self.store.read_state(self.name) - @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] + def mutate_state( + 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 config_schema(cls) -> dict[str, typing.Any]: - """Return the Extension-owned schema projected to deployment state.""" - return dict(cls.__configschema__) + def mutate_config_and_state( + self, + 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]]: + result = self.store.mutate_config_and_state(self.name, transform) + self._refresh() + return result @dataclass @@ -230,24 +154,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 +178,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 +198,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 +207,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 +217,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,18 +230,19 @@ def _resolve( version: str, *, allow_yanked: bool, + release_client: ReleaseResolver, ): - release = self.release_client.get(name, version) - if release.state == "yanked" and allow_yanked: + release = release_client.get(name, version) + 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" ) 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 +252,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 +271,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 +281,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 +336,7 @@ async def _start( async def _start_acquired( self, app: fastapi.FastAPI, - state: ExtensionState, + state: InstalledExtension, association: PythonReleaseDescriptor, acquired: AcquiredDistribution, *, @@ -408,14 +352,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 stage_schema(schema: dict[str, typing.Any]) -> None: - schema_box["value"] = schema try: extension_class = typing.cast(type[ExtensionBase], modules.load(ExtensionBase)) @@ -423,33 +359,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), - persist_config=persist_config, - 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() @@ -471,6 +400,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() @@ -486,6 +416,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: @@ -501,7 +435,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 +478,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 +563,6 @@ async def close_running(self) -> None: "ExtensionDelegationError", "ExtensionHost", "ExtensionHostError", - "ExtensionState", + "InstalledExtension", + "PublicHTTPRoute", ] 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 edabb1e..34561ea 100644 --- a/app/business/extension/runtime.py +++ b/app/business/extension/runtime.py @@ -1,156 +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 - - -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] - persist_config: Callable[[dict[str, typing.Any]], None] - 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] - 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, - ) - 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/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/deploy/profiles/production.json b/deploy/profiles/production.json index 61f7bfe..20a7a42 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": "d4e6f8a0b2c3", "protocol_schema": "inkcre" }, "peer": { 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/CHANGELOG.md b/extensions/twitter/CHANGELOG.md index c1e6ac1..67f7a1e 100644 --- a/extensions/twitter/CHANGELOG.md +++ b/extensions/twitter/CHANGELOG.md @@ -4,6 +4,13 @@ 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. + +### 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/__init__.py b/extensions/twitter/__init__.py index 6bb098f..4bbd424 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,48 @@ 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_INBOUNDS + + return TWITTER_SETUP_INBOUNDS + + @classmethod + def public_http_routes(cls) -> tuple[PublicHTTPRoute, ...]: + return (PublicHTTPRoute(method="GET", path="/auth/callback"),) + + @classmethod + def update_config( + cls, + 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(value) if isinstance(value, dict) else value ) + + 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/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..0e0b053 --- /dev/null +++ b/extensions/twitter/setup_flow.py @@ -0,0 +1,677 @@ +"""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 +from app.business.peer import PeerHTTPInbound, PeerManager +from app.middleware import require_peer_jwt + + +if typing.TYPE_CHECKING: + from . import TwitterExtensionConfig + + +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 +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 + + +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) + + +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 + + +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 OAuthTransactionRequest(pydantic.BaseModel): + transaction_id: str + + +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 _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 + + 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 get_setup_status() -> TwitterSetupStatus: + config = _config() + state = _reconcile_oauth_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), + ) + + +def save_oauth_app(body: SaveOAuthAppRequest) -> 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" + ) + + 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, + ) + 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 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 current-user lookup returned an incomplete response" + ) + return dict(token), user_id, handle + except (TypeError, ValueError): + raise TwitterProviderError( + "Twitter current-user lookup returned an invalid 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: + 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 _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.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 disconnect_account() + 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..e762d84 100644 --- a/migrations/revision-integrity.json +++ b/migrations/revision-integrity.json @@ -14,8 +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": "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 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/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.""" diff --git a/pdm.lock b/pdm.lock index a3aead8..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:8d0bbdbea4f86221f6bec2534ef497777b44fd422bf75bcbeb5597a30b40aa72" +content_hash = "sha256:81275b4646d18bd62ccb1f7cac4848e178ed7ef33fcaf47a35aaa3bda691c1e0" [[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,11 +783,29 @@ files = [ {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, ] +[[package]] +name = "inkcre-extension-runtime-core-py" +version = "0.1.1" +requires_python = "<3.14,>=3.12" +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 = [ + "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.1-py3-none-any.whl", hash = "sha256:968eadadf79ab15fe793fb8077cc20fd6debd9b5467c427c71a97f267dd157a7"}, +] + [[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 = [ @@ -781,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]] @@ -825,6 +859,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 +950,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 +991,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 +1473,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 +1485,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 +1578,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 +1586,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 +1843,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 +2000,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]] @@ -2195,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 d9ad111..d8bff4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,13 @@ [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 = [ + "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)", "fastapi (>=0.139.2,<0.140.0)", @@ -53,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 = [ diff --git a/scripts/build_extension_preview.py b/scripts/build_extension_preview.py index 2371e39..0e10104 100644 --- a/scripts/build_extension_preview.py +++ b/scripts/build_extension_preview.py @@ -9,8 +9,6 @@ import subprocess import sys -from extension_distribution import read_project as read_distribution_project -from extension_distribution import verify_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.""" @@ -61,8 +92,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, + raw_wheel, + output_directory / "wheels" / project.key, + ) distributions.append( { "kind": "python", 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( diff --git a/tests/extensions/runtime_support.py b/tests/extensions/runtime_support.py index 5aff70e..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() @@ -29,17 +54,20 @@ 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] = {} + extension.unpublish() + extension.unbind() extension.release_runtime() - extension.on_start( - runtime_app, - ExtensionRuntimeRecord( - extension_id=extension.__extid__, - config=config or {}, - persist_config=lambda _config: None, - 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, diff --git a/tests/migrations/test_extension_peer_enabled_rpc.py b/tests/migrations/test_extension_peer_enabled_rpc.py index 1ca2869..ed2f66c 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,74 @@ 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, + }, + }, + ) + 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={"enabled": False})) + assert rebound.enabled is False + + +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, + } + )