diff --git a/backend/druks/core/routes.py b/backend/druks/core/routes.py index f7b0dcea..a167ed22 100644 --- a/backend/druks/core/routes.py +++ b/backend/druks/core/routes.py @@ -6,7 +6,7 @@ from fastapi.responses import HTMLResponse from druks.core.apis.github import GITHUB -from druks.core.services import GitHubApp +from druks.core.services import Github from druks.core.templates import render_page from druks.services.models import ServiceIdentity @@ -33,7 +33,7 @@ async def create_github_app(request: Request) -> HTMLResponse: f"https://{settings.urls.webhook_host}" if settings.urls.webhook_host else endpoint ) manifest = { - **GitHubApp.manifest, + **Github.manifest, "url": endpoint, "redirect_url": f"{endpoint}/api/core/github/manifest/callback", "hook_attributes": {"url": f"{webhook_base}/_external/github/events/", "active": True}, diff --git a/backend/druks/core/services.py b/backend/druks/core/services.py index 6ad770b5..1cd41a3d 100644 --- a/backend/druks/core/services.py +++ b/backend/druks/core/services.py @@ -4,7 +4,7 @@ import httpx from pydantic import BaseModel, Field, SecretStr -from druks.core.apis.github import GITHUB, GitHubClient +from druks.core.apis.github import GitHubClient from druks.core.apis.linear import LINEAR_GRAPHQL_URL from druks.services import Service, ServiceConnectError from druks.settings import load_settings @@ -14,8 +14,7 @@ _VERIFY_TIMEOUT = 10.0 -class GitHubApp(Service): - name = GITHUB +class Github(Service): description = ( "The GitHub App druks acts as — it receives webhooks and writes branches, " "pull requests, and comments. Create it from here, or paste an existing " @@ -64,7 +63,6 @@ async def verify(cls, settings: Settings) -> dict[str, Any]: class Linear(Service): - name = "linear" description = ( "The Linear identity druks reads and updates tickets as; its webhook " "secret verifies inbound deliveries." @@ -94,7 +92,6 @@ async def verify(cls, settings: Settings) -> dict[str, Any]: class Jira(Service): - name = "jira" description = ( "The Jira Cloud identity druks reads and updates tickets as; its webhook " "secret authenticates Automation deliveries." diff --git a/backend/druks/core/templates/service_oauth_callback.html b/backend/druks/core/templates/service_oauth_callback.html index f589ec95..bf6288d2 100644 --- a/backend/druks/core/templates/service_oauth_callback.html +++ b/backend/druks/core/templates/service_oauth_callback.html @@ -1,9 +1,9 @@ {% extends "page.html" %} {% block content %} -
Connected {{ name }}. +
Connected {{ slug }}. You can close this tab and return to druks.
{% endblock %} diff --git a/backend/druks/doctor.py b/backend/druks/doctor.py index d8b379e8..1f65f53e 100644 --- a/backend/druks/doctor.py +++ b/backend/druks/doctor.py @@ -55,9 +55,9 @@ def check_service_identities(settings: Settings) -> list[CheckResult]: db_session.registry.set(session) results: list[CheckResult] = [] for service in services.all(): - name = f"{service.name}_identity" + name = f"{service.slug}_identity" try: - row = ServiceIdentity.get(service.name) + row = ServiceIdentity.get(service.slug) except ServiceNotConnectedError: results.append( CheckResult( @@ -337,8 +337,13 @@ def _defined_capability(module: ModuleType) -> tuple[str, str] | None: and value.__module__ == name ): return "webhooks", f"{value.__module__}.{value.__qualname__}" - if isinstance(value, type) and issubclass(value, Service) and value.__module__ == name: - return "services", value.name + if ( + isinstance(value, type) + and issubclass(value, Service) + and not value.abstract + and value.__module__ == name + ): + return "services", value.slug if isinstance(value, Agent) and value.module == name: return "agents", value.name return diff --git a/backend/druks/extensions/registry.py b/backend/druks/extensions/registry.py index 1489aa62..a8afa439 100644 --- a/backend/druks/extensions/registry.py +++ b/backend/druks/extensions/registry.py @@ -62,7 +62,7 @@ def autodiscover(package: str) -> list[ModuleType]: webhooks = Registry("webhooks", key=lambda cls: f"{cls.__module__}.{cls.__qualname__}") -services = Registry("services", key=lambda cls: cls.name) +services = Registry("services", key=lambda cls: cls.slug) workflows = Registry("workflows", key=lambda cls: cls.kind) agents = Registry("agents", key=lambda agent: agent.id) browser_sessions = Registry("browser_sessions", key=lambda session: session.name) diff --git a/backend/druks/services/base.py b/backend/druks/services/base.py index dcd49031..da5935b1 100644 --- a/backend/druks/services/base.py +++ b/backend/druks/services/base.py @@ -1,3 +1,4 @@ +import re from typing import Any, ClassVar from pydantic import BaseModel, ValidationError @@ -11,6 +12,9 @@ from .models import OauthConnection, ServiceIdentity from .oauth import OauthClient, fetch_identity +# GoogleCalendar -> google_calendar, HTTPServer -> http_server. +_CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])") + class Connection: """One signed-in provider account, reached through the extension's @@ -47,7 +51,7 @@ async def get_access_token(self, scopes: tuple[str, ...] = (), cached: bool = Tr ) async def disconnect(self) -> None: - await OauthClient(provider=self.service.name).disconnect(self.row, reason="user") + await OauthClient(provider=self.service.slug).disconnect(self.row, reason="user") class ScopedService: @@ -71,35 +75,36 @@ def label(self) -> str: def list_for_account(self, account_id: str) -> list[Connection]: return [ Connection(self.service, row) - for row in OauthConnection.list_for_account(self.service.name, account_id) + for row in OauthConnection.list_for_account(self.service.slug, account_id) ] def get(self, connection_id: str) -> Connection | None: row = OauthConnection.get(connection_id) - if row and row.provider == self.service.name and not row.revoked_at: + if row and row.provider == self.service.slug and not row.revoked_at: return Connection(self.service, row) class Service: """The appliance's own identity at an external provider — one per service, - declared by the code that consumes it. Subclass in a ``services`` module, - set ``name`` and an inner ``Settings`` model; the platform renders the - connect card, verifies and stores the paste, and reports doctor state, all - from the declaration. Read back through the same class: + declared by the code that consumes it. Subclass in a ``services`` module + with an inner ``Settings`` model; the platform renders the connect card, + verifies and stores the paste, and reports doctor state, all from the + declaration. Read back through the same class: ``Gmail.get().secrets["client_secret"]``. Used as a class, never instantiated — the same install-singleton shape as ``Extension``. """ - name: ClassVar[str] - # The connect card's heading — the platform derives it from ``name``. + # Keys the service_identities row and the connect wire. Druks derives it + # from the class name. Set it only to keep the key after a class rename. + slug: ClassVar[str] + # The connect card's heading. Druks derives it from the slug. title: ClassVar[str] description: ClassVar[str] = "" # Whether doctor fails when this service is not connected. required: ClassVar[bool] = True - # True marks a shared provider base — subclasses inherit its declarations - # and register; the base itself never does. + # True marks a shared provider base. It never registers; its subclasses do. abstract: ClassVar[bool] = False settings_model: ClassVar[type[BaseModel]] # Set both endpoints when the registered app is an OAuth client; @@ -126,25 +131,31 @@ class Service: def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) + if "name" in cls.__dict__: + raise TypeError( + f"{cls.__name__} declares a `name`. A service keys by `slug`, " + "derived from the class name. Drop `name` or set `slug`." + ) + if "title" in cls.__dict__: + raise TypeError( + f"{cls.__name__} declares a `title`. Druks derives the card " + "heading from the slug. Drop `title`." + ) if cls.__dict__.get("abstract"): - if "name" in cls.__dict__: + if "slug" in cls.__dict__: raise TypeError( - f"{cls.__name__} sets both `abstract` and `name` — an abstract " + f"{cls.__name__} sets both `abstract` and `slug`. An abstract " "base never registers. Drop one." ) return - name = getattr(cls, "name", None) - if not name: - raise TypeError(f"{cls.__name__} must set a `name`") - if "title" in cls.__dict__: - raise TypeError( - f"{cls.__name__} declares a `title` — the card heading derives " - "from `name`. Drop it." - ) - if not NAME_RE.match(name): + if "slug" in cls.__dict__: + slug = cls.__dict__["slug"] + else: + slug = _CAMEL_BOUNDARY.sub("_", cls.__name__).lower() + if not NAME_RE.match(slug): raise TypeError( - f"service name {name!r} must match {NAME_RE.pattern!r} — it keys the " - "service_identities row and the connect wire" + f"service slug {slug!r} must match {NAME_RE.pattern!r}. It keys the " + "service_identities row and the connect wire." ) declared = getattr(cls, "Settings", None) if not isinstance(declared, type) or not issubclass(declared, BaseModel): @@ -156,7 +167,10 @@ def __init_subclass__(cls, **kwargs: Any) -> None: f"{cls.__name__}.Settings must declare client_id and client_secret " "fields — get_oauth_client() reads the OAuth client from them" ) - cls.title = name.replace("_", " ").title() + # A registered service is concrete even under an abstract base. + cls.abstract = False + cls.slug = slug + cls.title = slug.replace("_", " ").title() cls.settings_model = declared services.register(cls) @@ -185,7 +199,7 @@ def connect_fields(cls) -> list[dict[str, Any]]: @classmethod def get(cls) -> ServiceIdentity: - return ServiceIdentity.get(cls.name) + return ServiceIdentity.get(cls.slug) @classmethod def with_scopes(cls, *scopes: str) -> ScopedService: @@ -222,13 +236,13 @@ async def get_identity(cls, access_token: str) -> dict[str, Any]: @classmethod def get_oauth_client(cls) -> OauthClient: """The connected identity as a configured ``OauthClient``, keyed by - the service name. Raises ``ServiceNotConnectedError`` until the + the service slug. Raises ``ServiceNotConnectedError`` until the operator connects the service.""" if not cls.token_endpoint: raise TypeError(f"{cls.__name__} declares no OAuth endpoints") connected = cls.get() return OauthClient( - provider=cls.name, + provider=cls.slug, authorization_endpoint=cls.authorization_endpoint, token_endpoint=cls.token_endpoint, client_id=connected.identity["client_id"], @@ -240,7 +254,7 @@ def get_oauth_client(cls) -> OauthClient: @classmethod def is_connected(cls) -> bool: try: - ServiceIdentity.get(cls.name) + ServiceIdentity.get(cls.slug) except ServiceNotConnectedError: return False return True @@ -275,6 +289,6 @@ async def connect(cls, payload: dict[str, Any]) -> ServiceIdentity: if all(str(value).strip() for value in (*identity.values(), *secrets.values())): proven = await cls.verify(settings) return ServiceIdentity.connect( - cls.name, identity={**identity, **proven}, secrets=secrets + cls.slug, identity={**identity, **proven}, secrets=secrets ) raise ServiceConnectError("Every field is required.") diff --git a/backend/druks/services/routes.py b/backend/druks/services/routes.py index 3c0405bc..1afeae97 100644 --- a/backend/druks/services/routes.py +++ b/backend/druks/services/routes.py @@ -24,13 +24,13 @@ async def list_services() -> list[ServiceResponse]: entries = [] for service in services.all(): try: - row = ServiceIdentity.get(service.name) + row = ServiceIdentity.get(service.slug) except ServiceNotConnectedError: row = None connections = [] if service.token_endpoint: # The detail shows revoked connections as history beside the live. - connections = OauthConnection.list_for_provider(service.name, include_revoked=True) + connections = OauthConnection.list_for_provider(service.slug, include_revoked=True) entries.append(ServiceResponse.from_row(service, row, connections)) return entries @@ -38,15 +38,15 @@ async def list_services() -> list[ServiceResponse]: # Session identity only, like the settings PATCH: the appliance's own # credentials are never writable with an agent PAT. @router.post( - "/{name}", + "/{slug}", response_model=ServiceResponse, response_model_by_alias=True, dependencies=[Depends(current_session_account)], ) -async def connect_service(name: str, payload: dict[str, str]) -> ServiceResponse: - service = services.get(name) +async def connect_service(slug: str, payload: dict[str, str]) -> ServiceResponse: + service = services.get(slug) if not service: - raise HTTPException(status_code=404, detail=f"No service {name!r}.") + raise HTTPException(status_code=404, detail=f"No service {slug!r}.") try: row = await service.connect(payload) except ServiceConnectError as error: @@ -54,36 +54,36 @@ async def connect_service(name: str, payload: dict[str, str]) -> ServiceResponse if service.token_endpoint: # A replaced client can never refresh the old client's connections — # revoke every live one; the consents stay on record. - client = OauthClient(provider=name) - for connection in OauthConnection.list_for_provider(name): + client = OauthClient(provider=slug) + for connection in OauthConnection.list_for_provider(slug): await client.disconnect(connection, reason="client_replaced") await publish( "oauth.disconnected", - provider=name, + provider=slug, connection_id=connection.id, account_id=connection.account_id, ) return ServiceResponse.from_row(service, row) -def _get_oauth_service(name: str): - service = services.get(name) +def _get_oauth_service(slug: str): + service = services.get(slug) if not service or not service.token_endpoint: - raise HTTPException(status_code=404, detail=f"No OAuth service {name!r}.") + raise HTTPException(status_code=404, detail=f"No OAuth service {slug!r}.") return service -@oauth_router.get("/{name}/connect", dependencies=[Depends(current_session_account)]) +@oauth_router.get("/{slug}/connect", dependencies=[Depends(current_session_account)]) async def connect_oauth_service( - name: str, request: Request, connection: str = "", next: str = "" + slug: str, request: Request, connection: str = "", next: str = "" ) -> RedirectResponse: - service = _get_oauth_service(name) + service = _get_oauth_service(slug) account_id = current_account_id.get() if connection: row = OauthConnection.get(connection) - if not row or row.provider != name: + if not row or row.provider != slug: raise HTTPException( - status_code=404, detail=f"No connection {connection!r} on {name!r}." + status_code=404, detail=f"No connection {connection!r} on {slug!r}." ) if next and (not next.startswith("/") or next.startswith(("//", "/\\"))): # A bare same-origin path only — anything host-shaped is an open redirect. @@ -162,7 +162,7 @@ async def oauth_callback(state: str = "", code: str = "", error: str = "") -> Re ) if pending["next"]: return RedirectResponse(pending["next"]) - return render_page("service_oauth_callback.html", name=provider) + return render_page("service_oauth_callback.html", slug=provider) @oauth_router.get("/connections", dependencies=[Depends(current_session_account)]) diff --git a/backend/druks/services/schemas.py b/backend/druks/services/schemas.py index db662f08..f1887a97 100644 --- a/backend/druks/services/schemas.py +++ b/backend/druks/services/schemas.py @@ -34,7 +34,7 @@ class ConnectionResponse(BaseResponse): class ServiceResponse(BaseResponse): # Connection state and identity facts only — never a stored secret. - name: str + slug: str title: str description: str required: bool @@ -55,7 +55,7 @@ def from_row( connections: "list[OauthConnection] | None" = None, ) -> "ServiceResponse": return cls( - name=service.name, + slug=service.slug, title=service.title, description=service.description, required=service.required, diff --git a/backend/tests/test_auth_boundary.py b/backend/tests/test_auth_boundary.py index 34aec68c..f5bdd276 100644 --- a/backend/tests/test_auth_boundary.py +++ b/backend/tests/test_auth_boundary.py @@ -29,8 +29,8 @@ ("DELETE", "/api/auth/personal-tokens/{pat_id}"), ("DELETE", "/api/harnesses/{name}/connection"), ("PATCH", "/api/settings/extensions"), - ("POST", "/api/services/{name}"), - ("GET", "/api/oauth/{name}/connect"), + ("POST", "/api/services/{slug}"), + ("GET", "/api/oauth/{slug}/connect"), ("GET", "/api/oauth/connections"), ("DELETE", "/api/oauth/connections/{connection_id}"), } @@ -39,8 +39,8 @@ # the route-level session dependency is the stricter of the two. DUAL_GATED_API_PATHS = { "/api/settings/extensions", - "/api/services/{name}", - "/api/oauth/{name}/connect", + "/api/services/{slug}", + "/api/oauth/{slug}/connect", "/api/oauth/connections", "/api/oauth/connections/{connection_id}", } diff --git a/backend/tests/test_extension_appless_load.py b/backend/tests/test_extension_appless_load.py index e9e9d090..b9799a89 100644 --- a/backend/tests/test_extension_appless_load.py +++ b/backend/tests/test_extension_appless_load.py @@ -84,7 +84,6 @@ async def run(self, widget: str) -> None: class Probemail(Service): - name = "probemail" class Settings(BaseModel): account: str = Field(title="Account") diff --git a/backend/tests/test_services.py b/backend/tests/test_services.py index a45bd06a..60ba3270 100644 --- a/backend/tests/test_services.py +++ b/backend/tests/test_services.py @@ -35,7 +35,7 @@ def _connect( def _github_entry(client: TestClient) -> dict: entries = client.get("/api/services").json() - return next(entry for entry in entries if entry["name"] == "github") + return next(entry for entry in entries if entry["slug"] == "github") # --- The row ---------------------------------------------------------------- @@ -393,7 +393,6 @@ def test_get_oauth_client_reads_the_connected_identity(declared_services, druks_ from pydantic import BaseModel, SecretStr class Acme(Service): - name = "acme" authorization_endpoint = "https://acme.test/authorize" token_endpoint = "https://acme.test/token" basic_auth = True @@ -425,7 +424,6 @@ def test_oauth_service_declarations_fail_loudly(declared_services): with pytest.raises(TypeError, match="client_id and client_secret"): class Keyless(Service): - name = "keyless" authorization_endpoint = "https://acme.test/authorize" token_endpoint = "https://acme.test/token" @@ -435,16 +433,21 @@ class Settings(BaseModel): with pytest.raises(TypeError, match="both OAuth endpoints"): class HalfDeclared(Service): - name = "half_declared" token_endpoint = "https://acme.test/token" class Settings(BaseModel): client_id: str client_secret: SecretStr - class Plain(Service): - name = "plain_service" + with pytest.raises(TypeError, match="keys by"): + + class Named(Service): + name = "named" + class Settings(BaseModel): + api_key: SecretStr + + class Plain(Service): class Settings(BaseModel): api_key: SecretStr @@ -467,16 +470,17 @@ class Settings(BaseModel): client_secret: SecretStr class Mail(AcmeBase): - name = "acme_mail" + slug = "acme_mail" assert services.get("acme_mail") is Mail + assert Mail.abstract is False assert Mail.settings_model is AcmeBase.Settings with pytest.raises(TypeError, match="abstract"): - class Named(Service): + class Pinned(Service): abstract = True - name = "named_base" + slug = "pinned" def test_with_scopes_declares_the_union_and_reads_connections(declared_services, monkeypatch): @@ -484,7 +488,6 @@ def test_with_scopes_declares_the_union_and_reads_connections(declared_services, from pydantic import BaseModel, SecretStr class Acme(Service): - name = "acme" authorization_endpoint = "https://acme.test/authorize" token_endpoint = "https://acme.test/token" @@ -539,7 +542,6 @@ async def test_get_identity_without_a_declared_endpoint_is_empty(declared_servic from pydantic import BaseModel, SecretStr class Quiet(Service): - name = "quiet_provider" authorization_endpoint = "https://quiet.test/authorize" token_endpoint = "https://quiet.test/token" @@ -555,8 +557,6 @@ def test_with_scopes_requires_oauth_endpoints(declared_services): from pydantic import BaseModel, SecretStr class Plain(Service): - name = "plain_no_oauth" - class Settings(BaseModel): api_key: SecretStr @@ -573,7 +573,6 @@ def acme(declared_services, monkeypatch): from pydantic import BaseModel, SecretStr class Acme(Service): - name = "acme" authorization_endpoint = "https://acme.test/authorize" token_endpoint = "https://acme.test/token" identity_endpoint = "https://acme.test/whoami" @@ -763,11 +762,11 @@ def test_fresh_sign_in_with_matching_identity_resurrects_revoked_connection( from druks.testing import configure_app_for_test ServiceIdentity.connect( - keyed_acme.name, identity={"client_id": "id-1"}, secrets={"client_secret": "sec-1"} + keyed_acme.slug, identity={"client_id": "id-1"}, secrets={"client_secret": "sec-1"} ) account = Account.get_or_create("op@example.com") connection = OauthConnection.create( - provider=keyed_acme.name, + provider=keyed_acme.slug, account_id=account.id, refresh_token="rt-old", scopes=["profile.read"], @@ -778,7 +777,7 @@ def test_fresh_sign_in_with_matching_identity_resurrects_revoked_connection( settings = make_settings(tmp_path, urls={"endpoint": "https://druks.example"}) with TestClient(configure_app_for_test(settings=settings)) as client: - _complete_oauth_sign_in(client, keyed_acme.name) + _complete_oauth_sign_in(client, keyed_acme.slug) db_session().expire_all() resurrected = OauthConnection.get(connection_id) @@ -786,13 +785,13 @@ def test_fresh_sign_in_with_matching_identity_resurrects_revoked_connection( assert not resurrected.revoked_at assert not resurrected.revoked_reason assert resurrected.refresh_token.decrypt() == "rt-1" - assert [row.id for row in OauthConnection.list_for_provider(keyed_acme.name)] == [connection_id] - assert len(OauthConnection.list_for_provider(keyed_acme.name, include_revoked=True)) == 1 + assert [row.id for row in OauthConnection.list_for_provider(keyed_acme.slug)] == [connection_id] + assert len(OauthConnection.list_for_provider(keyed_acme.slug, include_revoked=True)) == 1 assert oauth_events == [ ( "oauth.connected", { - "provider": keyed_acme.name, + "provider": keyed_acme.slug, "connection_id": connection_id, "account_id": account.id, "reconsent": True, @@ -807,11 +806,11 @@ def test_matching_fresh_sign_in_lands_on_live_connection_and_evicts_cached_token from druks.testing import configure_app_for_test ServiceIdentity.connect( - keyed_acme.name, identity={"client_id": "id-1"}, secrets={"client_secret": "sec-1"} + keyed_acme.slug, identity={"client_id": "id-1"}, secrets={"client_secret": "sec-1"} ) account = Account.get_or_create("op@example.com") connection = OauthConnection.create( - provider=keyed_acme.name, + provider=keyed_acme.slug, account_id=account.id, refresh_token="rt-old", scopes=["profile.read"], @@ -821,20 +820,20 @@ def test_matching_fresh_sign_in_lands_on_live_connection_and_evicts_cached_token evicted_connection_ids = [] async def record_eviction(oauth_client, evicted_connection_id): - assert oauth_client.provider == keyed_acme.name + assert oauth_client.provider == keyed_acme.slug evicted_connection_ids.append(evicted_connection_id) monkeypatch.setattr(OauthClient, "evict_access_token", record_eviction) settings = make_settings(tmp_path, urls={"endpoint": "https://druks.example"}) with TestClient(configure_app_for_test(settings=settings)) as client: - _complete_oauth_sign_in(client, keyed_acme.name) + _complete_oauth_sign_in(client, keyed_acme.slug) db_session().expire_all() reconnected = OauthConnection.get(connection_id) assert reconnected assert reconnected.refresh_token.decrypt() == "rt-1" - assert len(OauthConnection.list_for_provider(keyed_acme.name, include_revoked=True)) == 1 + assert len(OauthConnection.list_for_provider(keyed_acme.slug, include_revoked=True)) == 1 assert evicted_connection_ids == [connection_id] assert oauth_events[-1][1]["connection_id"] == connection_id assert oauth_events[-1][1]["reconsent"] is True @@ -846,11 +845,11 @@ def test_fresh_sign_in_with_live_and_revoked_identity_matches_lands_on_live_conn from druks.testing import configure_app_for_test ServiceIdentity.connect( - keyed_acme.name, identity={"client_id": "id-1"}, secrets={"client_secret": "sec-1"} + keyed_acme.slug, identity={"client_id": "id-1"}, secrets={"client_secret": "sec-1"} ) account = Account.get_or_create("op@example.com") live = OauthConnection.create( - provider=keyed_acme.name, + provider=keyed_acme.slug, account_id=account.id, refresh_token="rt-live-old", scopes=["profile.read"], @@ -858,7 +857,7 @@ def test_fresh_sign_in_with_live_and_revoked_identity_matches_lands_on_live_conn ) live_id = live.id revoked = OauthConnection.create( - provider=keyed_acme.name, + provider=keyed_acme.slug, account_id=account.id, refresh_token="rt-revoked-old", scopes=["profile.read"], @@ -869,7 +868,7 @@ def test_fresh_sign_in_with_live_and_revoked_identity_matches_lands_on_live_conn settings = make_settings(tmp_path, urls={"endpoint": "https://druks.example"}) with TestClient(configure_app_for_test(settings=settings)) as client: - _complete_oauth_sign_in(client, keyed_acme.name) + _complete_oauth_sign_in(client, keyed_acme.slug) db_session().expire_all() reconnected = OauthConnection.get(live_id) @@ -879,7 +878,7 @@ def test_fresh_sign_in_with_live_and_revoked_identity_matches_lands_on_live_conn assert reconnected.refresh_token.decrypt() == "rt-1" assert still_revoked.revoked_at assert not still_revoked.refresh_token - assert len(OauthConnection.list_for_provider(keyed_acme.name, include_revoked=True)) == 2 + assert len(OauthConnection.list_for_provider(keyed_acme.slug, include_revoked=True)) == 2 assert oauth_events[-1][1]["connection_id"] == live_id assert oauth_events[-1][1]["reconsent"] is True @@ -890,11 +889,11 @@ def test_fresh_sign_in_without_the_declared_identity_fact_creates_a_new_connecti from druks.testing import configure_app_for_test ServiceIdentity.connect( - acme.name, identity={"client_id": "id-1"}, secrets={"client_secret": "sec-1"} + acme.slug, identity={"client_id": "id-1"}, secrets={"client_secret": "sec-1"} ) account = Account.get_or_create("op@example.com") revoked = OauthConnection.create( - provider=acme.name, + provider=acme.slug, account_id=account.id, refresh_token="rt-old", scopes=["profile.read"], @@ -905,12 +904,12 @@ def test_fresh_sign_in_without_the_declared_identity_fact_creates_a_new_connecti settings = make_settings(tmp_path, urls={"endpoint": "https://druks.example"}) with TestClient(configure_app_for_test(settings=settings)) as client: - _complete_oauth_sign_in(client, acme.name) + _complete_oauth_sign_in(client, acme.slug) db_session().expire_all() - [created] = OauthConnection.list_for_provider(acme.name) + [created] = OauthConnection.list_for_provider(acme.slug) assert created.id != revoked_id - assert len(OauthConnection.list_for_provider(acme.name, include_revoked=True)) == 2 + assert len(OauthConnection.list_for_provider(acme.slug, include_revoked=True)) == 2 assert OauthConnection.get(revoked_id).revoked_at assert oauth_events[-1][1]["connection_id"] == created.id assert oauth_events[-1][1]["reconsent"] is False @@ -1102,8 +1101,8 @@ def test_list_serves_the_connections_beside_the_declared_union(tmp_path, acme, d from druks.services.models import OauthConnection from druks.testing import configure_app_for_test - def entry(client, name="acme"): - return next(e for e in client.get("/api/services").json() if e["name"] == name) + def entry(client, slug="acme"): + return next(e for e in client.get("/api/services").json() if e["slug"] == slug) with TestClient(configure_app_for_test(settings=make_settings(tmp_path))) as client: assert entry(client, "github")["isOauth"] is False diff --git a/docs/writing-an-extension.md b/docs/writing-an-extension.md index d74a41c9..e8bbdbc2 100644 --- a/docs/writing-an-extension.md +++ b/docs/writing-an-extension.md @@ -704,9 +704,11 @@ accounts") — and a credential only your extension posts with belongs in your extension settings instead. Declare one class in `services.py` and the platform does the rest: it renders -the connect card in Settings (the heading derives from `name`), verifies and -stores the paste (`SecretStr` fields land encrypted, plain fields become -identity facts), and reports `druks doctor` state: +the connect card in Settings, verifies and stores the paste (`SecretStr` +fields land encrypted, plain fields become identity facts), and reports +`druks doctor` state. The class name is the identity. Druks derives the slug +from it (`Gmail` → `gmail`, `GoogleCalendar` → `google_calendar`) and derives +the card heading from the slug: ```python from pydantic import BaseModel, Field, SecretStr @@ -715,7 +717,6 @@ from druks.services import Service, ServiceConnectError class Gmail(Service): - name = "gmail" description = "The appliance's own OAuth client — every mailbox authenticates against it." class Settings(BaseModel): @@ -723,6 +724,10 @@ class Gmail(Service): client_secret: SecretStr = Field(title="Client secret") ``` +The slug keys the `service_identities` row and the connect wire. A class +rename changes the slug, rekeys the card, and orphans the connected identity. +Set `slug = "gmail"` on the class to keep the old key. + Read it back through the same class: ```python @@ -746,8 +751,8 @@ async def verify(cls, settings: Settings) -> dict: Set `required = False` on the class when the appliance is healthy without the service connected; doctor then notes it instead of reporting pending setup. -Key the service for the integration your extension consumes (`"gmail"`), not -the provider (`"google"`). A second integration on the same provider declares +Key the service for the integration your extension consumes (`Gmail`), not +the provider (`Google`). A second integration on the same provider declares its own service, and the operator decides per card whether the underlying registration is shared or a narrower one — that choice is their scope and blast-radius control. @@ -760,7 +765,6 @@ fields: ```python class Acme(Service): - name = "acme" authorization_endpoint = "https://acme.example/oauth/authorize" token_endpoint = "https://acme.example/oauth/token" # True = HTTP Basic on the token endpoint. False = secret in the body. @@ -804,9 +808,9 @@ shape. Override `get_identity` for them: One provider can back several services — Google backs both Gmail and Google Calendar, and each keeps its own card and its own key. Share the provider's -declarations through an abstract base. Set `abstract = True`: the base never -registers, and each subclass inherits everything it declares, `Settings` -included: +declarations through an abstract base. Set `abstract = True`. The base never +registers. Each subclass inherits everything it declares, `Settings` +included, and needs nothing beyond its class name: ```python class GoogleOauth(Service): @@ -824,11 +828,11 @@ class GoogleOauth(Service): class Gmail(GoogleOauth): - name = "gmail" + pass -class Calendar(GoogleOauth): - name = "google_calendar" +class GoogleCalendar(GoogleOauth): + pass ``` Declare your extension's use of the service, with the scopes your calls diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 392e473a..0228b3a5 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -223,8 +223,8 @@ export const api = { // pasted credentials against the provider before anything replaces a working // identity. Field names come from each entry's spec. services: () => getJSON