From 03339c13f74dce7a48854dd4cf6b53b881fb3831 Mon Sep 17 00:00:00 2001 From: ahmad-ajmal <165422770+ahmad-ajmal@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:54:42 +0100 Subject: [PATCH 1/8] feat: add protected shared credential manager Add profile-scoped vaults, protected Windows host execution and native owner consent. Route browser, model, integration and isolated agent consumers through approved credential helpers while preserving existing generated tokens and sessions. Include security review fixes, regression coverage, offline migration tooling and deployment documentation. Windows installation and interactive acceptance remain deferred. Signed-off-by: ahmad-ajmal <165422770+ahmad-ajmal@users.noreply.github.com> --- agent_core/core/embedding_interface.py | 8 +- agent_core/core/impl/action/executor.py | 42 +- agent_core/core/impl/action/manager.py | 5 +- agent_core/core/impl/llm/cache/byteplus.py | 6 +- .../impl/llm/transports/byteplus_responses.py | 6 +- agent_core/core/impl/video_gen/interface.py | 36 +- agent_core/core/impl/vlm/interface.py | 6 +- agent_core/core/llm/google_gemini_client.py | 33 + agent_core/core/models/connection_tester.py | 118 + agent_core/core/models/factory.py | 33 + app/agent_app/integration_bridge.py | 90 + app/agent_app/manager.py | 30 + app/agent_base.py | 6 +- app/config.py | 5 + app/credentials/__init__.py | 5 + app/credentials/aws_helper.py | 73 + app/credentials/broker.py | 510 + app/credentials/browser_helper.py | 294 + app/credentials/browser_migration.py | 169 + app/credentials/catalog.py | 540 + app/credentials/client.py | 320 + app/credentials/consumer_runtime.py | 65 + app/credentials/contracts.py | 85 + app/credentials/directory.py | 124 + app/credentials/errors.py | 45 + app/credentials/execution.py | 93 + app/credentials/gateway_helper.py | 136 + app/credentials/helpers.py | 900 + app/credentials/host_service.py | 1486 ++ app/credentials/identity.py | 143 + app/credentials/integrations.py | 435 + app/credentials/invocation.py | 216 + app/credentials/keys.py | 101 + app/credentials/listeners.py | 93 + app/credentials/media_clients.py | 65 + app/credentials/migration.py | 739 + app/credentials/migration_source.py | 140 + app/credentials/model_transport.py | 249 + app/credentials/mtproto_helper.py | 239 + app/credentials/native/CredentialHost.cs | 399 + app/credentials/native/bootstrap.py | 19 + app/credentials/oauth_transport.py | 46 + app/credentials/operation_session.py | 109 + app/credentials/owner_transport.py | 261 + app/credentials/provider_auth.py | 88 + app/credentials/provider_routes.json | 20072 ++++++++++++++++ app/credentials/records.py | 220 + app/credentials/references.py | 40 + app/credentials/request_review.py | 74 + app/credentials/resolver.py | 36 + app/credentials/route_policy.py | 99 + app/credentials/secrets.py | 36 + app/credentials/settings.py | 65 + app/credentials/storage.py | 135 + app/credentials/streaming.py | 127 + app/credentials/twitter_helper.py | 83 + app/credentials/use_errors.py | 64 + app/credentials/vault.py | 540 + app/credentials/windows_host.py | 475 + app/credentials/wire.py | 112 + app/data/action/credential_operations.py | 95 + app/data/action/integrations/_helpers.py | 102 +- app/data/action/understand_video.py | 38 +- app/data/action/web_search.py | 21 +- app/integrations.py | 14 +- app/mini_browser/actions_api.py | 3 + app/mini_browser/core.py | 6 +- app/mini_browser/ops.py | 19 +- app/mini_browser/vault.py | 34 +- app/mini_browser/ws.py | 12 + app/onboarding/interfaces/steps.py | 25 +- app/ui_layer/adapters/browser_adapter.py | 5 + app/ui_layer/adapters/ws_lanes.py | 1 + .../components/MiniBrowser/PasswordsModal.tsx | 622 +- .../src/pages/Onboarding/OnboardingPage.tsx | 22 +- .../Settings/CredentialsSettings.module.css | 12 + .../pages/Settings/CredentialsSettings.tsx | 162 + .../pages/Settings/IntegrationsSettings.tsx | 88 +- .../src/pages/Settings/ModelSettings.tsx | 26 +- .../src/pages/Settings/SettingsPage.tsx | 5 +- .../frontend/src/pages/Settings/types.ts | 3 + .../src/store/resources/ResourceSync.ts | 1 - .../src/store/slices/modelSettingsSlice.ts | 1 + .../browser/frontend/src/types/index.ts | 35 +- app/ui_layer/commands/builtin/cred.py | 8 + app/ui_layer/commands/builtin/integrations.py | 98 +- app/ui_layer/onboarding/controller.py | 19 +- app/ui_layer/settings/model_settings.py | 74 +- app/ui_layer/settings/openrouter_catalog.py | 11 +- app/ui_layer/settings/profile_bundle.py | 10 + app/ui_layer/settings/provider_settings.py | 13 + craftos_integrations/contracts.py | 31 + craftos_integrations/core/accounts.py | 16 +- craftos_integrations/core/listeners.py | 7 +- craftos_integrations/core/system.py | 47 +- craftos_integrations/helpers/http.py | 46 +- craftos_integrations/helpers/source_auth.py | 68 + craftos_integrations/helpers/source_oauth.py | 40 + .../helpers/source_requests.py | 15 + .../providers/_lark_common.py | 29 +- .../providers/discord/_discord_voice.py | 13 +- .../providers/github/client.py | 10 +- craftos_integrations/providers/lark/client.py | 13 +- craftos_integrations/providers/line/client.py | 6 +- .../providers/notion/client.py | 5 +- .../providers/slack/client.py | 9 +- .../providers/supabase/client.py | 256 +- .../providers/telegram_bot/client.py | 4 +- .../telegram_user/_telegram_mtproto.py | 44 + .../providers/telegram_user/client.py | 39 +- .../providers/telegram_user/provider.py | 67 +- .../providers/twitter/client.py | 18 +- docs/plans/credential-manager-cm01.md | 133 + docs/plans/credential-manager-cm02.md | 117 + docs/plans/credential-manager-cm03.md | 176 + docs/plans/credential-manager-consumers.md | 279 + docs/plans/credential-manager-windows-host.md | 202 + .../credential-host-win_amd64-py312.txt | 21 + scripts/build_credential_host.py | 245 + scripts/credential_host.py | 110 + scripts/generate_credential_routes.py | 425 + scripts/install_credential_host.ps1 | 94 + scripts/verify_credential_host.py | 187 + tests/credentials/test_auth_protocols.py | 729 + tests/credentials/test_broker.py | 1070 + tests/credentials/test_consumer_adapters.py | 315 + tests/credentials/test_export_exclusions.py | 50 + tests/credentials/test_extended_helpers.py | 358 + tests/credentials/test_gateway_helper.py | 96 + tests/credentials/test_identity.py | 201 + .../test_integration_continuity.py | 115 + tests/credentials/test_job_transport.py | 145 + tests/credentials/test_keys.py | 148 + tests/credentials/test_migration.py | 200 + tests/credentials/test_model_transport.py | 104 + tests/credentials/test_protected_host.py | 299 + .../credentials/test_security_review_fixes.py | 644 + tests/credentials/test_source_consumers.py | 317 + tests/credentials/test_source_migration.py | 276 + tests/credentials/test_vault.py | 511 + tests/test_mini_browser_ops.py | 196 +- tests/test_mini_browser_ws.py | 211 +- 142 files changed, 39733 insertions(+), 1398 deletions(-) create mode 100644 app/credentials/__init__.py create mode 100644 app/credentials/aws_helper.py create mode 100644 app/credentials/broker.py create mode 100644 app/credentials/browser_helper.py create mode 100644 app/credentials/browser_migration.py create mode 100644 app/credentials/catalog.py create mode 100644 app/credentials/client.py create mode 100644 app/credentials/consumer_runtime.py create mode 100644 app/credentials/contracts.py create mode 100644 app/credentials/directory.py create mode 100644 app/credentials/errors.py create mode 100644 app/credentials/execution.py create mode 100644 app/credentials/gateway_helper.py create mode 100644 app/credentials/helpers.py create mode 100644 app/credentials/host_service.py create mode 100644 app/credentials/identity.py create mode 100644 app/credentials/integrations.py create mode 100644 app/credentials/invocation.py create mode 100644 app/credentials/keys.py create mode 100644 app/credentials/listeners.py create mode 100644 app/credentials/media_clients.py create mode 100644 app/credentials/migration.py create mode 100644 app/credentials/migration_source.py create mode 100644 app/credentials/model_transport.py create mode 100644 app/credentials/mtproto_helper.py create mode 100644 app/credentials/native/CredentialHost.cs create mode 100644 app/credentials/native/bootstrap.py create mode 100644 app/credentials/oauth_transport.py create mode 100644 app/credentials/operation_session.py create mode 100644 app/credentials/owner_transport.py create mode 100644 app/credentials/provider_auth.py create mode 100644 app/credentials/provider_routes.json create mode 100644 app/credentials/records.py create mode 100644 app/credentials/references.py create mode 100644 app/credentials/request_review.py create mode 100644 app/credentials/resolver.py create mode 100644 app/credentials/route_policy.py create mode 100644 app/credentials/secrets.py create mode 100644 app/credentials/settings.py create mode 100644 app/credentials/storage.py create mode 100644 app/credentials/streaming.py create mode 100644 app/credentials/twitter_helper.py create mode 100644 app/credentials/use_errors.py create mode 100644 app/credentials/vault.py create mode 100644 app/credentials/windows_host.py create mode 100644 app/credentials/wire.py create mode 100644 app/data/action/credential_operations.py create mode 100644 app/ui_layer/browser/frontend/src/pages/Settings/CredentialsSettings.module.css create mode 100644 app/ui_layer/browser/frontend/src/pages/Settings/CredentialsSettings.tsx create mode 100644 craftos_integrations/helpers/source_auth.py create mode 100644 craftos_integrations/helpers/source_oauth.py create mode 100644 craftos_integrations/helpers/source_requests.py create mode 100644 docs/plans/credential-manager-cm01.md create mode 100644 docs/plans/credential-manager-cm02.md create mode 100644 docs/plans/credential-manager-cm03.md create mode 100644 docs/plans/credential-manager-consumers.md create mode 100644 docs/plans/credential-manager-windows-host.md create mode 100644 requirements/credential-host-win_amd64-py312.txt create mode 100644 scripts/build_credential_host.py create mode 100644 scripts/credential_host.py create mode 100644 scripts/generate_credential_routes.py create mode 100644 scripts/install_credential_host.ps1 create mode 100644 scripts/verify_credential_host.py create mode 100644 tests/credentials/test_auth_protocols.py create mode 100644 tests/credentials/test_broker.py create mode 100644 tests/credentials/test_consumer_adapters.py create mode 100644 tests/credentials/test_export_exclusions.py create mode 100644 tests/credentials/test_extended_helpers.py create mode 100644 tests/credentials/test_gateway_helper.py create mode 100644 tests/credentials/test_identity.py create mode 100644 tests/credentials/test_integration_continuity.py create mode 100644 tests/credentials/test_job_transport.py create mode 100644 tests/credentials/test_keys.py create mode 100644 tests/credentials/test_migration.py create mode 100644 tests/credentials/test_model_transport.py create mode 100644 tests/credentials/test_protected_host.py create mode 100644 tests/credentials/test_security_review_fixes.py create mode 100644 tests/credentials/test_source_consumers.py create mode 100644 tests/credentials/test_source_migration.py create mode 100644 tests/credentials/test_vault.py diff --git a/agent_core/core/embedding_interface.py b/agent_core/core/embedding_interface.py index 970f54322..e13757274 100644 --- a/agent_core/core/embedding_interface.py +++ b/agent_core/core/embedding_interface.py @@ -148,7 +148,13 @@ def _get_byteplus_embedding(self, text: str) -> Optional[List[float]]: "Content-Type": "application/json", "Authorization": f"Bearer {self.api_key}", } - response = requests.post(url, json=payload, headers=headers, timeout=120) + from app.credentials.model_transport import requests_response + + response = requests_response("byteplus", self.api_key, "POST", url, payload) + if response is None: + response = requests.post( + url, json=payload, headers=headers, timeout=120 + ) response.raise_for_status() result = response.json() data = result.get("data") diff --git a/agent_core/core/impl/action/executor.py b/agent_core/core/impl/action/executor.py index 6d15b734f..498a94a3f 100644 --- a/agent_core/core/impl/action/executor.py +++ b/agent_core/core/impl/action/executor.py @@ -685,8 +685,17 @@ class ActionExecutor: - Automatic requirement installation """ - def __init__(self): + def __init__( + self, + *, + credential_profile_id: Optional[str] = None, + credential_profile_resolver=None, + ): self._inflight: Dict[str, Any] = {} + # Host-selected preference, never action metadata or agent-bundle state. + # The protected service still requires explicit native owner approval. + self._credential_profile_id = credential_profile_id + self._credential_profile_resolver = credential_profile_resolver async def execute_atomic_action( self, @@ -719,6 +728,37 @@ async def execute_atomic_action( if requirements and execution_mode == "internal": _ensure_requirements(requirements) + if execution_mode == "credential_isolated": + from app.credentials.consumer_runtime import execute_action + + profile_id = self._credential_profile_id + if self._credential_profile_resolver is not None: + try: + profile_id = await asyncio.to_thread( + self._credential_profile_resolver + ) + except Exception: + profile_id = None + if profile_id: + from uuid import UUID + + try: + profile_id = str(UUID(profile_id)) + except (ValueError, TypeError, AttributeError): + profile_id = None + if not profile_id or requirements: + return { + "status": "error", + "message": "Credential actions require an explicit profile and the protected host's standard library runtime", + } + return await execute_action( + action.code, + input_data, + profile_id=profile_id, + subject=action.name, + timeout=effective_timeout, + ) + if execution_mode == "internal": # Use async executor which handles both sync and async action functions # Async functions are awaited directly; sync functions run in thread pool diff --git a/agent_core/core/impl/action/manager.py b/agent_core/core/impl/action/manager.py index f0a16427e..dbf7fd7e7 100644 --- a/agent_core/core/impl/action/manager.py +++ b/agent_core/core/impl/action/manager.py @@ -174,6 +174,7 @@ def __init__( on_action_end: Optional[OnActionEndHook] = None, get_parent_id: Optional[GetParentIdHook] = None, idempotency_guard: Optional["IdempotencyGuard"] = None, + credential_profile_resolver=None, ): """ Build an ActionManager that can execute and track actions. @@ -199,7 +200,9 @@ def __init__( self.event_stream_manager = event_stream_manager self.context_engine = context_engine self.state_manager = state_manager - self.executor = ActionExecutor() + self.executor = ActionExecutor( + credential_profile_resolver=credential_profile_resolver + ) # Track in-flight actions self._inflight: Dict[str, Dict] = {} diff --git a/agent_core/core/impl/llm/cache/byteplus.py b/agent_core/core/impl/llm/cache/byteplus.py index 19bf17a2f..94bd10885 100644 --- a/agent_core/core/impl/llm/cache/byteplus.py +++ b/agent_core/core/impl/llm/cache/byteplus.py @@ -143,7 +143,11 @@ def _call_responses_api( f"[BYTEPLUS REQUEST] Payload: {self._sanitize_payload_for_logging(payload)}" ) - response = requests.post(url, json=payload, headers=headers, timeout=600) + from app.credentials.model_transport import requests_response + + response = requests_response("byteplus", self.api_key, "POST", url, payload) + if response is None: + response = requests.post(url, json=payload, headers=headers, timeout=600) # Log the response status logger.info(f"[BYTEPLUS RESPONSE] Status: {response.status_code}") diff --git a/agent_core/core/impl/llm/transports/byteplus_responses.py b/agent_core/core/impl/llm/transports/byteplus_responses.py index ea5db6499..066177fa6 100644 --- a/agent_core/core/impl/llm/transports/byteplus_responses.py +++ b/agent_core/core/impl/llm/transports/byteplus_responses.py @@ -236,7 +236,11 @@ def generate_standard( ) logger.info(f"[BYTEPLUS STANDARD REQUEST] Messages count: {len(messages)}") - response = requests.post(url, json=payload, headers=headers, timeout=600) + from app.credentials.model_transport import requests_response + + response = requests_response("byteplus", iface.api_key, "POST", url, payload) + if response is None: + response = requests.post(url, json=payload, headers=headers, timeout=600) # Log response status logger.info(f"[BYTEPLUS STANDARD RESPONSE] Status: {response.status_code}") diff --git a/agent_core/core/impl/video_gen/interface.py b/agent_core/core/impl/video_gen/interface.py index d621985c6..7496a5e74 100644 --- a/agent_core/core/impl/video_gen/interface.py +++ b/agent_core/core/impl/video_gen/interface.py @@ -977,15 +977,19 @@ def _byteplus_submit( """Submit a Seedance task and return its task id.""" url = f"{base_url}/contents/generations/tasks" try: - r = requests.post( - url, - headers={ - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - }, - json=body, - timeout=60, - ) + from app.credentials.model_transport import requests_response + + r = requests_response("byteplus", api_key, "POST", url, body) + if r is None: + r = requests.post( + url, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json=body, + timeout=60, + ) except Exception as exc: raise _classified_error("byteplus", exc, self.model) from exc @@ -1024,11 +1028,15 @@ def _byteplus_poll( last_status = "unknown" while True: try: - r = requests.get( - poll_url, - headers={"Authorization": f"Bearer {api_key}"}, - timeout=30, - ) + from app.credentials.model_transport import requests_response + + r = requests_response("byteplus", api_key, "GET", poll_url) + if r is None: + r = requests.get( + poll_url, + headers={"Authorization": f"Bearer {api_key}"}, + timeout=30, + ) r.raise_for_status() except Exception as exc: raise _classified_error("byteplus", exc, self.model) from exc diff --git a/agent_core/core/impl/vlm/interface.py b/agent_core/core/impl/vlm/interface.py index df9a2f6f6..2c9ef01bb 100644 --- a/agent_core/core/impl/vlm/interface.py +++ b/agent_core/core/impl/vlm/interface.py @@ -748,7 +748,11 @@ def _byteplus_describe_bytes( "Authorization": f"Bearer {self.api_key}", } - response = requests.post(url, json=payload, headers=headers, timeout=120) + from app.credentials.model_transport import requests_response + + response = requests_response("byteplus", self.api_key, "POST", url, payload) + if response is None: + response = requests.post(url, json=payload, headers=headers, timeout=120) response.raise_for_status() result = response.json() diff --git a/agent_core/core/llm/google_gemini_client.py b/agent_core/core/llm/google_gemini_client.py index 98e2593b5..28ad7e21e 100644 --- a/agent_core/core/llm/google_gemini_client.py +++ b/agent_core/core/llm/google_gemini_client.py @@ -442,6 +442,12 @@ def delete_cache(self, cache_name: str) -> None: cache_id = f"cachedContents/{cache_name}" url = self._endpoint(cache_id) + from app.credentials.model_transport import requests_response + + protected = requests_response("gemini", self._api_key, "DELETE", url) + if protected is not None: + protected.raise_for_status() + return response = requests.delete( url, headers={"x-goog-api-key": self._api_key}, @@ -795,6 +801,12 @@ def poll_video_operation(self, operation_name: str) -> Dict[str, Any]: if path.startswith(f"{self._api_version}/"): path = path[len(f"{self._api_version}/") :] url = self._endpoint(path) + from app.credentials.model_transport import requests_response + + protected = requests_response("gemini", self._api_key, "GET", url) + if protected is not None: + protected.raise_for_status() + return protected.json() response = requests.get( url, headers={"x-goog-api-key": self._api_key}, @@ -821,6 +833,12 @@ def download_video(self, video_uri: str, *, timeout: int = 120) -> bytes: ``HTTPError`` messages (which include the request URL) and any request/response logging. """ + from app.credentials.model_transport import requests_response + + protected = requests_response("gemini", self._api_key, "GET", video_uri) + if protected is not None: + protected.raise_for_status() + return protected.content response = requests.get( video_uri, headers={"x-goog-api-key": self._api_key}, @@ -839,6 +857,14 @@ def _endpoint(self, path: str) -> str: def _post_json(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]: """Send POST request and return JSON response.""" + from app.credentials.model_transport import requests_response + + protected = requests_response( + "gemini", self._api_key, "POST", self._endpoint(path), payload + ) + if protected is not None: + protected.raise_for_status() + return protected.json() response = requests.post( self._endpoint(path), headers={"x-goog-api-key": self._api_key}, @@ -862,6 +888,13 @@ def _post_json(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]: response.raise_for_status() return response.json() + def _require_legacy_transport(self): + from app.credentials.references import is_reference + from app.credentials.client import CredentialHostUnavailable + + if is_reference(self._api_key): + raise CredentialHostUnavailable("credential_model_helper_unavailable") + @staticmethod def _extract_text(response: Dict[str, Any]) -> str: """Extract text content from Gemini response.""" diff --git a/agent_core/core/models/connection_tester.py b/agent_core/core/models/connection_tester.py index 1b9114679..c2d5f9b18 100644 --- a/agent_core/core/models/connection_tester.py +++ b/agent_core/core/models/connection_tester.py @@ -41,6 +41,124 @@ def test_provider_connection( Returns: Dictionary with success/message/provider/error. """ + from app.credentials.references import is_reference + + if is_reference(api_key): + from app.credentials.model_transport import installed_model_helpers + from app.credentials.invocation import invoke + + try: + body = None + if model: + if provider == "gemini": + operation = f"generate_{model}" + body = { + "contents": [{"parts": [{"text": "Hi"}]}], + "generationConfig": {"maxOutputTokens": 4}, + } + else: + operation = ( + "messages" if provider == "anthropic" else "chat_completions" + ) + body = { + "model": model, + "max_tokens": 4, + "messages": [{"role": "user", "content": "Hi"}], + } + else: + operation = "models" + routes = [ + item + for item in installed_model_helpers() + if item["helper"] == f"model_{provider}" + and item["operation"] == operation + and ( + not base_url + or item["endpoint"].startswith(base_url.rstrip("/") + "/") + ) + ] + if not routes and model and provider == "gemini": + from app.credentials.route_policy import match_route + + base = ( + base_url or "https://generativelanguage.googleapis.com/v1beta" + ).rstrip("/") + if not base.endswith("/v1beta") and not base_url: + base += "/v1beta" + matched = match_route( + [ + r + for r in installed_model_helpers() + if r["helper"] == "model_gemini" + ], + "POST", + base + "/models/" + model + ":generateContent", + ) + if matched is not None: + route, path, query = matched + routes = [route] + operation = route["operation"] + if route.get("envelope"): + body = {"path": path, "query": query, "data": body} + if len(routes) != 1: + return { + "success": False, + "message": "This endpoint needs one installed credential helper.", + "provider": provider, + } + route = routes[0] + response = invoke( + api_key, + route["helper"], + operation, + body, + subject=f"Test {provider} connection", + ) + success = 200 <= response["status"] < 300 + return { + "success": success, + "message": "Connection verified." if success else "Connection failed.", + "provider": provider, + } + except Exception: + return { + "success": False, + "message": "A supported credential helper and owner approval are required.", + "provider": provider, + } + if provider == "bedrock": + source = aws_credentials + if not source: + try: + from app.config import get_aws_credentials + + source = get_aws_credentials() + except Exception: + source = {} + if "source_reference" in source: + try: + from app.credentials.model_transport import VaultBedrockClient + + client = VaultBedrockClient( + source["source_reference"], + source.get("region") or base_url or "us-east-1", + ) + client.converse( + modelId=model or "us.anthropic.claude-haiku-4-5-20251001-v1:0", + messages=[{"role": "user", "content": [{"text": "Hi"}]}], + inferenceConfig={"maxTokens": 1}, + ) + return { + "success": True, + "message": "Connection verified.", + "provider": provider, + } + except Exception: + return { + "success": False, + "message": "A reviewed Bedrock signing helper and owner approval are required.", + "provider": provider, + } return _test_provider_connection_inner( provider, api_key=api_key, diff --git a/agent_core/core/models/factory.py b/agent_core/core/models/factory.py index 7d8162f88..ccb359bfe 100644 --- a/agent_core/core/models/factory.py +++ b/agent_core/core/models/factory.py @@ -107,6 +107,16 @@ def _create_openai_client( if default_headers: kwargs["default_headers"] = default_headers + if oauth_provider is None: + from app.credentials.references import is_reference + + if is_reference(api_key): + from app.credentials.model_transport import http_client + + kwargs["api_key"] = "vault-managed" + kwargs["http_client"] = http_client(provider, api_key, resolve_key) + return OpenAI(**kwargs) + if oauth_provider is None and resolve_key is not None: # Credential pool: re-resolve the key per request (same SDK property # mechanism as subscription OAuth below), so a cooldown-driven @@ -172,6 +182,15 @@ def _create_anthropic_client(*, api_key: str, resolve_key=None): "Install it with the Python that launches CraftBot: " "`python -m pip install 'anthropic>=0.97.0'`." ) from exc + from app.credentials.references import is_reference + + if is_reference(api_key): + from app.credentials.model_transport import http_client + + return Anthropic( + api_key="vault-managed", + http_client=http_client("anthropic", api_key, resolve_key), + ) if resolve_key is None: return Anthropic(api_key=api_key) @@ -597,6 +616,7 @@ def create( # If unset, fall back to settings → AWS_REGION env → default. region = resolved_base_url or "us-east-1" access_key = secret_key = session_token = None + creds = {} try: from app.config import get_aws_credentials # type: ignore @@ -610,6 +630,19 @@ def create( # role, SSO profile). Useful when running on an EC2/ECS host. pass + if "source_reference" in creds: + from app.credentials.model_transport import VaultBedrockClient + + return { + **empty_context, + "provider": provider, + "model": model, + "bedrock_client": VaultBedrockClient( + creds["source_reference"], creds.get("region") or region + ), + "initialized": True, + } + if boto3 is None: if deferred: return empty_context diff --git a/app/agent_app/integration_bridge.py b/app/agent_app/integration_bridge.py index 94ea3f2e4..7e1ee4753 100644 --- a/app/agent_app/integration_bridge.py +++ b/app/agent_app/integration_bridge.py @@ -12,6 +12,8 @@ from __future__ import annotations import logging +import asyncio +import time from pathlib import Path from typing import TYPE_CHECKING, Dict, Optional @@ -84,6 +86,7 @@ def __init__(self, manager: "AgentAppManager"): # follow_redirects is OFF for the proxy: an allowed host could 302 to an # attacker-controlled host and the injected credentials would follow it. self._http_client = httpx.AsyncClient(timeout=30, follow_redirects=False) + self._credential_runs = {} def register_routes(self, app: web.Application) -> None: """Register integration bridge routes on the aiohttp app.""" @@ -93,11 +96,98 @@ def register_routes(self, app: web.Application) -> None: app.router.add_post("/api/bridge/llm", self._handle_llm) app.router.add_post("/api/bridge/vlm", self._handle_vlm) app.router.add_post("/api/bridge/agent_request", self._handle_agent_request) + app.router.add_post("/api/credentials/worker", self._handle_credential_worker) logger.info("[INTEGRATION_BRIDGE] Routes registered") async def cleanup(self) -> None: """Close the HTTP client.""" await self._http_client.aclose() + from app.credentials.client import request + + for run_id in tuple(self._credential_runs): + try: + await asyncio.to_thread(request, "stop", run_id=run_id) + except Exception: + pass + self._credential_runs.clear() + + async def _handle_credential_worker(self, request: web.Request) -> web.Response: + """Launch a separate revision-pinned worker; never relay secret-use RPC. + + The existing bearer identifies the requesting project only. Native owner + consent and the worker's real AppContainer/job establish vault grants. + Bridge launches are development workers: the shared live/shadow token + cannot prove which deployment originated a request. + """ + project = self._validate_token(request) + if not project: + return web.json_response({"error": "Unauthorized"}, status=401) + self._credential_runs = { + key: value + for key, value in self._credential_runs.items() + if value[1] > time.monotonic() + } + try: + data = await request.json() + if type(data) is not dict: + raise ValueError() + operation = data.get("operation") + if operation == "launch" and set(data) == { + "operation", + "profile_id", + "files", + "entry", + }: + if len(self._credential_runs) >= 16: + return web.json_response( + {"error": "credential_use_limit"}, status=429 + ) + result = await self._manager.launch_credential_worker( + project, + data["profile_id"], + data["files"], + data["entry"], + development=True, + ) + self._credential_runs[result["run_id"]] = ( + project, + time.monotonic() + 900, + ) + elif ( + ( + operation in ("result", "program_result", "stop") + and set(data) == {"operation", "run_id"} + ) + or ( + operation == "response" + and set(data) == {"operation", "run_id", "offset"} + ) + or ( + operation == "stage_payload" + and set(data) == {"operation", "run_id", "offset", "data", "done"} + ) + ): + run = self._credential_runs.get(data["run_id"]) + if run is None or run[0] != project: + return web.json_response( + {"error": "credential_use_denied"}, status=403 + ) + from app.credentials.client import request as host_request + + result = await asyncio.to_thread( + host_request, + operation, + **{key: value for key, value in data.items() if key != "operation"}, + ) + if operation == "stop": + del self._credential_runs[data["run_id"]] + else: + return web.json_response({"error": "credential_use_denied"}, status=403) + return web.json_response(result) + except Exception: + return web.json_response( + {"error": "credential_worker_unavailable"}, status=400 + ) # ------------------------------------------------------------------ # Route handlers diff --git a/app/agent_app/manager.py b/app/agent_app/manager.py index a69eba8fa..c54542586 100644 --- a/app/agent_app/manager.py +++ b/app/agent_app/manager.py @@ -239,6 +239,36 @@ def to_dict(self) -> Dict[str, Any]: class AgentAppManager: """Manages Agent App project lifecycle.""" + async def launch_credential_worker( + self, + project_id: str, + profile_id: str, + files: dict, + entry: str, + *, + development=False, + ) -> dict: + """Separate protected Python worker; no bridge token or host env inheritance. + + Existing web/Node processes receive no vault authority. The service + snapshots these files and pins their actual hash and job. Project name + and deployment remain explicitly unverified claims in native consent. + """ + from app.credentials.client import request + + if self.get_project(project_id) is None: + raise ValueError("Unknown Agent App project") + return await asyncio.to_thread( + request, + "launch", + profile_id=profile_id, + kind="agent_app", + subject=project_id, + deployment="development" if development else "live", + files=files, + entry=entry, + ) + def __init__(self, workspace_root: Path): """ Initialize the Agent App Manager. diff --git a/app/agent_base.py b/app/agent_base.py index 16836ed79..bdd4534b4 100644 --- a/app/agent_base.py +++ b/app/agent_base.py @@ -351,6 +351,8 @@ def __init__( from app.triggers.activity_log import ActivityLogGuard, get_activity_log self.activity_log = get_activity_log() + from app.credentials.client import action_profile + self.action_manager = ActionManager( self.action_library, self.llm, @@ -359,6 +361,7 @@ def __init__( self.context_engine, self.state_manager, idempotency_guard=ActivityLogGuard(self.activity_log), + credential_profile_resolver=action_profile, ) self.action_router = ActionRouter( self.action_library, @@ -4094,7 +4097,8 @@ async def _initialize_external_libraries(self) -> None: }, extras={ "agent_name": agent_name, - "openai_api_key": os.environ.get("OPENAI_API_KEY", ""), + "openai_api_key": get_api_key("openai") + or os.environ.get("OPENAI_API_KEY", ""), # The WhatsApp bridge spawns a Node subprocess and needs the # runtime this app resolved. Injected rather than imported — # the integrations package must stay host-blind. diff --git a/app/config.py b/app/config.py index 756dbd702..eca3f5f6a 100644 --- a/app/config.py +++ b/app/config.py @@ -443,6 +443,11 @@ def get_aws_credentials() -> Dict[str, str]: aws = settings.get("aws_credentials", {}) or {} endpoints = settings.get("endpoints", {}) or {} + if aws.get("auth_mode") == "vault": + return { + "source_reference": aws.get("source_reference", ""), + "region": endpoints.get("aws_region") or "us-east-1", + } return { "access_key_id": aws.get("access_key_id") or os.environ.get("AWS_ACCESS_KEY_ID", "") diff --git a/app/credentials/__init__.py b/app/credentials/__init__.py new file mode 100644 index 000000000..e3cf28b3c --- /dev/null +++ b/app/credentials/__init__.py @@ -0,0 +1,5 @@ +"""Host-owned credential contracts and storage; no consumer routes are enabled. + +These types are not an authentication or OS isolation mechanism. Only trusted +host adapters may construct caller bindings from authenticated transport state. +""" diff --git a/app/credentials/aws_helper.py b/app/credentials/aws_helper.py new file mode 100644 index 000000000..c66fced9f --- /dev/null +++ b/app/credentials/aws_helper.py @@ -0,0 +1,73 @@ +"""Private static-AWS signing for one installed Bedrock Converse endpoint.""" + +from datetime import datetime, timezone +import hashlib +import hmac +import re +from urllib.parse import quote, urlsplit + +from .secrets import Secret +from .use_errors import CredentialUseError, UseCode + + +def endpoint_region(endpoint): + parsed = urlsplit(endpoint) + match = re.fullmatch( + r"bedrock-runtime\.([a-z]{2}(?:-[a-z]+)+-\d)\.amazonaws\.com", parsed.netloc + ) + if not match or not re.fullmatch(r"/model/[A-Za-z0-9_.:-]+/converse", parsed.path): + raise ValueError("invalid_bedrock_helper") + return match[1] + + +def signing_headers(endpoint, body: bytes, fields: Secret): + """No session-token flow here: generated AWS credentials retain their owner. + + Canonical request/signing-key derivation follow AWS SigV4. The endpoint has + no query, encoded aliases, consumer path parameters or alternate AWS service. + """ + region = endpoint_region(endpoint) + selected = Secret( + {key: fields.reveal()[key] for key in ("access_key_id", "secret_access_key")} + ) + if not re.fullmatch( + r"[A-Z0-9]{16,128}", selected.reveal()["access_key_id"] + ) or not re.fullmatch( + r"[A-Za-z0-9/+=]{16,256}", selected.reveal()["secret_access_key"] + ): + raise CredentialUseError(UseCode.DENIED) + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + date = stamp[:8] + parsed = urlsplit(endpoint) + signed = "content-type;host;x-amz-content-sha256;x-amz-date" + digest = hashlib.sha256(body).hexdigest() + canonical = Secret( + "\n".join( + ( + "POST", + quote(parsed.path, safe="/~"), + "", + f"content-type:application/json\nhost:{parsed.netloc}\nx-amz-content-sha256:{digest}\nx-amz-date:{stamp}\n", + signed, + digest, + ) + ) + ) + scope = f"{date}/{region}/bedrock/aws4_request" + message = Secret( + f"AWS4-HMAC-SHA256\n{stamp}\n{scope}\n" + + hashlib.sha256(canonical.reveal().encode()).hexdigest() + ) + key = Secret(("AWS4" + selected.reveal()["secret_access_key"]).encode()) + for part in (date, region, "bedrock", "aws4_request"): + key = Secret(hmac.digest(key.reveal(), part.encode(), "sha256")) + signature = Secret( + hmac.new(key.reveal(), message.reveal().encode(), "sha256").hexdigest() + ) + return Secret( + { + "Authorization": f"AWS4-HMAC-SHA256 Credential={selected.reveal()['access_key_id']}/{scope}, SignedHeaders={signed}, Signature={signature.reveal()}", + "x-amz-date": stamp, + "x-amz-content-sha256": digest, + } + ), selected diff --git a/app/credentials/broker.py b/app/credentials/broker.py new file mode 100644 index 000000000..7501d98e5 --- /dev/null +++ b/app/credentials/broker.py @@ -0,0 +1,510 @@ +"""Host-owned authorization and trusted execution, with no secret-returning API. + +Never expose this Python object to consumer code or deserialize its context from +JSON. A future protected IPC adapter must resolve caller identity out of band. +The default isolation/owner-verification adapters deny all credential use. +""" + +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass, field +from enum import Enum +from functools import wraps +import hashlib +import json +import math +import re +import secrets +from threading import RLock +import time +from uuid import UUID, uuid4 + +from .contracts import ( + CredentialReference, + CredentialSummary, + HelperOperation, + UnavailableIsolationBoundary, +) +from .directory import IdentityDirectory +from .helpers import ( + HelperRegistry, + HelperResult, + HttpsCredentialCheck, + HttpsJsonOperation, + JsonResult, + _execute_check, + _execute_json, + require_match, +) +from .identity import CallerBinding, ExecutionContext, VaultScope +from .keys import VerificationUnavailable +from .use_errors import CredentialUseError, UseCode, use_boundary +from .vault import ProfileVault, VaultState +from .browser_helper import HttpsFormLogin, _execute_form + + +class GrantMode(str, Enum): + ONCE = "once" + BINDING = "binding" # Until expiry/lock/revocation; never crosses a restart. + + +@dataclass(frozen=True) +class Grant: + grant_id: UUID + binding: CallerBinding + reference: CredentialReference + helper: HttpsCredentialCheck + unlock_epoch: UUID + expires_at: float # Host monotonic time; not a serialized wall-clock claim. + mode: GrantMode + request_digest: bytes | None + + @property + def operation(self) -> HelperOperation: + return self.helper.operation + + +@dataclass(frozen=True) +class UseCapability: + handle: str = field(repr=False) + + def __post_init__(self): + if not isinstance(self.handle, str) or not re.fullmatch( + r"[A-Za-z0-9_\-]{43}", self.handle + ): + raise ValueError("invalid_capability") + + +@dataclass(frozen=True) +class _PendingUse: + grant_id: UUID + context: ExecutionContext + expires_at: float + + +@dataclass(frozen=True) +class AuditEvent: + action: str + outcome: str + binding_id: UUID | None + execution_id: UUID | None + scope: VaultScope | None + grant_id: UUID | None + + +@dataclass(frozen=True) +class UseOffer: + grant_id: UUID + summary: CredentialSummary + operation: HelperOperation + + +def canonical_request(payload): + """Freeze caller data before approval/dispatch; never log the representation.""" + try: + encoded = json.dumps( + payload, + ensure_ascii=True, + allow_nan=False, + sort_keys=True, + separators=(",", ":"), + ).encode("ascii") + if len(encoded) > 32_000_020: + raise ValueError() + return json.loads(encoded), hashlib.sha256(encoded).digest() + except (TypeError, ValueError, RecursionError): + raise CredentialUseError(UseCode.DENIED) from None + + +def _audited(action): + def decorate(operation): + @wraps(operation) + def audited(self, context, *args, **kwargs): + try: + result = operation(self, context, *args, **kwargs) + except Exception: + self._event(action, context, "denied") + raise + except BaseException: + self._event(action, context, "interrupted") + raise + outcome = result.value if type(result) is HelperResult else "allowed" + self._event( + action, context, outcome, result if type(result) is Grant else None + ) + return result + + return audited + + return decorate + + +class CredentialBroker: + def __init__( + self, + directory: IdentityDirectory, + vaults: tuple[ProfileVault, ...], + registry: HelperRegistry, + *, + isolation=None, + owner_verifier=None, + clock=time.monotonic, + ): + self._directory = directory + for vault in vaults: + directory.require_vault(vault.owner, vault.profile.scope) + self._vaults = {vault.profile.scope: vault for vault in vaults} + if len(self._vaults) != len(vaults): + raise ValueError("duplicate_profile_vault") + self._registry = registry + self._isolation = ( + isolation if isolation is not None else UnavailableIsolationBoundary() + ) + self._verifier = ( + owner_verifier if owner_verifier is not None else VerificationUnavailable() + ) + self._clock = clock + self._grants: dict[UUID, Grant] = {} + self._pending: dict[bytes, _PendingUse] = {} + self._audit: deque[AuditEvent] = deque(maxlen=512) + self._lock = RLock() + + def _event(self, action, context, outcome, grant=None): + # No secret values, capability handles, response text, caller input labels + # or paths. Bounded in-memory host audit; never a consumer subscription. + valid = type(context) is ExecutionContext + with self._lock: + self._audit.append( + AuditEvent( + action, + outcome, + context.binding.binding_id + if valid + else (grant.binding.binding_id if grant else None), + context.execution_id if valid else None, + context.binding.scope + if valid + else (grant.reference.scope if grant else None), + grant.grant_id if grant else None, + ) + ) + + def audit_events(self) -> tuple[AuditEvent, ...]: + """Host owner diagnostics only; persistence/export needs an owner adapter.""" + with self._lock: + return tuple(self._audit) + + def add_profile(self, vault: ProfileVault) -> None: + """Protected owner catalog operation; never a consumer route.""" + with self._lock: + self._directory.require_vault(vault.owner, vault.profile.scope) + if vault.profile.scope in self._vaults: + raise CredentialUseError(UseCode.DENIED) + self._vaults[vault.profile.scope] = vault + + def revoke_scoped(self, grant_id: UUID, scope: VaultScope) -> None: + with self._lock: + grant = self._grants.get(grant_id) + if grant is not None and grant.reference.scope != scope: + raise CredentialUseError(UseCode.DENIED) + self.revoke(grant_id) + + def _prune(self): + now = self._clock() + self._grants = { + key: grant for key, grant in self._grants.items() if grant.expires_at > now + } + self._pending = { + key: pending + for key, pending in self._pending.items() + if pending.expires_at > now and pending.grant_id in self._grants + } + + def _vault(self, context, reference): + if ( + type(context) is not ExecutionContext + or type(reference) is not CredentialReference + ): + raise CredentialUseError(UseCode.DENIED) + self._directory.require_scope(context, reference.scope) + vault = self._vaults.get(reference.scope) + if vault is None: + raise CredentialUseError(UseCode.DENIED) + return vault + + def _isolated(self, context): + # Returning a boolean/attestation is not an implementation of this check. + try: + verified = self._isolation.require_isolated(context) is None + except Exception: + verified = False + if not verified: + raise CredentialUseError(UseCode.ISOLATION) + + def _guard(self, grant, context, vault): + if grant.binding != context.binding: + raise CredentialUseError(UseCode.DENIED) + if self._grants.get(grant.grant_id) != grant: + raise CredentialUseError(UseCode.DENIED) + if self._clock() >= grant.expires_at: + raise CredentialUseError(UseCode.EXPIRED) + if self._registry.require(grant.operation) != grant.helper: + raise CredentialUseError(UseCode.DENIED) + self._directory.require_scope(context, grant.reference.scope) + vault._require_epoch(grant.unlock_epoch) + self._isolated(context) + + @use_boundary + @_audited("approve") + def approve( + self, + context: ExecutionContext, + reference: CredentialReference, + operation: HelperOperation, + *, + mode: GrantMode = GrantMode.ONCE, + lifetime: float = 900, + payload=None, + ) -> Grant: + """Owner channel only: fresh verification for this exact displayed request. + + Verification alone is not consent. The protected owner UI must display + caller/revision/deployment, profile/account, operation AND exact endpoint, + then invoke this method only following the owner's explicit approval. + Never publish it on the agent/app request transport. + """ + _, request_digest = canonical_request(payload) + with self._lock: + if ( + type(mode) is not GrantMode + or type(lifetime) not in (float, int) + or not math.isfinite(lifetime) + or not 0 < lifetime <= 3600 + ): + raise CredentialUseError(UseCode.DENIED) + vault = self._vault(context, reference) + specification = self._registry.require(operation) + self._isolated(context) + try: + verified = self._verifier.verify(vault.owner) is None + except Exception: + verified = False + if not verified: + raise CredentialUseError(UseCode.VERIFICATION) + # Do not pin a slow owner prompt to an obsolete unlock epoch. + with self._directory.hold_current(context), vault._lock: + epoch = vault.status().unlock_epoch + vault._require_epoch(epoch) + with vault._helper_record(reference, epoch) as record: + require_match(specification, record.details) + self._isolated(context) + self._prune() + if len(self._grants) >= 4096: + raise CredentialUseError(UseCode.LIMIT) + grant = Grant( + uuid4(), + context.binding, + reference, + specification, + epoch, + self._clock() + lifetime, + mode, + request_digest if mode is GrantMode.ONCE else None, + ) + self._grants[grant.grant_id] = grant + return grant + + @use_boundary + @_audited("authorize") + def authorize(self, context: ExecutionContext, grant_id: UUID) -> UseCapability: + """Consumer use interface, with context supplied by authenticated host IPC.""" + with self._lock: + grant = self._grants.get(grant_id) + if grant is None: + raise CredentialUseError(UseCode.DENIED) + vault = self._vault(context, grant.reference) + with self._directory.hold_current(context), vault._lock: + self._guard(grant, context, vault) + with vault._helper_record( + grant.reference, grant.unlock_epoch + ) as record: + require_match( + self._registry.require(grant.operation), record.details + ) + self._guard(grant, context, vault) + self._prune() + if len(self._pending) >= 8192: + raise CredentialUseError(UseCode.LIMIT) + capability = UseCapability(secrets.token_urlsafe(32)) + self._pending[self._digest(capability)] = _PendingUse( + grant_id, context, min(self._clock() + 30, grant.expires_at) + ) + return capability + + @use_boundary + def status(self, context: ExecutionContext) -> VaultState: + """Bound profile state only; no key material or owner-unlock operation.""" + if type(context) is not ExecutionContext: + raise CredentialUseError(UseCode.DENIED) + with self._directory.hold_current(context): + vault = self._vaults.get(context.binding.scope) + if vault is None: + raise CredentialUseError(UseCode.DENIED) + return vault.status().state + + @use_boundary + def find(self, context: ExecutionContext) -> tuple[UseOffer, ...]: + """Discover only current approvals for this binding, never all accounts.""" + if type(context) is not ExecutionContext: + raise CredentialUseError(UseCode.DENIED) + with self._lock, self._directory.hold_current(context): + self._prune() + offers = [] + for grant in tuple(self._grants.values()): + if grant.binding != context.binding: + continue + vault = self._vault(context, grant.reference) + with vault._lock: + self._guard(grant, context, vault) + with vault._helper_record( + grant.reference, grant.unlock_epoch + ) as record: + require_match( + self._registry.require(grant.operation), record.details + ) + offers.append( + UseOffer( + grant.grant_id, record.details.summary, grant.operation + ) + ) + return tuple(offers) + + @staticmethod + def _digest(capability): + if type(capability) is not UseCapability: + raise CredentialUseError(UseCode.CAPABILITY) + return hashlib.sha256(capability.handle.encode("ascii")).digest() + + @use_boundary + @_audited("execute") + def execute( + self, + context: ExecutionContext, + capability: UseCapability, + payload=None, + *, + execution=None, + ) -> HelperResult | JsonResult: + from contextlib import ExitStack + + grant = None + payload, request_digest = canonical_request(payload) + try: + with ExitStack() as held: + held.enter_context(self._lock) + digest = self._digest(capability) + pending = self._pending.get(digest) + if pending is None or pending.context != context: + raise CredentialUseError(UseCode.CAPABILITY) + del self._pending[digest] + if self._clock() >= pending.expires_at: + raise CredentialUseError(UseCode.CAPABILITY) + grant = self._grants.get(pending.grant_id) + if grant is None: + raise CredentialUseError(UseCode.DENIED) + if ( + grant.mode is GrantMode.ONCE + and grant.request_digest != request_digest + ): + raise CredentialUseError(UseCode.DENIED) + vault = self._vault(context, grant.reference) + held.enter_context(self._directory.hold_current(context)) + held.enter_context(vault._lock) + self._guard(grant, context, vault) + record = held.enter_context( + vault._helper_record(grant.reference, grant.unlock_epoch) + ) + specification = self._registry.require(grant.operation) + require_match(specification, record.details) + + def before_send(): + if execution is not None and execution.cancelled.is_set(): + raise CredentialUseError(UseCode.INTERRUPTED) + if self._clock() >= pending.expires_at: + raise CredentialUseError(UseCode.CAPABILITY) + self._guard(grant, context, vault) + + def dispatched(): + # Spend one-use authority before releasing the dispatch + # reservation. Other pending capabilities cannot replay it. + if grant.mode is GrantMode.ONCE: + self._retire(grant.grant_id) + held.close() + if execution is not None: + execution.dispatched.set() + + if type(specification) is HttpsFormLogin: + result = _execute_form( + specification, record.fields, before_send, payload + ) + elif type(specification) is HttpsJsonOperation: + result = _execute_json( + specification, + record.fields, + before_send, + payload, + execution=execution, + after_send=dispatched, + ) + else: + if payload not in (None, {"body": None}): + raise CredentialUseError(UseCode.DENIED) + result = _execute_check(specification, record.fields, before_send) + if type(result) not in (HelperResult, JsonResult): + raise CredentialUseError(UseCode.HELPER) + return result + finally: + if grant is not None and grant.mode is GrantMode.ONCE: + with self._lock: + self._retire(grant.grant_id) + + def _retire(self, grant_id): + self._grants.pop(grant_id, None) + self._pending = { + key: value + for key, value in self._pending.items() + if value.grant_id != grant_id + } + + def capability_grant(self, context, capability): + """Host-internal cancellation bookkeeping; possession grants no authority.""" + with self._lock: + pending = self._pending.get(self._digest(capability)) + if pending is None or pending.context != context: + raise CredentialUseError(UseCode.CAPABILITY) + return pending.grant_id + + def revoke(self, grant_id: UUID) -> None: + """Trusted host/owner revocation. Already dispatched requests finish first.""" + with self._lock: + grant = self._grants.pop(grant_id, None) + self._pending = { + key: value + for key, value in self._pending.items() + if value.grant_id != grant_id + } + if grant is not None: + self._event("revoke", None, "revoked", grant) + + @use_boundary + @_audited("cancel") + def cancel(self, context: ExecutionContext, capability: UseCapability) -> None: + """Cancel queued use. A dispatched HTTPS request cannot be retracted.""" + with self._lock: + self._directory.require_current(context) + digest = self._digest(capability) + pending = self._pending.get(digest) + if pending is None or pending.context != context: + raise CredentialUseError(UseCode.CAPABILITY) + del self._pending[digest] diff --git a/app/credentials/browser_helper.py b/app/credentials/browser_helper.py new file mode 100644 index 000000000..aefd35456 --- /dev/null +++ b/app/credentials/browser_helper.py @@ -0,0 +1,294 @@ +"""Reviewed HTTPS form sign-in without putting source values in Playwright. + +No universal form discovery, JavaScript execution, callback URLs or consumers' +browser/CDP handles. A host-installed descriptor fixes the form, destination, +success redirect and exact session-cookie names. Other flows need owner handoff. +""" + +from dataclasses import dataclass +from html.parser import HTMLParser +import http.client +from http.cookies import SimpleCookie +import json +import re +import socket +import ssl +from threading import Timer +import time +from urllib.parse import urlencode, urljoin, urlsplit + +from .contracts import CredentialKind +from .helpers import ( + HttpsCredentialCheck, + JsonResult, + _abort_socket, + _address, + _redact_response, + _remaining, +) +from .secrets import Secret +from .use_errors import CredentialUseError, UseCode, use_boundary + + +@dataclass(frozen=True) +class HttpsFormLogin(HttpsCredentialCheck): + login_page: str = "" + success_url: str = "" + username_field: str = "username" + password_field: str = "password" + csrf_field: str | None = None + cookie_names: tuple[str, ...] = () + + def __post_init__(self): + super().__post_init__() + if self.kind is not CredentialKind.LOGIN: + raise ValueError("invalid_browser_helper") + if self.csrf_field is not None and ( + not isinstance(self.csrf_field, str) or not self.csrf_field + ): + raise ValueError("invalid_browser_fields") + for endpoint in (self.login_page, self.success_url): + candidate = HttpsCredentialCheck(self.operation, self.kind, endpoint) + if candidate.origin != self.origin: + raise ValueError("invalid_browser_origin") + names = (self.username_field, self.password_field) + ( + (self.csrf_field,) if self.csrf_field else () + ) + if len(set(names)) != len(names) or any( + not isinstance(n, str) + or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.-]{0,63}", n) + for n in names + ): + raise ValueError("invalid_browser_fields") + if ( + type(self.cookie_names) is not tuple + or not 1 <= len(self.cookie_names) <= 8 + or len(set(self.cookie_names)) != len(self.cookie_names) + or any( + not re.fullmatch(r"[A-Za-z0-9_\-]{1,128}", n) for n in self.cookie_names + ) + ): + raise ValueError("invalid_browser_cookies") + + +class _Form(HTMLParser): + def __init__(self, specification): + super().__init__(convert_charrefs=True) + self.spec = specification + self.active = False + self.forms = [] + self.current = None + + def handle_starttag(self, tag, attributes): + attrs = dict(attributes) + if tag == "form": + if self.active: + raise ValueError("nested_form") + self.active = ( + attrs.get("method", "get").lower() == "post" + and urljoin(self.spec.login_page, attrs.get("action") or "") + == self.spec.endpoint + ) + self.current = {} if self.active else None + elif tag == "input" and self.active: + name = attrs.get("name") + if name in ( + self.spec.username_field, + self.spec.password_field, + self.spec.csrf_field, + ): + if name in self.current or "disabled" in attrs: + raise ValueError("ambiguous_form") + kind = (attrs.get("type") or "text").lower() + if name == self.spec.password_field and ( + kind != "password" + or "new-password" in (attrs.get("autocomplete") or "") + ): + raise ValueError("not_login_form") + if name == self.spec.username_field and kind not in ("text", "email"): + raise ValueError("not_login_form") + if name == self.spec.csrf_field and kind != "hidden": + raise ValueError("invalid_csrf") + self.current[name] = attrs.get("value") or "" + + def handle_endtag(self, tag): + if tag == "form" and self.active: + self.forms.append(self.current) + self.active = False + + +def _roundtrip(spec, url, method, body, cookies, guard, deadline): + parsed = urlsplit(url) + family, socktype, protocol, sockaddr = _address(parsed.hostname, parsed.port or 443) + guard() + tls = ssl.create_default_context() + tls.set_alpn_protocols(["http/1.1"]) + connection = http.client.HTTPSConnection( + parsed.hostname, parsed.port or 443, context=tls + ) + connection.auto_open = 0 + connection.set_debuglevel(0) + timer = None + try: + with socket.socket(family, socktype, protocol) as transport: + transport.settimeout(_remaining(deadline)) + transport.connect(sockaddr) + transport.settimeout(_remaining(deadline)) + connection.sock = tls.wrap_socket( + transport, server_hostname=parsed.hostname + ) + timer = Timer(_remaining(deadline), _abort_socket, args=(connection.sock,)) + timer.daemon = True + timer.start() + guard() + headers = { + "User-Agent": "CraftBotCredentialHost/1", + "Connection": "close", + "Accept": "text/html", + } + if cookies.reveal(): + headers["Cookie"] = "; ".join(cookies.reveal()) + if method == "POST": + headers.update( + { + "Content-Type": "application/x-www-form-urlencoded", + "Origin": spec.origin, + "Referer": spec.login_page, + } + ) + connection.request( + method, parsed.path or "/", body.reveal() if body else None, headers + ) + with connection.getresponse() as response: + raw = Secret(response.read(512001)) + if len(raw.reveal()) > 512000: + raise CredentialUseError(UseCode.LIMIT) + return response.status, Secret(response.getheaders()), raw + finally: + if timer is not None: + timer.cancel() + connection.close() + + +def _cookies(headers, host): + result = Secret({}) + for name, value in headers.reveal(): + if name.lower() != "set-cookie": + continue + parsed = SimpleCookie() + parsed.load(value) + for key, cookie in parsed.items(): + if ( + not cookie["secure"] + or cookie["path"] not in ("", "/") + or cookie["domain"] + and cookie["domain"].lstrip(".").lower() != host.lower() + ): + continue + if len(cookie.value) > 8192 or any( + ord(c) < 33 or ord(c) > 126 for c in cookie.value + ): + raise CredentialUseError(UseCode.HELPER) + result.reveal()[key] = { + "value": cookie.value, + "httpOnly": bool(cookie["httponly"]), + "sameSite": {"strict": "Strict", "lax": "Lax", "none": "None"}.get( + cookie["samesite"].lower(), "Lax" + ), + } + if cookie["max-age"] or cookie["expires"]: + from email.utils import parsedate_to_datetime + + try: + expires = ( + time.time() + int(cookie["max-age"]) + if cookie["max-age"] + else parsedate_to_datetime(cookie["expires"]).timestamp() + ) + except (ValueError, TypeError, OverflowError): + raise CredentialUseError(UseCode.HELPER) from None + if expires <= time.time(): + result.reveal().pop(key) + else: + result.reveal()[key]["expires"] = expires + return result + + +@use_boundary +def _execute_form(specification, fields: Secret, guard, payload): + if payload not in (None, {"body": None}): + raise CredentialUseError(UseCode.DENIED) + deadline = time.monotonic() + 25 + status, headers, raw = _roundtrip( + specification, + specification.login_page, + "GET", + None, + Secret([]), + guard, + deadline, + ) + if status != 200: + raise CredentialUseError(UseCode.HELPER) + form = _Form(specification) + form.feed(raw.reveal().decode("utf-8")) + required = {specification.username_field, specification.password_field} + if specification.csrf_field: + required.add(specification.csrf_field) + if len(form.forms) != 1 or set(form.forms[0]) != required: + raise CredentialUseError(UseCode.DENIED) + data = Secret( + { + specification.username_field: fields.reveal()["username"], + specification.password_field: fields.reveal()["password"], + } + ) + if specification.csrf_field: + token = form.forms[0][specification.csrf_field] + if not token or len(token) > 8192: + raise CredentialUseError(UseCode.DENIED) + data.reveal()[specification.csrf_field] = token + host = urlsplit(specification.endpoint).hostname + initial = _cookies(headers, host) + jar = SimpleCookie() + for name, item in initial.reveal().items(): + jar[name] = item["value"] + cookies = Secret([cookie.OutputString(attrs=[]) for cookie in jar.values()]) + status, headers, _ = _roundtrip( + specification, + specification.endpoint, + "POST", + Secret(urlencode(data.reveal()).encode("utf-8")), + cookies, + guard, + deadline, + ) + locations = [v for k, v in headers.reveal() if k.lower() == "location"] + if ( + status not in (302, 303) + or len(locations) != 1 + or urljoin(specification.endpoint, locations[0]) != specification.success_url + ): + return JsonResult( + 409, '{"outcome":"owner_handoff_required"}', "application/json" + ) + session = _cookies(headers, host) + if not all(name in session.reveal() for name in specification.cookie_names): + return JsonResult( + 409, '{"outcome":"owner_handoff_required"}', "application/json" + ) + values = [ + { + "name": name, + **session.reveal()[name], + "url": specification.origin + "/", + "secure": True, + } + for name in specification.cookie_names + ] + output = json.dumps({"cookies": values, "continue_url": specification.success_url}) + if _redact_response(output, fields) != output: + raise CredentialUseError( + UseCode.HELPER + ) # Never export a source echo as a cookie. + return JsonResult(200, output, "application/json") diff --git a/app/credentials/browser_migration.py b/app/credentials/browser_migration.py new file mode 100644 index 000000000..4c572a75d --- /dev/null +++ b/app/credentials/browser_migration.py @@ -0,0 +1,169 @@ +"""Owner-only offline retirement of the three current legacy browser files. + +Encrypted legacy history is archived under the SYSTEM state root. Its old key +is itself encrypted inside a non-usable vault recovery item, never a key file. +The metadata journal makes source deletion resumable after destination proof. +""" + +import base64 +import hashlib +import hmac +import json +import os +from pathlib import Path +from uuid import uuid5 + +from .contracts import CredentialKind +from .errors import VaultError, VaultErrorCode as Code +from .migration import migrate_browser_document +from .migration_source import OwnerSettingsSource +from .references import parse +from .secrets import Secret +from .windows_host import private_acl + +FILES = ( + "mini_browser_vault.enc", + "mini_browser_vault.enc.bak", + "mini_browser_vault.key", +) + + +def migrate_browser_files(vault, path, state: Path, owner_sid, session): + from cryptography.fernet import Fernet + + path = Path(path) + if path.name != FILES[0]: + raise VaultError(Code.INVALID) + source_id = hashlib.sha256(str(path).casefold().encode("utf-8")).hexdigest() + archive = state / "legacy-browser" / str(vault.profile.scope.profile_id) / source_id + archive.mkdir(parents=True, exist_ok=True) + private_acl(archive) + journal = archive / "migration.json" + sources = {} + try: + if journal.exists(): + proof = json.loads(journal.read_text(encoding="utf-8")) + else: + raw = {} + for name in FILES: + source = OwnerSettingsSource( + path.with_name(name), owner_sid, session, filename=name + ) + if name == FILES[1]: + with source._owner(): + if not source.path.exists(): + continue + sources[name] = source + raw[name] = source.read_bytes(32_000_000 if name != FILES[2] else 65536) + scheme, separator, wrapped = raw[FILES[2]].reveal().strip().partition(b":") + if not separator or not wrapped: + raise VaultError(Code.UNREADABLE) + if scheme == b"raw": + key = Secret(wrapped) + elif scheme == b"dpapi": + import win32crypt + + with sources[FILES[2]]._owner(): + key = Secret( + win32crypt.CryptUnprotectData( + base64.b64decode(wrapped, validate=True), + b"CraftBot/mini-browser/vault-key/v1", + None, + None, + 1, + )[1] + ) + else: + raise VaultError(Code.UNREADABLE) + plaintext = Secret(Fernet(key.reveal()).decrypt(raw[FILES[0]].reveal())) + mapping = migrate_browser_document( + vault, source_id, Secret(json.loads(plaintext.reveal())) + ) + recovery = vault.import_source( + uuid5(vault.profile.scope.profile_id, source_id + "/recovery-key"), + CredentialKind.SERVICE_SECRET, + "Legacy browser recovery", + source_id[:12], + Secret({"legacy_key": key.reveal().decode("ascii")}), + allowed_origins=(), + ) + # Archive authenticated ciphertext only. The old source key never + # appears in this directory or in the metadata deletion journal. + hashes = {} + for name, contents in raw.items(): + hashes[name] = hashlib.sha256(contents.reveal()).hexdigest() + if name == FILES[2]: + continue + target = archive / name + with target.open("wb") as stream: + stream.write(contents.reveal()) + stream.flush() + os.fsync(stream.fileno()) + private_acl(target) + if hashlib.sha256(target.read_bytes()).hexdigest() != hashes[name]: + raise VaultError(Code.IO) + proof = { + "version": 1, + "profile_id": str(vault.profile.scope.profile_id), + "mapping": mapping, + "files": hashes, + "recovery_id": str(recovery.summary.reference.credential_id), + } + temporary = archive / "migration.new" + with temporary.open("w", encoding="utf-8") as stream: + stream.write(json.dumps(proof)) + stream.flush() + os.fsync(stream.fileno()) + private_acl(temporary) + import win32file + + win32file.MoveFileEx(str(temporary), str(journal), 1 | 8) + if ( + proof["version"] != 1 + or proof["profile_id"] != str(vault.profile.scope.profile_id) + or set(proof["files"]) - set(FILES) + ): + raise VaultError(Code.UNREADABLE) + current = { + str(i.summary.reference.credential_id): i.summary.reference.version + for i in vault.list_items() + } + for reference in proof["mapping"].values(): + ref = parse(reference) + if ( + ref.profile_id != vault.profile.scope.profile_id + or current.get(str(ref.credential_id)) != ref.version + ): + raise VaultError(Code.CONFLICT) + if current.get(proof["recovery_id"]) != 1: + raise VaultError(Code.CONFLICT) + for name, expected in proof["files"].items(): + if ( + name != FILES[2] + and hashlib.sha256((archive / name).read_bytes()).hexdigest() + != expected + ): + raise VaultError(Code.UNREADABLE) + source = sources.get(name) + if source is None: + source = OwnerSettingsSource( + path.with_name(name), owner_sid, session, filename=name + ) + sources[name] = source + with source._owner(): + if not source.path.exists(): + continue # Earlier verified retirement completed this file. + contents = Secret(source.path.read_bytes()) + if not hmac.compare_digest( + hashlib.sha256(contents.reveal()).hexdigest(), expected + ): + raise VaultError(Code.CONFLICT) + source.path.unlink() + return { + "imported": len(proof["mapping"]), + "mapping_retained_by_host": True, + "restart_required": True, + } + finally: + for source in sources.values(): + source.close() diff --git a/app/credentials/catalog.py b/app/credentials/catalog.py new file mode 100644 index 000000000..c16e2d354 --- /dev/null +++ b/app/credentials/catalog.py @@ -0,0 +1,540 @@ +"""Immutable host helper catalog. No provider discovery from agent settings.""" + +from .contracts import CredentialKind, HelperOperation +from .helpers import HttpsCredentialCheck, HttpsJsonOperation + + +def specification(item): + if type(item) is not dict: + raise ValueError("invalid_helper") + common = {"helper", "operation", "kind", "endpoint", "secret_field"} + form_fields = { + "login_page", + "success_url", + "username_field", + "password_field", + "csrf_field", + "cookie_names", + } + extra = { + "type", + "method", + "auth_header", + "auth_prefix", + "protocol", + "query_fields", + "path_fields", + "envelope", + "request_format", + "response_format", + "max_response_bytes", + "header_fields", + } | form_fields + if set(item) - common - extra: + raise ValueError("invalid_helper") + arguments = ( + HelperOperation(item["helper"], item["operation"]), + CredentialKind(item["kind"]), + item["endpoint"], + item.get("secret_field"), + ) + if item.get("type") == "form" and not ( + set(item) & {"method", "auth_header", "auth_prefix", "protocol", "query_fields"} + ): + from .browser_helper import HttpsFormLogin + + if type(item.get("cookie_names")) is not list: + raise ValueError("invalid_browser_cookies") + + return HttpsFormLogin( + *arguments, + **{ + key: tuple(value) if key == "cookie_names" else value + for key, value in item.items() + if key in form_fields + }, + ) + if item.get("type", "check") == "check" and not (set(item) & (extra - {"type"})): + return HttpsCredentialCheck(*arguments) + if item.get("type") == "json": + if set(item) & form_fields: + raise ValueError("invalid_helper") + if "query_fields" in item and type(item["query_fields"]) is not list: + raise ValueError("invalid_helper_query") + return HttpsJsonOperation( + *arguments, + method=item.get("method", "POST"), + auth_header=item.get("auth_header", "Authorization"), + auth_prefix=item.get("auth_prefix", "Bearer "), + protocol=item.get("protocol", "json"), + query_fields=tuple(item.get("query_fields", [])), + path_fields=tuple(item.get("path_fields", [])), + envelope=item.get("envelope", False), + request_format=item.get("request_format", "json"), + response_format=item.get("response_format", "json"), + max_response_bytes=item.get("max_response_bytes", 2_000_000), + header_fields=tuple(item.get("header_fields", [])), + ) + raise ValueError("invalid_helper") + + +def model_helpers(): + # Endpoints match the provider factory. Custom hosts require an explicit + # reviewed installation descriptor; mutable settings cannot install helpers. + result = [] + for provider, base, protocol in ( + ("openai", "https://api.openai.com/v1", "openai"), + ("openrouter", "https://openrouter.ai/api/v1", "openai"), + ("anthropic", "https://api.anthropic.com/v1", "anthropic"), + ("byteplus", "https://ark.ap-southeast.bytepluses.com/api/v3", "openai"), + ("minimax", "https://api.minimax.io/v1", "openai"), + ("deepseek", "https://api.deepseek.com", "openai"), + ("moonshot", "https://api.moonshot.ai/v1", "openai"), + ("grok", "https://api.x.ai/v1", "openai"), + ("glm", "https://api.z.ai/api/paas/v4", "openai"), + ("fugu", "https://api.sakana.ai/v1", "openai"), + ("groq", "https://api.groq.com/openai/v1", "openai"), + ("mistral", "https://api.mistral.ai/v1", "openai"), + ("together", "https://api.together.ai/v1", "openai"), + ("fireworks", "https://api.fireworks.ai/inference/v1", "openai"), + ("cerebras", "https://api.cerebras.ai/v1", "openai"), + ("qwen", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "openai"), + ("huggingface", "https://router.huggingface.co/v1", "openai"), + ("nvidia", "https://integrate.api.nvidia.com/v1", "openai"), + ("perplexity", "https://api.perplexity.ai", "openai"), + ): + routes = ( + (("messages", "POST"), ("models", "GET")) + if protocol == "anthropic" + else ( + ("chat/completions", "POST"), + ("responses", "POST"), + ("models", "GET"), + ("embeddings", "POST"), + ("images/generations", "POST"), + ) + ) + for path, method in routes: + result.append( + { + "type": "json", + "helper": f"model_{provider}", + "operation": path.replace("/", "_"), + "kind": "api_key", + "endpoint": f"{base}/{path}", + "method": method, + "max_response_bytes": 64_000_000 + if path == "images/generations" + else 2_000_000, + "protocol": protocol, + "auth_header": "x-api-key" + if protocol == "anthropic" + else "Authorization", + "auth_prefix": "" if protocol == "anthropic" else "Bearer ", + } + ) + for model in ( + "gemini-2.0-flash", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-3-pro-image", + ): + result.append( + { + "type": "json", + "helper": "model_gemini", + "operation": f"generate_{model}", + "kind": "api_key", + "endpoint": f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent", + "max_response_bytes": 64_000_000, + "auth_header": "x-goog-api-key", + "auth_prefix": "", + } + ) + result.append( + { + "type": "json", + "helper": "model_gemini", + "operation": "embed_text-embedding-004", + "kind": "api_key", + "endpoint": "https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent", + "auth_header": "x-goog-api-key", + "auth_prefix": "", + } + ) + result.append( + { + "type": "json", + "helper": "model_gemini", + "operation": "create_cache", + "kind": "api_key", + "endpoint": "https://generativelanguage.googleapis.com/v1beta/cachedContents", + "auth_header": "x-goog-api-key", + "auth_prefix": "", + } + ) + result.append( + { + "type": "json", + "helper": "model_gemini", + "operation": "models", + "kind": "api_key", + "endpoint": "https://generativelanguage.googleapis.com/v1beta/models", + "method": "GET", + "auth_header": "x-goog-api-key", + "auth_prefix": "", + } + ) + for operation, path in (("credits", "credits"), ("auth_key", "auth/key")): + result.append( + { + "type": "json", + "helper": "model_openrouter", + "operation": operation, + "kind": "api_key", + "endpoint": f"https://openrouter.ai/api/v1/{path}", + "method": "GET", + } + ) + result.append( + { + "type": "json", + "helper": "model_google", + "operation": "custom_search", + "kind": "api_key", + "endpoint": "https://customsearch.googleapis.com/customsearch/v1", + "method": "GET", + "query_fields": ["q", "cx", "num"], + "auth_header": "x-goog-api-key", + "auth_prefix": "", + } + ) + result.append( + { + "type": "json", + "helper": "model_bedrock", + "operation": "converse_default", + "kind": "service_secret", + "secret_field": "secret_access_key", + "protocol": "aws_bedrock", + "endpoint": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-haiku-4-5-20251001-v1:0/converse", + } + ) + return result + media_helpers() + + +def media_helpers(): + result = [] + + def route(provider, operation, endpoint, method="POST", **extra): + from .route_policy import path_names + + result.append( + { + "type": "json", + "helper": "model_" + provider, + "operation": operation, + "kind": "api_key", + "endpoint": endpoint, + "method": method, + "envelope": True, + "path_fields": list(path_names(endpoint)), + **extra, + } + ) + + for action in ("generateContent", "embedContent", "predict", "predictLongRunning"): + route( + "gemini", + "model_" + action, + "https://generativelanguage.googleapis.com/v1beta/models/:model:" + action, + auth_header="x-goog-api-key", + auth_prefix="", + max_response_bytes=64_000_000, + ) + for path in ( + "/operations/:operation_id", + "/models/:model/operations/:operation_id", + ): + route( + "gemini", + "poll_" + str(len(result)), + "https://generativelanguage.googleapis.com/v1beta" + path, + "GET", + auth_header="x-goog-api-key", + auth_prefix="", + ) + route( + "gemini", + "delete_cache", + "https://generativelanguage.googleapis.com/v1beta/cachedContents/:cache_id", + "DELETE", + auth_header="x-goog-api-key", + auth_prefix="", + ) + for path in ( + "/v1beta/files/:file_id:download", + "/download/v1beta/files/:file_id:download", + "/v1beta/files/:file_id", + ): + route( + "gemini", + "download_" + str(len(result)), + "https://generativelanguage.googleapis.com" + path, + "GET", + auth_header="x-goog-api-key", + auth_prefix="", + query_fields=["alt"], + response_format="binary", + max_response_bytes=64_000_000, + ) + route( + "openai", + "images_edits", + "https://api.openai.com/v1/images/edits", + request_format="multipart", + max_response_bytes=64_000_000, + ) + route( + "openai", + "audio_transcriptions", + "https://api.openai.com/v1/audio/transcriptions", + request_format="multipart", + response_format="text", + ) + route( + "openai", + "audio_speech", + "https://api.openai.com/v1/audio/speech", + response_format="binary", + max_response_bytes=64_000_000, + ) + route( + "byteplus", + "video_submit", + "https://ark.ap-southeast.bytepluses.com/api/v3/contents/generations/tasks", + ) + route( + "byteplus", + "video_poll", + "https://ark.ap-southeast.bytepluses.com/api/v3/contents/generations/tasks/:task_id", + "GET", + ) + return result + + +def integration_helpers(): + result = [ + { + "type": "json", + "helper": f"integration_{provider}", + "operation": "verify", + "kind": "api_key", + "endpoint": endpoint, + "method": "GET", + "auth_header": header, + "auth_prefix": prefix, + } + for provider, endpoint, header, prefix in ( + ("github", "https://api.github.com/user", "Authorization", "Bearer "), + ("figma", "https://api.figma.com/v1/me", "X-Figma-Token", ""), + ("slack", "https://slack.com/api/auth.test", "Authorization", "Bearer "), + ) + ] + result.extend(authentication_helpers()) + result.append( + { + "type": "json", + "helper": "integration_figma", + "operation": "get_figma_current_user", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/me", + "method": "GET", + "auth_header": "X-Figma-Token", + "auth_prefix": "", + } + ) + for name, path, query in ( + ( + "list_slack_channels", + "conversations.list", + ["types", "limit", "exclude_archived", "cursor"], + ), + ("get_slack_channel_info", "conversations.info", ["channel"]), + ( + "get_slack_channel_history", + "conversations.history", + ["channel", "limit", "oldest", "latest", "cursor"], + ), + ("list_slack_users", "users.list", ["limit", "cursor"]), + ("get_slack_user_info", "users.info", ["user"]), + ( + "get_slack_thread_replies", + "conversations.replies", + ["channel", "ts", "limit", "cursor"], + ), + ): + result.append( + { + "type": "json", + "helper": "integration_slack", + "operation": name, + "kind": "api_key", + "endpoint": f"https://slack.com/api/{path}", + "method": "GET", + "query_fields": query, + } + ) + import json + from pathlib import Path + + for provider, endpoint, prefix in ( + ("discord", "https://discord.com/api/v10/users/@me", "Bot "), + ("stripe", "https://api.stripe.com/v1/account", "Bearer "), + ("notion", "https://api.notion.com/v1/users/me", "Bearer "), + ("hubspot", "https://api.hubapi.com/account-info/v3/details", "Bearer "), + ("posthog", "https://us.posthog.com/api/users/@me/", "Bearer "), + ("supabase", "https://api.supabase.com/v1/profile", "Bearer "), + ("line", "https://api.line.me/v2/bot/info", "Bearer "), + ("telegram_bot", "https://api.telegram.org/bot_TOKEN_/getMe", "Bearer "), + ): + result.append( + { + "type": "json", + "helper": "integration_" + provider, + "operation": "verify", + "kind": "api_key", + "endpoint": endpoint, + "method": "GET", + "auth_prefix": prefix, + **({"protocol": "telegram"} if provider == "telegram_bot" else {}), + } + ) + result.append( + { + "type": "json", + "helper": "integration_jira", + "operation": "verify", + "kind": "service_secret", + "secret_field": "api_token", + "protocol": "basic", + "endpoint": "https://jira.example.invalid/rest/api/3/myself", + "method": "GET", + } + ) + result.append( + { + "type": "json", + "helper": "integration_whatsapp_business", + "operation": "verify", + "kind": "api_key", + "endpoint": "https://graph.facebook.com/v21.0/:phone_number_id", + "method": "GET", + "envelope": True, + "path_fields": ["phone_number_id"], + } + ) + result.append( + { + "type": "json", + "helper": "integration_discord", + "operation": "gateway_events", + "kind": "api_key", + "endpoint": "https://gateway.discord.gg/", + "method": "GET", + "protocol": "discord_gateway", + } + ) + return ( + result + + json.loads( + Path(__file__).with_name("provider_routes.json").read_text(encoding="utf-8") + ) + + oauth_helpers() + ) + + +def oauth_helpers(): + # Match confidential-client exchanges already performed by shipped clients. + # Token persistence remains exclusively with those clients. + result = [] + for provider, endpoint, basic, json_body in ( + ("google", "https://oauth2.googleapis.com/token", False, False), + ("slack", "https://slack.com/api/oauth.v2.access", False, False), + ("linkedin", "https://www.linkedin.com/oauth/v2/accessToken", False, False), + ("hubspot", "https://api.hubapi.com/oauth/v1/token", False, False), + ("figma", "https://api.figma.com/v1/oauth/token", True, False), + ("figma", "https://api.figma.com/v1/oauth/refresh", True, False), + ("notion", "https://api.notion.com/v1/oauth/token", True, True), + ("supabase", "https://api.supabase.com/v1/oauth/token", True, False), + ("line", "https://api.line.me/oauth2/v3/token", False, False), + ): + result.append( + { + "type": "json", + "helper": "oauth_" + provider, + "operation": "exchange_" + str(len(result)), + "kind": "api_key", + "endpoint": endpoint, + "method": "POST", + "envelope": True, + "request_format": "json" if json_body else "form", + "protocol": "oauth_basic" + if basic + else "oauth_json" + if json_body + else "oauth_form", + } + ) + return result + + +def authentication_helpers(): + result = [ + { + "type": "json", + "helper": "integration_twitter", + "operation": "verify", + "kind": "service_secret", + "secret_field": "api_secret", + "endpoint": "https://api.twitter.com/2/users/me", + "method": "GET", + "protocol": "twitter_oauth1", + "envelope": True, + } + ] + for operation, protocol, endpoint in ( + ( + "tenant_token", + "lark_token", + "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal", + ), + ( + "websocket_endpoint", + "lark_websocket", + "https://open.larksuite.com/callback/ws/endpoint", + ), + ): + result.append( + { + "type": "json", + "helper": "auth_lark", + "operation": operation, + "kind": "api_key", + "endpoint": endpoint, + "protocol": protocol, + } + ) + for operation in ("send_code", "complete", "qr_login", "complete_password"): + result.append( + { + "type": "json", + "helper": "auth_telegram_user", + "operation": operation, + "kind": "service_secret", + "secret_field": "api_hash", + "endpoint": "https://api.telegram.org/mtproto/" + operation, + "protocol": "telegram_mtproto", + } + ) + return result diff --git a/app/credentials/client.py b/app/credentials/client.py new file mode 100644 index 000000000..bb6eae4ee --- /dev/null +++ b/app/credentials/client.py @@ -0,0 +1,320 @@ +"""Secret-free Windows client, also copied into isolated code snapshots. + +No vault imports, key resolver, HTTP transport or configurable server address. +The pipe must be owned by SYSTEM; unprivileged squatters cannot forge that +kernel owner. No SCM privileges or session/bridge tokens are needed in a sandbox. +""" + +from __future__ import annotations + +import ctypes as C +from ctypes import wintypes as W +import json +import os +import struct +import time + +PIPE = r"\\.\pipe\LOCAL\CraftBot.Credentials.v1" +SERVICE = "CraftBotCredentialHost" + + +class CredentialHostUnavailable(RuntimeError): + pass + + +def _dlls(): + if os.name != "nt": + raise CredentialHostUnavailable("credential_host_unavailable") + kernel = C.WinDLL("kernel32", use_last_error=True) + advapi = C.WinDLL("advapi32", use_last_error=True) + kernel.CreateFileW.argtypes = [ + W.LPCWSTR, + W.DWORD, + W.DWORD, + C.c_void_p, + W.DWORD, + W.DWORD, + W.HANDLE, + ] + kernel.CreateFileW.restype = W.HANDLE + kernel.GetNamedPipeServerProcessId.argtypes = [W.HANDLE, C.POINTER(W.DWORD)] + kernel.ReadFile.argtypes = [ + W.HANDLE, + C.c_void_p, + W.DWORD, + C.POINTER(W.DWORD), + C.c_void_p, + ] + kernel.WriteFile.argtypes = kernel.ReadFile.argtypes + kernel.CloseHandle.argtypes = [W.HANDLE] + advapi.GetSecurityInfo.argtypes = [ + W.HANDLE, + C.c_int, + W.DWORD, + C.POINTER(C.c_void_p), + C.c_void_p, + C.c_void_p, + C.c_void_p, + C.POINTER(C.c_void_p), + ] + advapi.ConvertSidToStringSidW.argtypes = [C.c_void_p, C.POINTER(W.LPWSTR)] + kernel.LocalFree.argtypes = [C.c_void_p] + return kernel, advapi + + +def _require_system_pipe(kernel, advapi, handle): + owner, descriptor = C.c_void_p(), C.c_void_p() + text = W.LPWSTR() + try: + if ( + advapi.GetSecurityInfo( + handle, 6, 1, C.byref(owner), None, None, None, C.byref(descriptor) + ) + != 0 + or not advapi.ConvertSidToStringSidW(owner, C.byref(text)) + or text.value != "S-1-5-18" + ): + raise CredentialHostUnavailable("credential_host_identity_mismatch") + finally: + if text: + kernel.LocalFree(text) + if descriptor: + kernel.LocalFree(descriptor) + + +def _request(operation: str, **parameters): + """Only metadata/references/capabilities. Context comes from the kernel peer.""" + handle = None + try: + data = json.dumps({"op": operation, **parameters}, ensure_ascii=True).encode( + "utf-8" + ) + if len(data) > 65536 or "op" in parameters: + raise CredentialHostUnavailable("credential_request_invalid") + kernel, advapi = _dlls() + # Exact data rights, not GENERIC_WRITE/FILE_CREATE_PIPE_INSTANCE. + # Identification SQOS prevents impersonation by a squatting server. + deadline = time.monotonic() + 305 + while True: + handle = kernel.CreateFileW( + PIPE, 3 | 0x120000, 0, None, 3, 0x100000 | 0x10000, None + ) + if handle != W.HANDLE(-1).value: + break + handle = None + # A missing service fails immediately. A busy protected host may be + # running an owner prompt or HTTPS operation (bounded by its 120s + # watchdog); polling controllers must not fail after five seconds. + if C.get_last_error() != 231 or time.monotonic() >= deadline: + raise CredentialHostUnavailable("credential_host_unavailable") + time.sleep(0.05) + server = W.DWORD() + if ( + not kernel.GetNamedPipeServerProcessId(handle, C.byref(server)) + or not server.value + ): + raise CredentialHostUnavailable("credential_host_identity_mismatch") + _require_system_pipe(kernel, advapi, handle) + transferred = W.DWORD() + frame = struct.pack("= deadline + ): + raise + time.sleep(0.1) + + +def use( + helper: str, + operation: str, + credential_id: str | None = None, + *, + credential_version=None, + payload=None, +): + staged = payload is None or type(payload) is dict and set(payload) == {"body"} + try: + if staged: + serialized = json.dumps( + payload["body"] if payload is not None else None, + ensure_ascii=True, + allow_nan=False, + separators=(",", ":"), + ) + if len(serialized) > 32_000_000: + raise CredentialHostUnavailable("credential_use_limit") + for offset in range(0, len(serialized), 8000): + chunk = serialized[offset : offset + 8000] + request( + "upload_payload_part", + offset=offset, + data=chunk, + done=offset + len(chunk) == len(serialized), + ) + return _use( + helper, operation, credential_id, credential_version, payload, staged + ) + except BaseException: + if staged: + try: + request("discard_payload") + except Exception: + pass + raise + + +def _use(helper, operation, credential_id, credential_version, payload, staged): + capability = request( + "request_use", + helper=helper, + operation=operation, + credential_id=credential_id, + **( + {"credential_version": credential_version} + if credential_version is not None + else {} + ), + ) + if staged: + request("start_use_staged", capability=capability["capability"]) + deadline = time.monotonic() + 900 + while time.monotonic() < deadline: + status = request("operation_status") + if status["done"]: + if status.get("error"): + raise CredentialHostUnavailable(status["error"]) + return {"outcome": status.get("outcome", "completed")} + time.sleep(0.05) + raise CredentialHostUnavailable("credential_operation_timeout") + raise CredentialHostUnavailable("credential_request_invalid") + + +def operations(): + """Page the public names without overflowing the bounded pipe frame.""" + result, offset = [], 0 + while offset <= 1024: + page = request("operations", offset=offset) + result.extend(page["operations"]) + if not page["more"]: + return result + offset += 32 + raise CredentialHostUnavailable("credential_response_invalid") + + +def find(): + result, offset = [], 0 + while offset <= 4096: + page = request("find", offset=offset) + result.extend(page["offers"]) + if not page["more"]: + return result + offset += 8 + raise CredentialHostUnavailable("credential_response_invalid") + + +def complete(result): + return request("complete", result=result) + + +def action_profile(): + """Owner-selected service preference; never read from action/bundle input.""" + from uuid import UUID + + value = request("action_profile")["profile_id"] + return str(UUID(value)) if value is not None else None + + +def request_entry(helper: str, operation: str, account: str): + """Ask the owner for protected entry; returns a reference, never values.""" + return request("request_entry", helper=helper, operation=operation, account=account) + + +def use_json(helper, operation, credential_id, *, credential_version, body=None): + """For isolated workers only. Releases bounded, host-sanitized output.""" + use( + helper, + operation, + credential_id, + credential_version=credential_version, + payload={"body": body}, + ) + result = request("read_response", offset=0) + chunks, offset = [], 0 + length = result["length"] + if type(length) is not int or not 0 <= length <= 96_000_000: + raise CredentialHostUnavailable("credential_response_invalid") + while offset < length: + chunk = result["chunk"] + if not isinstance(chunk, str) or not chunk or len(chunk) > 8000: + raise CredentialHostUnavailable("credential_response_invalid") + chunks.append(chunk) + offset += len(chunk) + if offset < length: + result = request("read_response", offset=offset) + return { + "status": result["status"], + "content_type": result["content_type"], + "body": "".join(chunks), + } diff --git a/app/credentials/consumer_runtime.py b/app/credentials/consumer_runtime.py new file mode 100644 index 000000000..81f3f9525 --- /dev/null +++ b/app/credentials/consumer_runtime.py @@ -0,0 +1,65 @@ +"""Launch credential-enabled Python code through the protected host only.""" + +import asyncio +import json +import time + +from .client import CredentialHostUnavailable, request + + +async def execute_action( + code: str, input_data: dict, *, profile_id: str, subject: str, timeout: int +): + # This wrapper is untrusted consumer code. All authority still comes from + # the native launch, kernel peer/job checks and explicit native owner consent. + data = json.dumps(input_data) + source = ( + "code = " + + repr(code) + + "\ndata = " + + repr(data) + + "\n" + + """import asyncio, inspect, json +import credential_client +namespace = {'__name__': '__credential_action__'} +try: + exec(code, namespace, namespace) + function = next(value for value in namespace.values() if inspect.isfunction(value) and value.__globals__ is namespace) + result = function(json.loads(data)) + if inspect.isawaitable(result): + result = asyncio.run(result) + credential_client.complete(result) +except BaseException: + credential_client.complete({'status': 'error', 'message': 'Isolated action failed'}) +""" + ) + launch = await asyncio.to_thread( + request, + "launch", + profile_id=profile_id, + kind="agent_session", + subject=subject, + deployment=None, + files={"action.py": source}, + entry="action.py", + ) + deadline = time.monotonic() + min(timeout, 900) + try: + while time.monotonic() < deadline: + result = await asyncio.to_thread( + request, "program_result", run_id=launch["run_id"] + ) + if "result" in result: + return result["result"] + if "exited" in result: + return { + "status": "error", + "message": "Isolated credential action exited without a result", + } + await asyncio.sleep(0.2) + return {"status": "error", "message": "Isolated credential action timed out"} + finally: + try: + await asyncio.to_thread(request, "stop", run_id=launch["run_id"]) + except CredentialHostUnavailable: + pass # Host restart/lock already killed the job; no legacy fallback. diff --git a/app/credentials/contracts.py b/app/credentials/contracts.py new file mode 100644 index 000000000..b4b17aa0e --- /dev/null +++ b/app/credentials/contracts.py @@ -0,0 +1,85 @@ +"""Secret-free consumer/helper-boundary contracts for CM-01. + +No implementation here authenticates callers, unlocks a vault, executes a +helper, or reads a credential. Those require CM-02/03 and OS-enforced isolation. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Protocol +from uuid import UUID + +from .identity import ExecutionContext, VaultScope, _label, _uuid + + +class CredentialKind(str, Enum): + LOGIN = "login" + API_KEY = "api_key" + SERVICE_SECRET = "service_secret" + + +@dataclass(frozen=True) +class CredentialReference: + scope: VaultScope + credential_id: UUID + version: int + + def __post_init__(self) -> None: + if not isinstance(self.scope, VaultScope): + raise ValueError("A vault scope is required") + _uuid(self.credential_id) + if type(self.version) is not int or self.version < 1: + raise ValueError("A positive credential version is required") + + +@dataclass(frozen=True) +class CredentialSummary: + reference: CredentialReference + kind: CredentialKind + service: str + account_label: str + + def __post_init__(self) -> None: + if not isinstance(self.reference, CredentialReference) or not isinstance( + self.kind, CredentialKind + ): + raise ValueError("A credential reference and kind are required") + _label(self.service) + _label(self.account_label) + + +@dataclass(frozen=True) +class HelperOperation: + """Registered operation identifier, never a module path or callback.""" + + helper_id: str + operation: str + + def __post_init__(self) -> None: + _label(self.helper_id) + _label(self.operation) + + +class IsolationBoundary(Protocol): + """Trusted host/platform adapter; never implemented by agent/app code. + + Must reject use unless code integrity, filesystem/process separation and + authenticated channel binding are enforced. Missing support fails closed. + A separate PID, venv, OS-user encryption or a context variable is not proof. + """ + + def require_isolated(self, context: ExecutionContext) -> None: ... + + +class IsolationUnavailable(RuntimeError): + def __init__(self) -> None: + super().__init__("credential_isolation_unavailable") + + +class UnavailableIsolationBoundary: + """Default outside the installed Windows SYSTEM host; no permissive fallback.""" + + def require_isolated(self, context: ExecutionContext) -> None: + raise IsolationUnavailable() diff --git a/app/credentials/directory.py b/app/credentials/directory.py new file mode 100644 index 000000000..32d9d9a93 --- /dev/null +++ b/app/credentials/directory.py @@ -0,0 +1,124 @@ +"""In-memory, host-owned identity directory; persistence belongs to CM-02. + +Not a consumer API. Its caller must already be a trusted host component. +This directory neither verifies OS identities nor grants credential access. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from threading import RLock +from uuid import UUID + +from .identity import ( + CallerBinding, + CallerIdentity, + ExecutionContext, + VaultOwner, + VaultProfile, + VaultScope, +) + + +class IdentityError(ValueError): + """Fixed, value-free identity errors, safe for a host adapter to map.""" + + +class IdentityDirectory: + def __init__(self) -> None: + self._owners: dict[UUID, VaultOwner] = {} + self._profiles: dict[UUID, VaultProfile] = {} + self._bindings: dict[UUID, CallerBinding] = {} + self._revoked: set[UUID] = set() + self._lock = RLock() + + def register_owner(self, owner: VaultOwner) -> None: + with self._lock: + existing = self._owners.get(owner.owner_id) + if existing is not None and existing != owner: + raise IdentityError("owner_conflict") + if any( + other.os_identity == owner.os_identity and other != owner + for other in self._owners.values() + ): + raise IdentityError("owner_conflict") + self._owners[owner.owner_id] = owner + + def register_profile(self, profile: VaultProfile) -> None: + with self._lock: + if profile.scope.owner_id not in self._owners: + raise IdentityError("owner_unknown") + existing = self._profiles.get(profile.scope.profile_id) + if existing is not None and existing != profile: + raise IdentityError("profile_conflict") + self._profiles[profile.scope.profile_id] = profile + + def bind(self, binding: CallerBinding) -> None: + """Register an explicitly authorized channel/profile association. + + Does not create a grant. A different profile/revision/runtime requires + a new binding; labels, parent sessions and app manifests confer none. + """ + with self._lock: + if binding.binding_id in self._revoked: + raise IdentityError("binding_revoked") + profile = self._profiles.get(binding.scope.profile_id) + if profile is None or profile.scope != binding.scope: + raise IdentityError("profile_unknown") + existing = self._bindings.get(binding.binding_id) + if existing is not None and existing != binding: + raise IdentityError("binding_conflict") + self._bindings[binding.binding_id] = binding + + def require_vault(self, owner: VaultOwner, scope: VaultScope) -> None: + """Reject host bootstrap wiring to a different OS owner/catalog scope.""" + with self._lock: + if self._owners.get(owner.owner_id) != owner: + raise IdentityError("owner_unknown") + profile = self._profiles.get(scope.profile_id) + if ( + owner.owner_id != scope.owner_id + or profile is None + or profile.scope != scope + ): + raise IdentityError("scope_denied") + + def resolve( + self, binding_id: UUID, authenticated_caller: CallerIdentity, execution_id: UUID + ) -> ExecutionContext: + """Resolve using transport-authenticated identity, not caller JSON. + + There is deliberately no fallback to main, a parent's binding, or a + global active profile. A new app revision/runtime cannot reuse a binding. + """ + with self._lock: + binding = self._bindings.get(binding_id) + if binding is None or binding.caller != authenticated_caller: + raise IdentityError("caller_unbound") + return ExecutionContext(binding, execution_id) + + def revoke(self, binding_id: UUID) -> None: + """Invalidate a channel without altering profiles or token/session data.""" + with self._lock: + self._bindings.pop(binding_id, None) + self._revoked.add(binding_id) + + def require_current(self, context: ExecutionContext) -> None: + """Recheck at use time: a previously resolved context may be revoked.""" + with self._lock: + if self._bindings.get(context.binding.binding_id) != context.binding: + raise IdentityError("caller_unbound") + + def require_scope(self, context: ExecutionContext, scope: VaultScope) -> None: + """Reject cross-profile/owner references even for the same human owner.""" + with self._lock: + self.require_current(context) + if context.binding.scope != scope: + raise IdentityError("scope_denied") + + @contextmanager + def hold_current(self, context: ExecutionContext): + """Serialize dispatch with revocation; already sent requests cannot be undone.""" + with self._lock: + self.require_current(context) + yield diff --git a/app/credentials/errors.py b/app/credentials/errors.py new file mode 100644 index 000000000..01e9384be --- /dev/null +++ b/app/credentials/errors.py @@ -0,0 +1,45 @@ +"""Stable errors: never include paths, provider responses or secret values.""" + +from enum import Enum +from functools import wraps + + +class VaultErrorCode(str, Enum): + LOCKED = "locked" + VERIFICATION_REQUIRED = "owner_verification_required" + BACKEND_UNAVAILABLE = "backend_unavailable" + UNREADABLE = "vault_unreadable" + IO = "vault_io_error" + INVALID = "credential_invalid" + CONFLICT = "credential_conflict" + NOT_FOUND = "credential_not_found" + ALREADY_EXISTS = "vault_already_exists" + SCOPE_DENIED = "scope_denied" + + +class VaultError(RuntimeError): + def __init__(self, code: VaultErrorCode) -> None: + self.code = code + super().__init__(code.value) + + +def sanitized_errors(operation): + """Drop inner frames as well as messages at the secret-bearing boundary. + + `raise ... from None` inside a context manager is insufficient: loguru's + diagnose mode can print the original exception from contextlib.__exit__'s + `value` local. Raise a fresh error OUTSIDE the except block, so no such + frames or exception contexts reach callers' diagnostic logs. + """ + + @wraps(operation) + def sanitized(*args, **kwargs): + try: + return operation(*args, **kwargs) + except VaultError as error: + code = error.code + except Exception: + code = VaultErrorCode.IO + raise VaultError(code) from None + + return sanitized diff --git a/app/credentials/execution.py b/app/credentials/execution.py new file mode 100644 index 000000000..51dece6f4 --- /dev/null +++ b/app/credentials/execution.py @@ -0,0 +1,93 @@ +"""Host-only cancellable execution and sanitized stream output.""" + +from threading import Event, Lock, Thread +from .helpers import JsonResult +from .use_errors import CredentialUseError, UseCode +from .wire import MAX_OUTPUT + + +class OperationJob: + def __init__(self, operation, *, budget=None, grant_id=None): + self.cancelled, self.done = Event(), Event() + self.dispatched = Event() + self.lock = Lock() + self.status, self.content_type, self.body, self.error = None, None, "", None + self.result = None + self.budget, self.grant_id, self.charged = budget, grant_id, 0 + self.thread = Thread(target=self._run, args=(operation,), daemon=True) + + def start(self, status, content_type): + with self.lock: + self.status, self.content_type = status, content_type + + def emit(self, text): + if self.cancelled.is_set(): + raise CredentialUseError(UseCode.INTERRUPTED) + with self.lock: + if len(self.body) + len(text) > MAX_OUTPUT: + raise CredentialUseError(UseCode.LIMIT) + self._charge(len(text)) + self.body += text + + def _charge(self, size): + if self.cancelled.is_set(): + raise CredentialUseError(UseCode.INTERRUPTED) + if self.budget is not None: + self.budget.change(size) + self.charged += size + + def close(self): + self.cancelled.set() + with self.lock: + if self.budget is not None: + self.budget.change(-self.charged) + self.charged, self.body, self.result = 0, "", None + + def _run(self, operation): + try: + self.result = operation(self) + if type(self.result) is JsonResult: + with self.lock: + if self.status is None: + self._charge(len(self.result.body)) + self.status, self.content_type, self.body = ( + self.result.status, + self.result.content_type, + self.result.body, + ) + except CredentialUseError as error: + self.error = ( + UseCode.INTERRUPTED if self.cancelled.is_set() else error.code + ).value + except BaseException: + self.error = ( + UseCode.INTERRUPTED if self.cancelled.is_set() else UseCode.HELPER + ).value + finally: + self.done.set() + + def snapshot(self, offset=0): + with self.lock: + if type(offset) is not int or not 0 <= offset <= len(self.body): + raise CredentialUseError(UseCode.DENIED) + return { + "status": self.status, + "content_type": self.content_type, + "length": len(self.body), + "chunk": self.body[offset : offset + 4000], + "done": self.done.is_set(), + "error": self.error, + } + + +class OutputBudget: + """Aggregate retained output across all isolated workers, in characters.""" + + def __init__(self, maximum=192_000_000): + self.maximum, self.used, self.lock = maximum, 0, Lock() + + def change(self, delta): + with self.lock: + if self.used + delta > self.maximum: + raise CredentialUseError(UseCode.LIMIT) + self.used = max(0, self.used + delta) diff --git a/app/credentials/gateway_helper.py b/app/credentials/gateway_helper.py new file mode 100644 index 000000000..454d35626 --- /dev/null +++ b/app/credentials/gateway_helper.py @@ -0,0 +1,136 @@ +"""Reviewed Discord Gateway identify + heartbeat + event receiver. + +No caller-selected WS URL, identify fields, messages, session/resume or signer. +The bot source is used only on the pinned, verified Discord TLS connection. +""" + +import json +import logging +import socket +import ssl +import time +from threading import Event, Thread, Timer + +from .helpers import JsonResult, _address, _abort_socket +from .secrets import Secret +from .streaming import StreamFilter +from .use_errors import CredentialUseError, UseCode + + +def execute(spec, fields, guard, payload, execution, after_send): + if payload != {"body": None} or execution is None: + raise CredentialUseError(UseCode.DENIED) + from websockets.sync.client import connect + + family, socktype, protocol, address = _address("gateway.discord.gg", 443) + guard() + logger = logging.Logger("credential_gateway_private") + logger.disabled, logger.propagate = True, False + deadline = time.monotonic() + 600 + finished, timer = Event(), None + try: + with socket.socket(family, socktype, protocol) as transport: + transport.settimeout(10) + transport.connect(address) + with connect( + "wss://gateway.discord.gg/?v=10&encoding=json", + sock=transport, + ssl=ssl.create_default_context(), + proxy=None, + compression=None, + open_timeout=10, + close_timeout=1, + max_size=1_000_000, + max_queue=4, + ping_interval=None, + logger=logger, + ) as websocket: + timer = Timer(600, _abort_socket, args=(websocket.socket,)) + timer.daemon = True + timer.start() + + def watch(): + while not finished.wait(0.05): + if execution.cancelled.is_set(): + _abort_socket(websocket.socket) + return + + Thread(target=watch, daemon=True).start() + hello = json.loads(websocket.recv(timeout=10)) + interval = hello.get("d", {}).get("heartbeat_interval") + if ( + hello.get("op") != 10 + or type(interval) not in (int, float) + or not 1000 <= interval <= 120000 + ): + raise CredentialUseError(UseCode.HELPER) + guard() + token = Secret(fields.reveal()["api_key"]) + if not token.reveal().isascii() or any( + ord(c) < 33 or ord(c) > 126 for c in token.reveal() + ): + raise CredentialUseError(UseCode.DENIED) + identify = Secret( + { + "op": 2, + "d": { + "token": token.reveal(), + "intents": 37376, + "properties": { + "os": "windows", + "browser": "craftosbot", + "device": "craftosbot", + }, + }, + } + ) + websocket.send(json.dumps(identify.reveal())) + if after_send is not None: + after_send() + execution.start(200, "text/event-stream") + filtering = StreamFilter( + Secret({"token": token.reveal()}), execution.emit + ) + heartbeat = time.monotonic() + last_ack, sequence = heartbeat, None + while time.monotonic() < deadline: + if execution.cancelled.is_set(): + raise CredentialUseError(UseCode.INTERRUPTED) + now = time.monotonic() + if now >= heartbeat: + if now - last_ack > 2 * interval / 1000: + raise CredentialUseError(UseCode.HELPER) + websocket.send(json.dumps({"op": 1, "d": sequence})) + heartbeat = now + interval / 1000 + try: + message = websocket.recv( + timeout=min(1, max(0.01, heartbeat - now)) + ) + except TimeoutError: + continue + if not isinstance(message, str): + raise CredentialUseError(UseCode.HELPER) + data = json.loads(message) + if type(data) is not dict or type(data.get("op")) is not int: + raise CredentialUseError(UseCode.HELPER) + if data.get("s") is not None: + if type(data["s"]) is not int: + raise CredentialUseError(UseCode.HELPER) + sequence = data["s"] + if data["op"] == 11: + last_ack = time.monotonic() + elif data["op"] == 1: + heartbeat = 0 + elif data["op"] in (7, 9): + break # New bounded worker/consent; never auto-resume. + elif data["op"] == 0 and data.get("t") in ( + "READY", + "MESSAGE_CREATE", + ): + filtering.feed("data: " + json.dumps(data)) + filtering.flush() + return JsonResult(200, "", "text/event-stream") + finally: + finished.set() + if timer is not None: + timer.cancel() diff --git a/app/credentials/helpers.py b/app/credentials/helpers.py new file mode 100644 index 000000000..b7b62e13a --- /dev/null +++ b/app/credentials/helpers.py @@ -0,0 +1,900 @@ +"""Fixed host helper implementation; no consumer URLs, code, headers or callbacks. + +The host installs immutable check specifications, never from manifests/actions. +Browser filling, provider requests and token-producing login flows need separate +reviewed helpers. This check discards all response data except a coarse outcome. +""" + +from __future__ import annotations + +import base64 +from dataclasses import dataclass +from enum import Enum +import http.client +import ipaddress +import json +import re +import socket +import ssl +from threading import Timer +import time +from types import MappingProxyType +from urllib.parse import urlsplit + +from .contracts import CredentialKind, HelperOperation +from .records import CredentialDetails, origins +from .secrets import Secret +from .use_errors import CredentialUseError, UseCode, use_boundary + + +class HelperResult(str, Enum): + ACCEPTED = "accepted" # HTTP 2xx, not proof of a provider's account identity. + REJECTED = "rejected" # HTTP 401/403. + UNAVAILABLE = "unavailable" # Includes redirects; never followed. + + +@dataclass(frozen=True) +class HttpsCredentialCheck: + """Host-reviewed, read-only GET with Basic or Bearer authorization. + + A service-secret field is explicitly selected by trusted configuration. + No field-name inference, arbitrary requests, or generated token storage. + """ + + operation: HelperOperation + kind: CredentialKind + endpoint: str + secret_field: str | None = None + + def __post_init__(self): + if not isinstance(self.operation, HelperOperation) or not isinstance( + self.kind, CredentialKind + ): + raise ValueError("invalid_helper") + if ( + not isinstance(self.endpoint, str) + or not self.endpoint.isascii() + or len(self.endpoint) > 2048 + or any(ord(char) <= 32 or ord(char) == 127 for char in self.endpoint) + or "\\" in self.endpoint + ): + raise ValueError("invalid_helper_endpoint") + parsed = urlsplit(self.endpoint) + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or "?" in self.endpoint + or "#" in self.endpoint + or parsed.netloc.endswith(":") + or not re.fullmatch(r"[A-Za-z0-9._~!$&'()*+,;=:@%/\-]*", parsed.path) + ): + raise ValueError("invalid_helper_endpoint") + # Reuse the strict origin parser; no www folding or wildcard policy. + origins((f"https://{parsed.netloc}",)) + if self.kind is CredentialKind.SERVICE_SECRET: + if not isinstance(self.secret_field, str) or not re.fullmatch( + r"[a-z][a-z0-9_]{0,63}", self.secret_field + ): + raise ValueError("invalid_helper_field") + elif self.secret_field is not None: + raise ValueError("invalid_helper_field") + + @property + def origin(self) -> str: + return origins((f"https://{urlsplit(self.endpoint).netloc}",))[0] + + +class HelperRegistry: + """Sealed at host bootstrap. No runtime register/import/execute-code API.""" + + def __init__(self, specifications: tuple[HttpsCredentialCheck, ...] = ()): + from .browser_helper import HttpsFormLogin + + if type(specifications) is not tuple or len(specifications) > 1024: + raise ValueError("invalid_helper_registry") + entries = {} + for specification in specifications: + if ( + type(specification) + not in (HttpsCredentialCheck, HttpsJsonOperation, HttpsFormLogin) + or specification.operation in entries + ): + raise ValueError("invalid_helper_registry") + entries[specification.operation] = specification + self._entries = MappingProxyType(entries) + + def require(self, operation: HelperOperation) -> HttpsCredentialCheck: + specification = self._entries.get(operation) + if specification is None: + raise CredentialUseError(UseCode.DENIED) + return specification + + +@dataclass(frozen=True) +class HttpsJsonOperation(HttpsCredentialCheck): + """One installed JSON API operation at one immutable HTTPS endpoint. + + Requesters supply JSON data, never a URL, path, method, headers or transport. + Resource parameters are constrained by sealed templates. Streaming is + filtered in the protected host before release. No response + headers, cookies, redirects or provider error bodies leave this boundary. + """ + + method: str = "POST" + auth_header: str = "Authorization" + auth_prefix: str = "Bearer " + protocol: str = "json" + query_fields: tuple[str, ...] = () + path_fields: tuple[str, ...] = () + envelope: bool = False + request_format: str = "json" + response_format: str = "json" + max_response_bytes: int = 2_000_000 + header_fields: tuple[str, ...] = () + + def __post_init__(self): + super().__post_init__() + if self.kind is CredentialKind.LOGIN or self.method not in ( + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + ): + raise ValueError("invalid_json_helper") + if ( + type(self.query_fields) is not tuple + or len(self.query_fields) > 128 + or len(json.dumps(self.query_fields)) > 4096 + or any( + not isinstance(k, str) + or not re.fullmatch(r"[A-Za-z][A-Za-z0-9_.]{0,63}", k) + for k in self.query_fields + ) + ): + raise ValueError("invalid_helper_query") + from .route_policy import path_names + + if ( + type(self.path_fields) is not tuple + or len(set(self.path_fields)) != len(self.path_fields) + or path_names(self.endpoint) != self.path_fields + or type(self.envelope) is not bool + or self.request_format not in ("json", "form", "multipart", "image") + or self.response_format not in ("json", "binary", "text") + ): + raise ValueError("invalid_helper_route") + if ( + self.path_fields + or self.request_format != "json" + or self.response_format in ("binary", "text") + ) and not self.envelope: + raise ValueError("invalid_helper_route") + if ( + type(self.max_response_bytes) is not int + or not 1 <= self.max_response_bytes <= 64_000_000 + ): + raise ValueError("invalid_helper_limit") + if type(self.header_fields) is not tuple or set(self.header_fields) - { + "Idempotency-Key", + "Stripe-Account", + }: + raise ValueError("invalid_helper_headers") + if (self.auth_header, self.auth_prefix) not in { + ("Authorization", "Bearer "), + ("Authorization", "Bot "), + ("x-api-key", ""), + ("x-goog-api-key", ""), + ("X-Figma-Token", ""), + } or self.protocol not in ( + "json", + "openai", + "anthropic", + "aws_bedrock", + "telegram", + "basic", + "oauth_form", + "oauth_json", + "oauth_basic", + "discord_gateway", + "twitter_oauth1", + "lark_token", + "lark_websocket", + "telegram_mtproto", + ): + raise ValueError("invalid_json_helper") + if self.protocol == "twitter_oauth1" and ( + not ( + self.origin == "https://api.twitter.com" + and self.request_format == "json" + or self.endpoint == "https://upload.twitter.com/1.1/media/upload.json" + and self.method == "POST" + and self.request_format == "multipart" + ) + or self.kind is not CredentialKind.SERVICE_SECRET + or self.secret_field != "api_secret" + or not self.envelope + ): + raise ValueError("invalid_twitter_helper") + if self.protocol in ("lark_token", "lark_websocket") and ( + self.endpoint + != ( + "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal" + if self.protocol == "lark_token" + else "https://open.larksuite.com/callback/ws/endpoint" + ) + or self.kind is not CredentialKind.API_KEY + or self.method != "POST" + or self.envelope + or self.query_fields + or self.path_fields + or self.request_format != "json" + ): + raise ValueError("invalid_lark_helper") + if self.protocol == "telegram_mtproto" and ( + self.endpoint + != "https://api.telegram.org/mtproto/" + self.operation.operation + or self.operation.operation + not in ("send_code", "complete", "qr_login", "complete_password") + or self.kind is not CredentialKind.SERVICE_SECRET + or self.secret_field != "api_hash" + or self.method != "POST" + or self.envelope + or self.query_fields + or self.path_fields + ): + raise ValueError("invalid_mtproto_helper") + if self.protocol == "discord_gateway" and ( + self.endpoint != "https://gateway.discord.gg/" + or self.kind is not CredentialKind.API_KEY + or self.method != "GET" + or self.query_fields + or self.path_fields + or self.envelope + ): + raise ValueError("invalid_gateway_helper") + if self.protocol.startswith("oauth_") and ( + self.kind is not CredentialKind.API_KEY + or self.method != "POST" + or self.path_fields + or self.query_fields + or not self.envelope + or self.request_format not in ("json", "form") + ): + raise ValueError("invalid_oauth_helper") + if self.protocol == "basic" and ( + self.kind is not CredentialKind.SERVICE_SECRET + or self.secret_field != "api_token" + ): + raise ValueError("invalid_basic_helper") + if self.protocol == "telegram" and ( + self.origin != "https://api.telegram.org" + or not self.endpoint.startswith( + ( + "https://api.telegram.org/bot_TOKEN_/", + "https://api.telegram.org/file/bot_TOKEN_/", + ) + ) + or "_TOKEN_" not in self.endpoint + ): + raise ValueError("invalid_telegram_helper") + if self.protocol == "aws_bedrock": + from .aws_helper import endpoint_region + + endpoint_region(self.endpoint) + if ( + self.kind is not CredentialKind.SERVICE_SECRET + or self.secret_field != "secret_access_key" + or self.method != "POST" + or self.query_fields + ): + raise ValueError("invalid_bedrock_helper") + + +@dataclass(frozen=True) +class JsonResult: + status: int + body: str + content_type: str + + +def _response_variants(fields: Secret): + """Scrub literal and common encoded echoes before any output is released.""" + import html + from urllib.parse import quote + + variants = set() + for raw in fields.reveal().values(): + if not raw: + continue + encoded = raw.encode("utf-8") + unicode_units = raw.encode("utf-16-be") + escaped = "".join( + f"\\u{int.from_bytes(unicode_units[i : i + 2], 'big'):04x}" + for i in range(0, len(unicode_units), 2) + ) + variants.update( + ( + raw, + json.dumps(raw)[1:-1], + html.escape(raw), + quote(raw, safe=""), + encoded.hex(), + base64.b64encode(encoded).decode("ascii"), + base64.urlsafe_b64encode(encoded).decode("ascii"), + escaped, + json.dumps(escaped)[1:-1], + "".join(f"%{byte:02x}" for byte in encoded), + "".join(f"%{byte:02X}" for byte in encoded), + ) + ) + return Secret(tuple(sorted(variants, key=len, reverse=True))) + + +def _redact_response(value: str, fields: Secret) -> str: + for variant in _response_variants(fields).reveal(): + value = value.replace(variant, "[redacted]") + return value + + +def _sanitize_json_payloads(payloads, fields): + """Validate redacted JSON and reject echoes split across semantic deltas. + + Network buffering alone misses a key split between SSE data records. Keep + strings at the same JSON path together (including tool-call arguments), and + also check concatenated leaves for array/object fragments. This is defence + against accidental provider echoes, not permission to trust arbitrary URLs. + Only fields actually sent to the reviewed service participate in filtering. + """ + sanitized = [] + paths = {} + leaves = [] + + def collect(value, path=()): + if type(value) is dict: + for key, child in value.items(): + collect(child, (*path, key)) + elif type(value) is list: + for index, child in enumerate(value): + collect(child, (*path, index)) + elif isinstance(value, str): + paths.setdefault(path, []).append(value) + leaves.append(value) + + for payload in payloads: + canonical = json.dumps(payload, ensure_ascii=False, allow_nan=False) + cleaned = _redact_response(canonical, fields) + # Substitution must never return invalid JSON (e.g. a numeric key echo). + collect(json.loads(cleaned)) + sanitized.append(cleaned) + for fragments in (*paths.values(), leaves): + joined = "".join(fragments) + if _redact_response(joined, fields) != joined: + raise CredentialUseError(UseCode.HELPER) + return sanitized + + +@use_boundary +def _execute_json( + specification: HttpsJsonOperation, + fields: Secret, + guard, + payload, + *, + execution=None, + after_send=None, +): + if type(payload) is not dict or set(payload) != {"body"}: + raise CredentialUseError(UseCode.DENIED) + if specification.protocol == "discord_gateway": + from .gateway_helper import execute + + return execute(specification, fields, guard, payload, execution, after_send) + if specification.protocol == "telegram_mtproto": + from .mtproto_helper import execute + + return execute(specification, fields, guard, payload, execution, after_send) + body = payload["body"] + session = None + path = urlsplit(specification.endpoint).path or "/" + query_data = body if specification.method == "GET" else None + business_headers = {} + if specification.envelope: + from .route_policy import target + + if ( + type(body) is not dict + or not {"path", "query", "data"} <= set(body) + or set(body) + - ( + {"path", "query", "data", "headers", "session"} + if specification.protocol == "twitter_oauth1" + else {"path", "query", "data", "headers"} + ) + ): + raise CredentialUseError(UseCode.DENIED) + session = body.get("session") + business_headers = body.get("headers", {}) + if ( + type(business_headers) is not dict + or set(business_headers) - set(specification.header_fields) + or any( + not isinstance(v, str) + or not 1 <= len(v) <= 256 + or any(ord(c) < 33 or ord(c) > 126 for c in v) + for v in business_headers.values() + ) + ): + raise CredentialUseError(UseCode.DENIED) + path = target(specification.endpoint, specification.path_fields, body["path"]) + query_data, body = body["query"], body["data"] + query = "" + if query_data is not None: + from urllib.parse import urlencode + + if ( + type(query_data) is not dict + or any( + k.split("[", 1)[0] not in specification.query_fields + or not re.fullmatch( + r"[A-Za-z][A-Za-z0-9_.]*(?:\[[A-Za-z0-9_.-]*\])*", k + ) + for k in query_data + ) + or any( + type(v) not in (str, int, bool, type(None)) for v in query_data.values() + ) + ): + raise CredentialUseError(UseCode.DENIED) + query = urlencode( + { + k: str(v).lower() if type(v) is bool else v + for k, v in query_data.items() + if v is not None + } + ) + if len(query) > 8192: + raise CredentialUseError(UseCode.LIMIT) + if specification.method == "GET": + body = None + if ( + body is not None + and type(body) is not dict + and not ( + specification.envelope + and specification.request_format == "json" + and type(body) is list + ) + ): + raise CredentialUseError(UseCode.DENIED) + from .wire import encode, MAX_OUTPUT + + encoded, request_type = encode(body, specification.request_format) + # Host-controlled timeout, immutable destination, no proxy or second DNS. + deadline = time.monotonic() + (600 if execution is not None else 60) + handshake_deadline = time.monotonic() + 20 + parsed = urlsplit(specification.endpoint) + port = parsed.port or 443 + family, socktype, protocol, sockaddr = _address(parsed.hostname, port) + guard() + context = ssl.create_default_context() + context.set_alpn_protocols(["http/1.1"]) + connection = http.client.HTTPSConnection(parsed.hostname, port, context=context) + connection.auto_open = 0 + connection.set_debuglevel(0) + timer = None + finished = None + try: + with socket.socket(family, socktype, protocol) as transport: + transport.settimeout(_remaining(handshake_deadline)) + transport.connect(sockaddr) + transport.settimeout(_remaining(handshake_deadline)) + connection.sock = context.wrap_socket( + transport, server_hostname=parsed.hostname + ) + connection.sock.settimeout(_remaining(deadline)) + timer = Timer(_remaining(deadline), _abort_socket, args=(connection.sock,)) + timer.daemon = True + timer.start() + if execution is not None: + from threading import Event, Thread + + finished = Event() + + def watch(): + while not finished.wait(0.05): + if execution.cancelled.is_set(): + _abort_socket(connection.sock) + return + + Thread(target=watch, daemon=True).start() + guard() + secret = Secret( + fields.reveal()[ + "api_key" + if specification.kind is CredentialKind.API_KEY + else specification.secret_field + ] + ) + if any(ord(c) < 33 or ord(c) > 126 for c in secret.reveal()): + raise CredentialUseError(UseCode.DENIED) + headers = { + "Content-Type": request_type, + "Accept": "application/json", + "Connection": "close", + "User-Agent": "CraftBotCredentialHost/1", + specification.auth_header: specification.auth_prefix + secret.reveal(), + } + if specification.protocol == "anthropic": + headers["anthropic-version"] = "2023-06-01" + if specification.operation.helper_id == "integration_notion": + headers["Notion-Version"] = "2022-06-28" + if specification.operation.helper_id == "integration_github": + headers["Accept"] = "application/vnd.github+json" + headers["X-GitHub-Api-Version"] = "2022-11-28" + headers.update(business_headers) + used_fields = Secret({"value": secret.reveal()}) + if specification.protocol == "twitter_oauth1": + from .twitter_helper import authorization + + signed, used_fields = authorization( + specification.origin + path, + specification.method, + query, + fields, + session, + ) + headers["Authorization"] = signed.reveal() + if specification.protocol in ("lark_token", "lark_websocket"): + if ( + type(body) is not dict + or set(body) != {"app_id"} + or not isinstance(body["app_id"], str) + or not re.fullmatch(r"cli_[A-Za-z0-9_]{1,128}", body["app_id"]) + ): + raise CredentialUseError(UseCode.DENIED) + private_body = Secret( + {"app_id": body["app_id"], "app_secret": secret.reveal()} + if specification.protocol == "lark_token" + else {"AppID": body["app_id"], "AppSecret": secret.reveal()} + ) + headers.pop(specification.auth_header, None) + encoded, headers["Content-Type"] = encode(private_body.reveal(), "json") + if specification.protocol == "basic": + username = Secret(fields.reveal()["username"]) + if ":" in username.reveal() or any( + ord(c) < 32 or ord(c) == 127 for c in username.reveal() + ): + raise CredentialUseError(UseCode.DENIED) + pair = Secret(username.reveal() + ":" + secret.reveal()) + headers["Authorization"] = ( + "Basic " + base64.b64encode(pair.reveal().encode()).decode() + ) + used_fields = Secret( + { + "username": username.reveal(), + "api_token": secret.reveal(), + "pair": pair.reveal(), + } + ) + if specification.protocol.startswith("oauth_"): + if type(body) is not dict or any( + type(v) not in (str, int, bool, type(None)) for v in body.values() + ): + raise CredentialUseError(UseCode.DENIED) + private_body = Secret(dict(body)) + private_body.reveal().pop("client_secret", None) + headers.pop(specification.auth_header, None) + if specification.protocol == "oauth_basic": + client_id = private_body.reveal().pop("client_id", None) + if ( + not isinstance(client_id, str) + or not client_id + or ":" in client_id + or not client_id.isascii() + or any(ord(c) < 33 or ord(c) == 127 for c in client_id) + ): + raise CredentialUseError(UseCode.DENIED) + pair = Secret(client_id + ":" + secret.reveal()) + headers["Authorization"] = ( + "Basic " + base64.b64encode(pair.reveal().encode()).decode() + ) + used_fields = Secret( + {"value": secret.reveal(), "pair": pair.reveal()} + ) + else: + private_body.reveal()["client_secret"] = secret.reveal() + encoded, headers["Content-Type"] = encode( + private_body.reveal(), specification.request_format + ) + if specification.protocol == "aws_bedrock": + from .aws_helper import signing_headers + + signed, used_fields = signing_headers( + specification.endpoint, encoded, fields + ) + headers.update(signed.reveal()) + if specification.protocol == "telegram": + if not re.fullmatch( + r"[0-9]{5,20}:[A-Za-z0-9_-]{20,128}", secret.reveal() + ): + raise CredentialUseError(UseCode.DENIED) + path = path.replace("_TOKEN_", secret.reveal()) + headers.pop(specification.auth_header, None) + if "Authorization" in headers: + used_fields = Secret( + {**used_fields.reveal(), "authorization": headers["Authorization"]} + ) + connection.request( + specification.method, + path + ("?" + query if query else ""), + encoded, + headers, + ) + if after_send is not None: + after_send() + with connection.getresponse() as response: + if not 200 <= response.status < 300: + status = ( + response.status + if response.status + in (400, 401, 403, 404, 408, 409, 422, 429, 500, 502, 503, 504) + else 502 + ) + return JsonResult( + status, + '{"error":{"message":"Credential operation failed"}}', + "application/json", + ) + # SSE filtering retains potential source fragments across events. + if response.status == 204: + return JsonResult(204, "{}", "application/json") + response_type = ( + response.getheader("Content-Type", "").split(";")[0].strip().lower() + ) + if ( + execution is not None + and response_type == "text/event-stream" + and specification.protocol in ("openai", "anthropic") + ): + from .streaming import relay + + execution.start(response.status, response_type) + relay( + response, + used_fields, + execution, + specification.max_response_bytes, + ) + return JsonResult(response.status, "", response_type) + raw = Secret(response.read(specification.max_response_bytes + 1)) + if len(raw.reveal()) > specification.max_response_bytes: + raise CredentialUseError(UseCode.LIMIT) + content_type = ( + response.getheader("Content-Type", "").split(";")[0].strip().lower() + ) + if specification.response_format == "binary": + if content_type not in ( + "video/mp4", + "image/png", + "image/jpeg", + "image/webp", + "application/octet-stream", + "application/pdf", + "application/zip", + "text/plain", + "audio/mpeg", + "audio/ogg", + "audio/wav", + "video/webm", + "image/gif", + ): + raise CredentialUseError(UseCode.HELPER) + inspection = Secret(raw.reveal().decode("latin-1")) + binary = Secret(base64.b64encode(raw.reveal()).decode("ascii")) + if ( + _redact_response(inspection.reveal(), used_fields) + != inspection.reveal() + or _redact_response(binary.reveal(), used_fields) + != binary.reveal() + or any( + variant.encode("utf-8") in raw.reveal() + for variant in _response_variants(used_fields).reveal() + ) + ): + raise CredentialUseError(UseCode.HELPER) + return JsonResult( + response.status, + json.dumps( + {"data": binary.reveal(), "media_type": content_type} + ), + "application/json", + ) + text = Secret(raw.reveal().decode("utf-8")) + if specification.response_format == "text": + return JsonResult( + response.status, + _redact_response(text.reveal(), used_fields), + "text/plain", + ) + if content_type == "text/event-stream" and specification.protocol in ( + "openai", + "anthropic", + ): + # Only valid JSON data records and the standard terminator. + canonical = [] + payloads = [] + for line in text.reveal().splitlines(): + if line.startswith("data:"): + if line[5:].strip() != "[DONE]": + payloads.append(json.loads(line[5:])) + line = len(payloads) - 1 + elif line.startswith(":"): + continue # Provider comments are not output data. + elif line.startswith("event:"): + if not re.fullmatch(r"event: ?[A-Za-z0-9_.-]{1,64}", line): + raise CredentialUseError(UseCode.HELPER) + elif line: + raise CredentialUseError(UseCode.HELPER) + canonical.append(line) + cleaned = _sanitize_json_payloads(payloads, used_fields) + text = Secret( + "\n".join( + "data: " + cleaned[line] if type(line) is int else line + for line in canonical + ) + + "\n" + ) + elif content_type == "application/json": + text = Secret( + _sanitize_json_payloads( + [json.loads(text.reveal())], used_fields + )[0] + ) + else: + raise CredentialUseError(UseCode.HELPER) + sanitized = _redact_response(text.reveal(), used_fields) + if len(sanitized.encode("utf-8")) > MAX_OUTPUT: + raise CredentialUseError(UseCode.LIMIT) + return JsonResult(response.status, sanitized, content_type) + finally: + if finished is not None: + finished.set() + if timer is not None: + timer.cancel() + connection.close() + + +def require_match(specification: HttpsCredentialCheck, details: CredentialDetails): + if ( + specification.kind is not details.summary.kind + or specification.origin not in details.allowed_origins + ): + raise CredentialUseError(UseCode.DENIED) + + +def _address(host: str, port: int): + """Resolve once and connect to exactly a checked numeric address. + + Reject the entire answer on any private/special address, including mixed + public/private answers. No proxy environment or second DNS lookup is used. + """ + from .resolver import resolve + + addresses = resolve(host, port) + if not addresses: + raise CredentialUseError(UseCode.DENIED) + for family, socktype, protocol, _, sockaddr in addresses: + address = ipaddress.ip_address(sockaddr[0]) + if ( + family not in (socket.AF_INET, socket.AF_INET6) + or socktype != socket.SOCK_STREAM + or not address.is_global + or address.is_multicast + or address.is_reserved + or getattr(address, "is_site_local", False) + or getattr(address, "ipv4_mapped", None) is not None + or getattr(address, "sixtofour", None) is not None + or getattr(address, "teredo", None) is not None + or "%" in sockaddr[0] + ): + raise CredentialUseError(UseCode.DENIED) + family, socktype, protocol, _, sockaddr = addresses[0] + return family, socktype, protocol, sockaddr + + +def _authorization(specification: HttpsCredentialCheck, fields: Secret) -> Secret: + # All intermediates remain redacted in trusted frames; the public broker + # boundary also discards inner frames on any exception. + if specification.kind is CredentialKind.LOGIN: + pair = Secret((fields.reveal()["username"], fields.reveal()["password"])) + if ":" in pair.reveal()[0] or any( + ord(char) < 32 or ord(char) == 127 + for value in pair.reveal() + for char in value + ): + raise CredentialUseError(UseCode.DENIED) + encoded = Secret(base64.b64encode(":".join(pair.reveal()).encode("utf-8"))) + return Secret("Basic " + encoded.reveal().decode("ascii")) + value = Secret( + fields.reveal()[ + "api_key" + if specification.kind is CredentialKind.API_KEY + else specification.secret_field + ] + ) + if not re.fullmatch(r"[A-Za-z0-9._~+/\-]+=*", value.reveal()): + raise CredentialUseError(UseCode.DENIED) + return Secret("Bearer " + value.reveal()) + + +def _remaining(deadline): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError() + return remaining + + +def _abort_socket(transport): + # A total request deadline must also stop trickled status/response headers; + # a per-recv socket timeout alone does not bound that case. + try: + transport.shutdown(socket.SHUT_RDWR) + except (OSError, AttributeError): + pass + + +@use_boundary +def _execute_check(specification: HttpsCredentialCheck, fields: Secret, guard): + """Private broker entry point. `guard` is host code, never a request value.""" + deadline = time.monotonic() + 10 + parsed = urlsplit(specification.endpoint) + port = parsed.port or 443 + family, socktype, protocol, sockaddr = _address(parsed.hostname, port) + guard() # Slow DNS must not extend capability/owner-unlock lifetimes. + _remaining(deadline) + context = ssl.create_default_context() + context.set_alpn_protocols(["http/1.1"]) + connection = http.client.HTTPSConnection( + parsed.hostname, port, timeout=10, context=context + ) + connection.auto_open = 0 # Never reconnect to an unchecked DNS address. + connection.set_debuglevel(0) + timer = None + try: + with socket.socket(family, socktype, protocol) as transport: + transport.settimeout(_remaining(deadline)) + transport.connect(sockaddr) + transport.settimeout(_remaining(deadline)) + connection.sock = context.wrap_socket( + transport, server_hostname=parsed.hostname + ) + connection.sock.settimeout(_remaining(deadline)) + timer = Timer(_remaining(deadline), _abort_socket, args=(connection.sock,)) + timer.daemon = True + timer.start() + guard() # Recheck immediately before secrets enter the request. + authorization = _authorization(specification, fields) + connection.request( + "GET", + parsed.path or "/", + headers={ + "Authorization": authorization.reveal(), + "Connection": "close", + "Accept": "application/json", + }, + ) + # Do not return/cache/log headers, URL, body, cookies or client objects. + with connection.getresponse() as response: + _remaining(deadline) + if 200 <= response.status < 300: + return HelperResult.ACCEPTED + if response.status in (401, 403): + return HelperResult.REJECTED + return HelperResult.UNAVAILABLE + finally: + if timer is not None: + timer.cancel() + connection.close() diff --git a/app/credentials/host_service.py b/app/credentials/host_service.py new file mode 100644 index 000000000..4bceaabf4 --- /dev/null +++ b/app/credentials/host_service.py @@ -0,0 +1,1486 @@ +"""Installed worker. Only the SCM host's private stdin carries kernel envelopes. + +The ordinary browser/bridge is not an owner channel. Public consumer messages +cannot provide a caller context, isolated flag, PID, grant, or secret fields. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from contextlib import closing +import hashlib +import json +from pathlib import Path, PurePosixPath +import sys +import time +from threading import Timer, Thread, Event, RLock +from .execution import OperationJob, OutputBudget +from uuid import UUID, uuid4 + +from .broker import CredentialBroker, GrantMode, UseCapability, canonical_request +from .contracts import ( + CredentialKind, + CredentialReference, + HelperOperation, + IsolationUnavailable, +) +from .directory import IdentityDirectory +from .helpers import HelperRegistry, HttpsJsonOperation, JsonResult, require_match +from .catalog import specification +from .browser_helper import HttpsFormLogin +from .identity import ( + AppDeployment, + CallerBinding, + CallerIdentity, + CallerKind, + ExecutionContext, + VaultOwner, + VaultProfile, + VaultScope, +) +from .owner_transport import WindowsOwnerConsent +from .use_errors import CredentialUseError, UseCode, use_boundary +from .vault import ProfileVault, VaultState +from .windows_host import IsolatedProcess, Peer, api, check_install, private_acl + + +@dataclass +class Launch: + process: IsolatedProcess + context: ExecutionContext + controller: Peer + deadline: float + result: object = None + timer: object = None + credential_output: JsonResult | None = None + payload_text: str = "" + payload_ready: bool = False + job: OperationJob | None = None + snapshot_revision: str | None = None + closed: Event = field(default_factory=Event) + guard: object = field(default_factory=RLock) + + def close(self): + with self.guard: + if self.closed.is_set(): + return False + self.closed.set() + if self.job is not None: + self.job.close() + if self.timer is not None: + self.timer.cancel() + try: + self.process.close() + finally: + self.controller.close() + return True + + +@dataclass(frozen=True) +class OwnerView: + pid: int + created: int + scope: VaultScope + epoch: UUID + expires: float + items: tuple + + +def metadata_page(items, offset): + if type(offset) is not int or not 0 <= offset <= len(items): + raise CredentialUseError(UseCode.DENIED) + page, size = [], 0 + for item in items[offset : offset + 50]: + length = len(json.dumps(item, ensure_ascii=True).encode("utf-8")) + if size + length > 24000: + break + page.append(item) + size += length + if offset < len(items) and not page: + raise CredentialUseError(UseCode.LIMIT) + next_offset = offset + len(page) + return { + "items": page, + "next_offset": next_offset if next_offset < len(items) else None, + "total": len(items), + } + + +def source_files(value, entry): + """A bounded immutable code snapshot, not host-supplied filesystem paths.""" + if ( + type(value) is not dict + or not 1 <= len(value) <= 32 + or not isinstance(entry, str) + ): + raise CredentialUseError(UseCode.DENIED) + total = 0 + names = set() + digest = hashlib.sha256() + digest.update(b"CraftBot snapshot v1\0" + entry.encode("utf-8") + b"\0") + for name, content in sorted(value.items()): + if not isinstance(name, str) or not isinstance(content, str): + raise CredentialUseError(UseCode.DENIED) + path = PurePosixPath(name) + if ( + not name.isascii() + or len(name) > 160 + or path.is_absolute() + or not path.parts + or path.as_posix() != name + or any(part in (".", "..") for part in path.parts) + or any( + char + not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_./-" + for char in name + ) + or any( + part.split(".")[0].upper() + in { + "CON", + "PRN", + "AUX", + "NUL", + *[f"COM{i}" for i in range(10)], + *[f"LPT{i}" for i in range(10)], + } + or part.endswith((".", " ")) + for part in path.parts + ) + or name.casefold() in names + or path.parts[0].casefold() in ("credential_client.py", "_host_entry.py") + ): + raise CredentialUseError(UseCode.DENIED) + names.add(name.casefold()) + encoded = content.encode("utf-8") + total += len(encoded) + if total > 48000: + raise CredentialUseError(UseCode.LIMIT) + name_bytes = name.encode("ascii") + digest.update( + len(name_bytes).to_bytes(4, "little") + + name_bytes + + len(encoded).to_bytes(8, "little") + + encoded + ) + if entry not in value or not entry.endswith(".py"): + raise CredentialUseError(UseCode.DENIED) + # Prefix collisions (a.py plus a.py/b.py) are rejected before writing. + for name in names: + if any( + parent.as_posix().casefold() in names + for parent in PurePosixPath(name).parents + if parent.as_posix() != "." + ): + raise CredentialUseError(UseCode.DENIED) + return "sha256:" + digest.hexdigest() + + +class ProtectedHost: + def __init__(self, root: Path, state: Path, configuration: dict): + self.root, self.state = root, state + self.owner = VaultOwner( + UUID(configuration["owner_id"]), configuration["owner_sid"] + ) + self.directory = IdentityDirectory() + self.directory.register_owner(self.owner) + self.consent = WindowsOwnerConsent( + root / "CredentialHost.exe", self.owner.os_identity + ) + self.vaults = {} + self.views = {} + catalog = state / "profiles.json" + profiles = ( + json.loads(catalog.read_text(encoding="utf-8")) + if catalog.exists() + else configuration["profiles"] + ) + if type(profiles) is not list or not 1 <= len(profiles) <= 64: + raise IsolationUnavailable() + for item in profiles: + if len(item["label"]) > 64: + raise IsolationUnavailable() + profile = VaultProfile( + VaultScope(self.owner.owner_id, UUID(item["id"])), item["label"] + ) + self.directory.register_profile(profile) + if profile.scope.profile_id in self.vaults: + raise IsolationUnavailable() + self.vaults[profile.scope.profile_id] = ProfileVault( + state / "vaults", self.owner, profile, verifier=self.consent + ) + specifications = tuple( + specification(item) for item in configuration.get("helpers", []) + ) + self.registry = HelperRegistry(specifications) + self.leases: dict[UUID, Launch] = {} + self.output_budget = OutputBudget() + for vault in self.vaults.values(): + vault._on_lock = lambda scope=vault.profile.scope: self._cancel_profile( + scope + ) + self.broker = CredentialBroker( + self.directory, + tuple(self.vaults.values()), + self.registry, + isolation=self, + owner_verifier=self.consent, + ) + + def require_isolated(self, context): + launch = next( + (item for item in self.leases.values() if item.context == context), None + ) + if launch is None or time.monotonic() >= launch.deadline: + raise IsolationUnavailable() + peer = Peer(launch.process.pid) + try: + if peer.sid != self.owner.os_identity or not launch.process.matches(peer): + raise IsolationUnavailable() + finally: + peer.close() + + def _cancel_profile(self, scope): + # Called under a vault lock, so this must not acquire the broker or + # directory lock. The HTTPS/Gateway watcher aborts active sockets. + for launch in tuple(self.leases.values()): + if launch.context.binding.scope == scope and launch.job is not None: + launch.job.cancelled.set() + + def _owner(self, peer): + if ( + peer.sid != self.owner.os_identity + or peer.container + or peer.elevated + or not peer.alive() + ): + raise CredentialUseError(UseCode.DENIED) + self.consent.session = peer.session + + def _consumer(self, peer): + if peer.sid != self.owner.os_identity: + raise CredentialUseError(UseCode.DENIED) + for launch in self.leases.values(): + if launch.process.matches(peer): + self.require_isolated(launch.context) + self.consent.session = peer.session + return launch + raise IsolationUnavailable() + + @staticmethod + def _shape(payload, fields): + if type(payload) is not dict or set(payload) != {"op", *fields}: + raise CredentialUseError(UseCode.DENIED) + + def _profile(self, value): + vault = self.vaults.get(UUID(value)) + if vault is None: + raise CredentialUseError(UseCode.DENIED) + return vault + + def _prune(self): + self.views = { + key: value + for key, value in self.views.items() + if time.monotonic() < value.expires + } + for key, launch in tuple(self.leases.items()): + if time.monotonic() >= launch.deadline or not launch.controller.alive(): + self._expire(key, launch) + + def _expire(self, key, launch): + # Independent of control-pipe traffic and idempotent against stop/lock. + first = launch.close() + if self.leases.get(key) is launch: + self.leases.pop(key, None) + if not first: + return + Thread( + target=self.directory.revoke, + args=(launch.context.binding.binding_id,), + daemon=True, + ).start() + + def _watch(self, key, launch): + while not launch.closed.wait(1): + try: + expired = time.monotonic() >= launch.deadline + disconnected = not launch.controller.alive() + abandoned = ( + launch.job is not None + and not launch.job.done.is_set() + and api()[6].WaitForSingleObject(launch.process.handle, 0) == 0 + ) + if expired or disconnected or abandoned: + self._expire(key, launch) + return + except Exception: + self._expire(key, launch) + return + + @use_boundary + def dispatch(self, peer, payload): + self._prune() + if type(payload) is not dict or not isinstance(payload.get("op"), str): + raise CredentialUseError(UseCode.DENIED) + operation = payload["op"] + if operation in { + "profiles", + "action_profile", + "action_profile_set", + "initialize", + "entry", + "launch", + "result", + "program_result", + "response", + "stage_payload", + "reset_operation", + "stop", + "lock", + "unlock", + "items", + "items_page", + "edit", + "delete", + "activity", + "revoke", + "profile_add", + "migrate_settings", + "migrate_browser", + "migrate_integration", + "migrate_environment", + "catalog", + "view", + }: + self._owner(peer) + if operation == "lock": + self._shape(payload, ("profile_id",)) + self._profile(payload["profile_id"]) + # The native supervisor clears all host keys/jobs immediately, + # including a resolver blocked before it can observe cancellation. + return {"reset_host": True} + if operation not in ( + "profiles", + "action_profile", + "result", + "program_result", + "response", + "stop", + "stage_payload", + "reset_operation", + "catalog", + ) and any( + item.job is not None + and not item.job.done.is_set() + and not item.job.dispatched.is_set() + for item in self.leases.values() + ): + raise CredentialUseError(UseCode.BUSY) + return self._manage(peer, operation, payload) + launch = self._consumer(peer) + context = launch.context + vault = self.vaults[context.binding.scope.profile_id] + if operation in ( + "request_use", + "authorize_grant", + "request_entry", + "status", + "find", + ) and any( + item.job is not None + and not item.job.done.is_set() + and not item.job.dispatched.is_set() + for item in self.leases.values() + ): + raise CredentialUseError(UseCode.BUSY) + if operation == "operation_status": + self._shape(payload, ()) + if launch.job is None: + return {"done": True, "active": False, "error": None} + return { + "done": launch.job.done.is_set(), + "active": True, + "error": launch.job.error, + "outcome": launch.job.result.value + if launch.job.done.is_set() + and launch.job.result is not None + and type(launch.job.result) is not JsonResult + else "completed", + } + if operation == "authorize_grant": + self._shape(payload, ("grant_id",)) + return { + "capability": self.broker.authorize( + context, UUID(payload["grant_id"]) + ).handle + } + if operation == "start_use_staged": + self._shape(payload, ("capability",)) + if any( + item.job is not None + and not item.job.done.is_set() + and not item.job.dispatched.is_set() + for item in self.leases.values() + ): + raise CredentialUseError(UseCode.BUSY) + if ( + sum( + len(item.job.body) if item.job is not None else 0 + for item in self.leases.values() + ) + > 96_000_000 + ): + raise CredentialUseError(UseCode.LIMIT) + if ( + not launch.payload_ready + or launch.job is not None + and not launch.job.done.is_set() + ): + raise CredentialUseError(UseCode.DENIED) + body = {"body": json.loads(launch.payload_text)} + launch.payload_text, launch.payload_ready = "", False + capability = UseCapability(payload["capability"]) + with launch.guard: + if launch.closed.is_set(): + raise CredentialUseError(UseCode.DENIED) + if launch.job is not None: + launch.job.close() + launch.job = OperationJob( + lambda job: self.broker.execute( + context, capability, body, execution=job + ), + budget=self.output_budget, + grant_id=self.broker.capability_grant(context, capability), + ) + launch.job.thread.start() + return {"started": True} + if operation == "status": + self._shape(payload, ()) + return {"state": self.broker.status(context).value} + if operation == "upload_payload_part": + self._shape(payload, ("offset", "data", "done")) + return self._stage(launch, payload) + if operation == "payload_info": + self._shape(payload, ()) + return { + "ready": launch.payload_ready, + "sha256": hashlib.sha256( + launch.payload_text.encode("ascii") + ).hexdigest() + if launch.payload_ready + else None, + } + if operation == "discard_payload": + self._shape(payload, ()) + launch.payload_text, launch.payload_ready = "", False + return {"discarded": True} + if operation == "operations": + self._shape(payload, ("offset",) if "offset" in payload else ()) + offset = payload.get("offset", 0) + entries = tuple(self.registry._entries.values()) + if type(offset) is not int or not 0 <= offset <= len(entries): + raise CredentialUseError(UseCode.DENIED) + return { + "operations": [ + { + "helper": spec.operation.helper_id, + "operation": spec.operation.operation, + } + for spec in entries[offset : offset + 32] + ], + "more": offset + 32 < len(entries), + } + if operation == "find": + self._shape(payload, ("offset",) if "offset" in payload else ()) + offers = self.broker.find(context) + offset = payload.get("offset", 0) + if type(offset) is not int or not 0 <= offset <= len(offers): + raise CredentialUseError(UseCode.DENIED) + return { + "offers": [ + { + "grant_id": str(offer.grant_id), + "credential_id": str(offer.summary.reference.credential_id), + "version": offer.summary.reference.version, + "service": offer.summary.service, + "account": offer.summary.account_label, + "helper": offer.operation.helper_id, + "operation": offer.operation.operation, + } + for offer in offers[offset : offset + 8] + ], + "more": offset + 8 < len(offers), + } + if operation == "request_entry": + self._shape(payload, ("helper", "operation", "account")) + from .identity import _label + from .references import SourceReference + + _label(payload["account"]) + spec = self.registry.require( + HelperOperation(payload["helper"], payload["operation"]) + ) + self.consent.message = f"Unlock {vault.profile.label} for a secure-entry request from {context.binding.caller.subject_id}?\nHelper: {spec.operation.helper_id}/{spec.operation.operation}\nDestination: {spec.endpoint}" + if vault.status().state is not VaultState.READY: + vault.unlock() + names = { + CredentialKind.LOGIN: ("username", "password"), + CredentialKind.API_KEY: ("api_key",), + }.get(spec.kind, (spec.secret_field,)) + if type(spec) is HttpsJsonOperation and spec.protocol == "aws_bedrock": + names = ("access_key_id", "secret_access_key") + if type(spec) is HttpsJsonOperation and spec.protocol == "basic": + names = ("username", "api_token") + if type(spec) is HttpsJsonOperation and spec.protocol == "twitter_oauth1": + names = ("api_key", "api_secret") + if ( + type(spec) is HttpsJsonOperation + and spec.protocol == "telegram_mtproto" + and spec.operation.operation == "complete_password" + ): + names = ("api_hash", "password") + self.consent.message = ( + f"Save a new {spec.kind.value} in {vault.profile.label}?\nCaller: {context.binding.caller.subject_id}\n" + f"Account label: {payload['account']}\nHelper: {spec.operation.helper_id}/{spec.operation.operation}\n" + f"Permitted origin: {spec.origin}\nSaving does not approve credential use. No values return to the requesting app." + ) + fields = self.consent.entry(names) + item = vault.create( + spec.kind, + spec.operation.helper_id, + payload["account"], + fields, + allowed_origins=(spec.origin,), + ) + ref = item.summary.reference + return { + "credential_id": str(ref.credential_id), + "version": ref.version, + "reference": SourceReference( + ref.scope.profile_id, ref.credential_id, ref.version + ).encode(), + } + if operation == "request_use": + if not launch.payload_ready: + raise CredentialUseError(UseCode.DENIED) + approved_body, _ = canonical_request( + {"body": json.loads(launch.payload_text)} + ) + repeat_fields = ("repeat",) if "repeat" in payload else () + self._shape( + payload, + ("helper", "operation", "credential_id", "credential_version") + + repeat_fields + if "credential_version" in payload + else ("helper", "operation", "credential_id") + repeat_fields, + ) + specification = self.registry.require( + HelperOperation(payload["helper"], payload["operation"]) + ) + repeat = payload.get("repeat", False) + if ( + type(repeat) is not bool + or repeat + and ( + type(specification) is not HttpsJsonOperation + or specification.method != "GET" + ) + ): + raise CredentialUseError(UseCode.DENIED) + self.consent.message = f"Unlock {vault.profile.label} for a credential request?\nCaller: {context.binding.caller.subject_id}\nDestination: {specification.endpoint}" + if vault.status().state is not VaultState.READY: + vault.unlock() + candidates = [] + for item in vault.list_items(): + try: + require_match(specification, item) + except CredentialUseError: + continue + if payload[ + "credential_id" + ] is None or item.summary.reference.credential_id == UUID( + payload["credential_id"] + ): + candidates.append(item) + if len(candidates) != 1: + raise CredentialUseError( + UseCode.DENIED + ) # Explicit account selection needed. + item = candidates[0] + if "credential_version" in payload and ( + type(payload["credential_version"]) is not int + or payload["credential_version"] != item.summary.reference.version + ): + raise CredentialUseError(UseCode.CHANGED) + caller = context.binding.caller + self.consent.message = ( + "Authenticate to review a credential request on the private desktop.\n" + "Review caller claims, profile, account, resolved destination and parameters before approving.\n" + + ( + "Allow repeated GET requests for this exact operation and isolated worker until its 15-minute lease expires, lock or revocation. Resource/query parameters may change within the displayed installed route." + if repeat + else "Expires: 30 seconds; one attempt." + ) + ) + # Full request review travels only on the SYSTEM-only UI pipe, not + # a command line, browser channel, log, or caller-supplied summary. + from .request_review import review_request + + self.consent.review_request = { + "Caller label (unverified)": json.dumps(caller.subject_id), + "Caller kind (unverified)": caller.kind.value, + "Claimed deployment (unverified)": caller.deployment.value + if caller.deployment + else "agent", + "Code SHA256": getattr(launch, "snapshot_revision", None) + or caller.app_revision + or "isolated agent snapshot", + "Profile": vault.profile.label, + "Account": item.summary.account_label, + "Credential": f"{item.summary.reference.credential_id} v{item.summary.reference.version}", + "Operation": f"{specification.operation.helper_id}/{specification.operation.operation}", + **review_request(specification, approved_body, repeat=repeat), + } + try: + grant = self.broker.approve( + context, + item.summary.reference, + specification.operation, + mode=GrantMode.BINDING if repeat else GrantMode.ONCE, + lifetime=max(0.01, min(900, launch.deadline - time.monotonic())) + if repeat + else 30, + payload=approved_body, + ) + finally: + self.consent.review_request = None + return { + "capability": self.broker.authorize(context, grant.grant_id).handle, + "grant_id": str(grant.grant_id), + } + if operation == "cancel": + self._shape(payload, ("capability",)) + self.broker.cancel(context, UseCapability(payload["capability"])) + return {"cancelled": True} + if operation in ("use", "use_staged"): + # All network work runs in a cancellable job. A consumer cannot + # select a synchronous route that monopolizes the control pipe. + raise CredentialUseError(UseCode.DENIED) + if operation == "complete": + self._shape(payload, ("result",)) + if len(json.dumps(payload["result"])) > 16000: + raise CredentialUseError(UseCode.LIMIT) + launch.result = {"result": payload["result"]} + return {"completed": True} + if operation == "read_response": + self._shape(payload, ("offset",)) + if launch.job is not None: + return launch.job.snapshot(payload["offset"]) + output, offset = launch.credential_output, payload["offset"] + if ( + output is None + or type(offset) is not int + or not 0 <= offset <= len(output.body) + ): + raise CredentialUseError(UseCode.DENIED) + return { + "status": output.status, + "content_type": output.content_type, + "length": len(output.body), + "chunk": output.body[offset : offset + 4000], + } + raise CredentialUseError(UseCode.DENIED) + + def _stage(self, launch, payload): + if launch.closed.is_set(): + raise CredentialUseError(UseCode.DENIED) + data, offset, done = payload["data"], payload["offset"], payload["done"] + if ( + launch.payload_ready + or type(offset) is not int + or offset != len(launch.payload_text) + or not isinstance(data, str) + or not data.isascii() + or len(data) > 8000 + or type(done) is not bool + or len(launch.payload_text) + len(data) > 32_000_000 + or sum(len(item.payload_text) for item in self.leases.values()) + len(data) + > 64_000_000 + ): + raise CredentialUseError(UseCode.DENIED) + launch.payload_text += data + if done: + value = json.loads(launch.payload_text) + if value is not None and type(value) is not dict: + raise CredentialUseError(UseCode.DENIED) + launch.payload_ready = True + return {"uploaded": len(launch.payload_text), "ready": launch.payload_ready} + + def _manage(self, peer, operation, payload): + if operation in ("action_profile", "action_profile_set"): + self._shape( + payload, ("profile_id",) if operation == "action_profile_set" else () + ) + path = self.state / "action-profile.json" + if operation == "action_profile_set": + profile_id = payload["profile_id"] + vault = self._profile(profile_id) if profile_id is not None else None + self.consent.message = ( + f"Use {vault.profile.label} for credential-isolated agent actions?\nEach launch and credential operation still needs owner approval." + if vault + else "Disable credential-isolated agent actions?" + ) + self.consent.verify(self.owner) + temporary = path.with_suffix(".tmp") + temporary.write_text( + json.dumps( + { + "profile_id": str(vault.profile.scope.profile_id) + if vault + else None + } + ), + encoding="utf-8", + ) + private_acl(temporary) + temporary.replace(path) + value = ( + json.loads(path.read_text(encoding="utf-8"))["profile_id"] + if path.exists() + else None + ) + if value is not None: + self._profile(value) + return {"profile_id": value} + if operation in ("migrate_integration", "migrate_environment"): + self._shape( + payload, + ("profile_id", "path", "provider", "source_accounts") + if operation == "migrate_integration" + else ("profile_id", "path"), + ) + from .migration import ( + migrate_integration_document, + migrate_environment_document, + SOURCE_FIELDS, + ) + from .migration_source import OwnerSettingsSource + from .secrets import Secret + + path = payload["path"] + if ( + not isinstance(path, str) + or len(path) > 400 + or any(ord(c) < 32 for c in path) + ): + raise CredentialUseError(UseCode.DENIED) + if operation == "migrate_integration": + provider, accounts = payload["provider"], payload["source_accounts"] + if ( + provider not in SOURCE_FIELDS + or type(accounts) is not list + or len(accounts) > 32 + or any(not isinstance(a, str) or len(a) > 256 for a in accounts) + ): + raise CredentialUseError(UseCode.DENIED) + filename = provider + ".accounts.json" + else: + filename = Path(path).name + if filename not in (".env", ".env.local"): + raise CredentialUseError(UseCode.DENIED) + vault = self._profile(payload["profile_id"]) + self.consent.message = f"Offline source migration into {vault.profile.label}?\nFile: {path}\nClose all CraftBot instances and writers first. Saved manual sources become verified vault references; generated tokens/sessions stay with their current owners. Restart CraftBot afterwards." + if operation == "migrate_integration": + self.consent.message += ( + "\nExplicit manual-source accounts: " + ", ".join(accounts) + ) + self.consent.verify(self.owner) + if vault.status().state is not VaultState.READY: + vault.unlock() + source = OwnerSettingsSource( + path, self.owner.os_identity, peer.session, filename=filename + ) + try: + source_id = hashlib.sha256( + str(source.path).casefold().encode() + ).hexdigest() + catalog = [ + {"helper": spec.operation.helper_id, "endpoint": spec.endpoint} + for spec in self.registry._entries.values() + ] + if operation == "migrate_integration": + result = migrate_integration_document( + vault, + source_id, + provider, + source.read(), + source.commit, + source_accounts=accounts, + helper_catalog=catalog, + ) + else: + document = Secret(source.read_bytes().reveal().decode("utf-8")) + result = migrate_environment_document( + vault, + source_id, + document, + source.commit_text, + helper_catalog=catalog, + ) + return { + "imported": result.imported, + "retained_references": result.retained_references, + "review_required": result.review_required, + "retained_derived": result.retained_derived, + "restart_required": True, + } + finally: + source.close() + if operation == "items_page": + self._shape(payload, ("profile_id", "view_id", "offset")) + vault = self._profile(payload["profile_id"]) + view = self.views.get(UUID(payload["view_id"])) + if ( + view is None + or view.pid != peer.pid + or view.created != peer.created + or view.scope != vault.profile.scope + or view.epoch != vault.status().unlock_epoch + or time.monotonic() >= view.expires + ): + raise CredentialUseError(UseCode.DENIED) + return { + **metadata_page(view.items, payload["offset"]), + "view_id": payload["view_id"], + } + if operation == "migrate_browser": + self._shape(payload, ("profile_id", "path")) + vault = self._profile(payload["profile_id"]) + if ( + not isinstance(payload["path"], str) + or len(payload["path"]) > 400 + or any(ord(c) < 32 for c in payload["path"]) + ): + raise CredentialUseError(UseCode.DENIED) + self.consent.message = ( + f"Offline saved-login migration into {vault.profile.label}?\nSource: {payload['path']}\n" + "Close all CraftBot instances first. Current logins will enter the protected vault. Encrypted legacy history and its separately encrypted recovery key will be retained by the host; verified legacy source files will be removed. Browser profiles, cookies and sessions stay in place. Restart CraftBot afterwards." + ) + self.consent.verify(self.owner) + if vault.status().state is not VaultState.READY: + vault.unlock() + from .browser_migration import migrate_browser_files + + return migrate_browser_files( + vault, payload["path"], self.state, self.owner.os_identity, peer.session + ) + if operation == "catalog": + self._shape(payload, ("offset",)) + offset = payload["offset"] + entries = tuple(self.registry._entries.values()) + if type(offset) is not int or not 0 <= offset <= len(entries): + raise CredentialUseError(UseCode.DENIED) + rows = [ + { + "helper": spec.operation.helper_id, + "operation": spec.operation.operation, + "endpoint": spec.endpoint, + **( + { + "path_fields": list(spec.path_fields), + "query_fields": list(spec.query_fields), + "envelope": spec.envelope, + "request_format": spec.request_format, + "response_format": spec.response_format, + "header_fields": list(spec.header_fields), + "protocol": spec.protocol, + } + if type(spec) is HttpsJsonOperation + else {} + ), + "method": "POST" + if type(spec) is HttpsFormLogin + else spec.method + if type(spec) is HttpsJsonOperation + else "GET", + "type": "form" + if type(spec) is HttpsFormLogin + else "json" + if type(spec) is HttpsJsonOperation + else "check", + } + for spec in entries[offset : offset + 8] + ] + page = metadata_page(rows, 0)["items"] + next_offset = offset + len(page) + return { + "helpers": page, + "more": next_offset < len(entries), + "next_offset": next_offset, + } + if operation == "migrate_settings": + self._shape(payload, ("profile_id", "path")) + vault = self._profile(payload["profile_id"]) + if not isinstance(payload["path"], str) or len(payload["path"]) > 400: + raise CredentialUseError(UseCode.DENIED) + self.consent.message = f"Offline model-key migration into {vault.profile.label}?\nSource: {payload['path']}\nClose all CraftBot instances and other Settings writers first. Source API keys become references after encrypted destination verification. Tokens and sessions retain their owners. Restart CraftBot afterwards." + self.consent.verify(self.owner) + if vault.status().state is not VaultState.READY: + vault.unlock() + from .migration import migrate_model_document + from .migration_source import OwnerSettingsSource + + source = OwnerSettingsSource( + payload["path"], self.owner.os_identity, peer.session + ) + try: + document = source.read() + source_id = hashlib.sha256( + str(source.path).casefold().encode("utf-8") + ).hexdigest() + result = migrate_model_document( + vault, + source_id, + document, + source.commit, + helper_catalog=[ + { + "helper": s.operation.helper_id, + "endpoint": s.endpoint, + "protocol": s.protocol, + } + for s in self.registry._entries.values() + if type(s) is HttpsJsonOperation + and ( + s.kind is CredentialKind.API_KEY + or s.protocol in ("aws_bedrock", "telegram_mtproto") + ) + ], + ) + return { + "imported": result.imported, + "retained_references": result.retained_references, + "restart_required": True, + } + finally: + source.close() + if operation == "profiles": + self._shape(payload, ()) + return [ + { + "id": str(key), + "label": vault.profile.label, + "state": "busy" + if any( + item.context.binding.scope == vault.profile.scope + and item.job is not None + and not item.job.done.is_set() + for item in self.leases.values() + ) + else vault.status().state.value, + } + for key, vault in self.vaults.items() + ] + if operation == "profile_add": + self._shape(payload, ("label",)) + from .identity import _label + + _label(payload["label"]) + label = payload["label"] + if len(self.vaults) >= 64 or len(label) > 64: + raise CredentialUseError(UseCode.LIMIT) + self.consent.message = f"Create credential profile {label}?" + self.consent.verify(self.owner) + profile = VaultProfile(VaultScope(self.owner.owner_id, uuid4()), label) + vault = ProfileVault( + self.state / "vaults", self.owner, profile, verifier=self.consent + ) + vault._on_lock = lambda: self._cancel_profile(profile.scope) + # Persist a SYSTEM-owned catalog before exposing the new profile. + catalog = self.state / "profiles.new" + catalog.write_text( + json.dumps( + [ + { + "id": str(v.profile.scope.profile_id), + "label": v.profile.label, + } + for v in (*self.vaults.values(), vault) + ] + ), + encoding="utf-8", + ) + private_acl(catalog) + catalog.replace(self.state / "profiles.json") + self.directory.register_profile(profile) + self.vaults[profile.scope.profile_id] = vault + self.broker.add_profile(vault) + return {"id": str(profile.scope.profile_id)} + if operation in ( + "items", + "unlock", + "edit", + "delete", + "view", + "activity", + "revoke", + ): + fields = { + "items": ("profile_id",), + "unlock": ("profile_id",), + "activity": ("profile_id",), + "revoke": ("profile_id", "grant_id"), + "delete": ("profile_id", "credential_id", "version"), + "view": ("profile_id", "credential_id", "version"), + "edit": ( + "profile_id", + "credential_id", + "version", + "service", + "account", + "origins", + "replace_fields", + ), + }[operation] + self._shape(payload, fields) + vault = self._profile(payload["profile_id"]) + self.consent.message = ( + f"{operation.capitalize()} credentials in {vault.profile.label}?" + ) + if operation == "unlock": + vault.unlock() + return {"state": vault.status().state.value} + if operation in ("edit", "delete", "view"): + reference = CredentialReference( + vault.profile.scope, + UUID(payload["credential_id"]), + payload["version"], + ) + current = next( + (i for i in vault.list_items() if i.summary.reference == reference), + None, + ) + if current is None: + raise CredentialUseError(UseCode.CHANGED) + self.consent.message = ( + f"{operation.capitalize()} saved credential?\nProfile: {vault.profile.label}\n" + f"Service: {current.summary.service}\nAccount: {current.summary.account_label}\n" + f"Credential: {reference.credential_id} v{reference.version}" + ) + if operation == "edit": + from .identity import _label + from .records import origins + + _label(payload["service"]) + _label(payload["account"]) + allowed = origins(tuple(payload["origins"])) + self.consent.message += ( + f"\nNew service: {payload['service']}\nNew account: {payload['account']}\n" + f"New permitted origins: {', '.join(allowed)}\nReplace secret fields: {payload['replace_fields'] is True}" + ) + if operation == "revoke": + self.consent.message += f"\nGrant: {UUID(payload['grant_id'])}" + if operation == "view": + # The native executable verifies the owner before accepting the + # private SYSTEM-only response. Public RPC returns no values. + with vault._helper_record( + reference, vault.status().unlock_epoch + ) as record: + self.consent.view(record.fields) + return {"viewed": True} + # Native verification is required even if the requesting browser was + # authenticated and the profile already happens to be unlocked. + self.consent.verify(self.owner) + if operation == "items": + if len(self.views) >= 16: + raise CredentialUseError(UseCode.LIMIT) + view_id = uuid4() + items = tuple(self._item(item) for item in vault.list_items()) + epoch = vault.status().unlock_epoch + if epoch is None: + raise CredentialUseError(UseCode.LOCKED) + self.views[view_id] = OwnerView( + peer.pid, + peer.created, + vault.profile.scope, + epoch, + time.monotonic() + 60, + items, + ) + return {**metadata_page(items, 0), "view_id": str(view_id)} + if operation == "activity": + return [ + { + "action": e.action, + "outcome": e.outcome, + "grant_id": str(e.grant_id) if e.grant_id else None, + } + for e in self.broker.audit_events()[-128:] + if e.scope == vault.profile.scope + ] + if operation == "revoke": + grant_id = UUID(payload["grant_id"]) + for launch in self.leases.values(): + if ( + launch.context.binding.scope == vault.profile.scope + and launch.job is not None + and launch.job.grant_id == grant_id + ): + launch.job.cancelled.set() + self.broker.revoke_scoped(grant_id, vault.profile.scope) + return {"revoked": True} + reference = CredentialReference( + vault.profile.scope, UUID(payload["credential_id"]), payload["version"] + ) + if operation == "delete": + vault.delete(reference) + return {"deleted": True} + if type(payload["replace_fields"]) is not bool: + raise CredentialUseError(UseCode.DENIED) + item = next( + (i for i in vault.list_items() if i.summary.reference == reference), + None, + ) + if item is None: + raise CredentialUseError(UseCode.CHANGED) + secret = None + if payload["replace_fields"]: + names = { + CredentialKind.LOGIN: ("username", "password"), + CredentialKind.API_KEY: ("api_key",), + }.get(item.summary.kind) + if names is None: + with vault._helper_record( + reference, vault.status().unlock_epoch + ) as record: + names = tuple(record.fields.reveal()) + secret = self.consent.entry(names) + return self._item( + vault.update( + reference, + service=payload["service"], + account_label=payload["account"], + fields=secret, + allowed_origins=tuple(payload["origins"]), + ) + ) + if operation in ("initialize", "lock", "entry"): + fields = ( + ("profile_id",) + if operation != "entry" + else ("profile_id", "kind", "service", "account", "origins", "fields") + ) + self._shape(payload, fields) + vault = self._profile(payload["profile_id"]) + self.consent.message = f"{operation.capitalize()} credentials in profile {vault.profile.label}?" + if operation == "lock": + vault.lock() + return {"state": "locked"} + if operation == "initialize": + vault.initialize() + return {"state": "ready"} + kind = CredentialKind(payload["kind"]) + from .identity import _label + from .records import origins + + _label(payload["service"]) + _label(payload["account"]) + origins(tuple(payload["origins"])) + names = { + CredentialKind.LOGIN: ("username", "password"), + CredentialKind.API_KEY: ("api_key",), + }.get(kind) + if names is None: + names = tuple(payload["fields"]) + import re + + if ( + not 1 <= len(names) <= 8 + or len(set(names)) != len(names) + or any( + not isinstance(name, str) + or not re.fullmatch(r"[a-z][a-z0-9_]{0,63}", name) + for name in names + ) + ): + raise CredentialUseError(UseCode.DENIED) + elif payload["fields"] != []: + raise CredentialUseError(UseCode.DENIED) + if vault.status().state is not VaultState.READY: + vault.unlock() + self.consent.message = f"Save {kind.value} for {payload['service']} / {payload['account']} in {vault.profile.label}?\nPermitted origins: {', '.join(payload['origins'])}" + secret = self.consent.entry(names) + item = vault.create( + kind, + payload["service"], + payload["account"], + secret, + allowed_origins=tuple(payload["origins"]), + ) + return {"credential_id": str(item.summary.reference.credential_id)} + if operation == "launch": + self._shape( + payload, + ("profile_id", "kind", "subject", "deployment", "files", "entry"), + ) + if len(self.leases) >= 16: + raise CredentialUseError(UseCode.LIMIT) + vault = self._profile(payload["profile_id"]) + revision = source_files(payload["files"], payload["entry"]) + kind = CallerKind(payload["kind"]) + instance = uuid4() + caller = CallerIdentity( + kind, + payload["subject"], + instance, + revision if kind is CallerKind.AGENT_APP else None, + AppDeployment(payload["deployment"]) + if kind is CallerKind.AGENT_APP + else None, + ) + if kind is not CallerKind.AGENT_APP and payload["deployment"] is not None: + raise CredentialUseError(UseCode.DENIED) + self.consent.message = f"Launch isolated {kind.value}?\nRequested label (unverified): {json.dumps(caller.subject_id)}\nProfile: {vault.profile.label}\nCode SHA256: {revision}\nClaimed deployment (unverified): {payload['deployment'] or 'agent'}\nApp name and deployment are caller claims, not trusted enrollment.\nCredential use requires a separate approval." + self.consent.verify(self.owner) + if not peer.alive(): + raise CredentialUseError(UseCode.DENIED) + process = IsolatedProcess( + self.state / "runs" / instance.hex, + self.root / "python" / "python.exe", + self.owner.os_identity, + peer.session, + ) + binding = controller = None + try: + for name, content in payload["files"].items(): + path = process.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + private_acl(path, process.app_sid) + client = self.root / "app" / "credentials" / "client.py" + (process.root / "credential_client.py").write_bytes(client.read_bytes()) + private_acl(process.root / "credential_client.py", process.app_sid) + # -I removes script dir. The protected generated entry adds only + # its immutable snapshot; it never imports the CraftBot checkout. + runner = process.root / "_host_entry.py" + runner.write_text( + "import sys, runpy\nfrom pathlib import Path\nsys.path.insert(0, str(Path(__file__).parent))\nrunpy.run_path(" + + repr(str(process.root / payload["entry"])) + + ", run_name='__main__')\n", + encoding="utf-8", + ) + private_acl(runner, process.app_sid) + binding = CallerBinding(uuid4(), caller, vault.profile.scope) + self.directory.bind(binding) + context = self.directory.resolve(binding.binding_id, caller, uuid4()) + process.start("_host_entry.py") + controller = Peer(peer.pid) + if controller.created != peer.created or not controller.alive(): + raise CredentialUseError(UseCode.DENIED) + self.leases[instance] = Launch( + process, + context, + controller, + time.monotonic() + 900, + snapshot_revision=revision, + ) + launch = self.leases[instance] + timer = Timer(900, self._expire, args=(instance, launch)) + timer.daemon = True + self.leases[instance].timer = timer + timer.start() + Thread(target=self._watch, args=(instance, launch), daemon=True).start() + except BaseException: + self.leases.pop(instance, None) + if binding is not None: + self.directory.revoke(binding.binding_id) + if controller is not None: + controller.close() + process.close() + raise + return { + "run_id": str(instance), + "revision": revision, + "identity_verified": False, + } + if operation in ( + "result", + "program_result", + "stop", + "response", + "stage_payload", + "reset_operation", + ): + self._shape( + payload, + ("run_id", "offset", "data", "done") + if operation == "stage_payload" + else ("run_id", "offset") + if operation == "response" + else ("run_id",), + ) + key = UUID(payload["run_id"]) + launch = self.leases.get(key) + if ( + launch is None + or launch.controller.pid != peer.pid + or launch.controller.created != peer.created + ): + raise CredentialUseError(UseCode.DENIED) + if operation == "stage_payload": + return self._stage(launch, payload) + if operation == "program_result": + if launch.result is not None: + return launch.result + if api()[6].WaitForSingleObject(launch.process.handle, 0) == 0: + return { + "exited": api()[3].GetExitCodeProcess(launch.process.handle) + } + return {"pending": True} + if operation == "reset_operation": + if launch.job is None or not launch.job.done.is_set(): + raise CredentialUseError(UseCode.DENIED) + launch.job.close() + launch.job, launch.credential_output, launch.result = None, None, None + return {"ready": True} + if operation == "response": + if launch.job is not None: + return launch.job.snapshot(payload["offset"]) + output = launch.credential_output + offset = payload["offset"] + if ( + output is None + or type(offset) is not int + or not 0 <= offset <= len(output.body) + ): + raise CredentialUseError(UseCode.DENIED) + return {"chunk": output.body[offset : offset + 4000]} + if operation == "result": + if launch.job is not None: + snapshot = launch.job.snapshot() + if snapshot["error"]: + raise CredentialUseError(UseCode(snapshot["error"])) + if snapshot["status"] is not None: + return { + "response": { + key: snapshot[key] + for key in ("status", "content_type", "length", "done") + } + } + if snapshot["done"]: + result = launch.job.result + return { + "result": { + "outcome": result.value + if result is not None + else "unavailable" + } + } + if launch.credential_output is not None: + output = launch.credential_output + return { + "response": { + "status": output.status, + "content_type": output.content_type, + "length": len(output.body), + } + } + if launch.result is not None: + return launch.result + if api()[6].WaitForSingleObject(launch.process.handle, 0) == 0: + return { + "exited": api()[3].GetExitCodeProcess(launch.process.handle) + } + return {"pending": True} + self._expire(key, launch) + return {"stopped": True} + raise CredentialUseError(UseCode.DENIED) + + @staticmethod + def _item(item): + summary = item.summary + return { + "credential_id": str(summary.reference.credential_id), + "version": summary.reference.version, + "kind": summary.kind.value, + "service": summary.service, + "account": summary.account_label, + "origins": list(item.allowed_origins), + "updated_at": item.updated_at, + } + + +def resolve_envelope(envelope): + if ( + type(envelope) is not dict + or set(envelope) + != {"peer_pid", "peer_created", "peer_sid", "peer_app_sid", "payload"} + or type(envelope["peer_pid"]) is not int + ): + raise CredentialUseError(UseCode.DENIED) + peer = Peer(envelope["peer_pid"]) + try: + if ( + str(peer.created) != envelope["peer_created"] + or not peer.alive() + or peer.sid != envelope["peer_sid"] + or peer.app_sid != envelope["peer_app_sid"] + ): + raise CredentialUseError(UseCode.DENIED) + except BaseException: + peer.close() + raise + return peer + + +def main(root: Path): + # No environment opt-out or developer bypass. SCM/SYSTEM is mandatory. + import win32con + import win32security + import ctypes + + buffer = ctypes.create_unicode_buffer(32768) + if ctypes.windll.shell32.SHGetFolderPathW(0, 0x23, None, 0, buffer) != 0: + raise IsolationUnavailable() + state = Path(buffer.value) / "CraftBotCredentialHost" + check_install(root, state) + with closing( + win32security.OpenProcessToken(api()[0].GetCurrentProcess(), 0x28) + ) as token: + win32security.AdjustTokenPrivileges( + token, + False, + [ + ( + win32security.LookupPrivilegeValue(None, name), + win32con.SE_PRIVILEGE_ENABLED, + ) + for name in ( + "SeTcbPrivilege", + "SeAssignPrimaryTokenPrivilege", + "SeIncreaseQuotaPrivilege", + "SeRestorePrivilege", + ) + ], + ) + host = ProtectedHost( + root, state, json.loads((root / "host.json").read_text(encoding="utf-8-sig")) + ) + for line in sys.stdin: + peer = None + try: + if len(line) > 400001: # Native envelope limit plus trailing newline. + raise CredentialUseError(UseCode.DENIED) + envelope = json.loads(line) + peer = resolve_envelope(envelope) + value = host.dispatch(peer, envelope["payload"]) + response = {"ok": True, "result": value} + except CredentialUseError as error: + response = {"ok": False, "error": error.code.value} + except BaseException: + response = {"ok": False, "error": UseCode.UNAVAILABLE.value} + finally: + if peer is not None: + peer.close() + print(json.dumps(response, ensure_ascii=True), flush=True) diff --git a/app/credentials/identity.py b/app/credentials/identity.py new file mode 100644 index 000000000..8b6e4ed13 --- /dev/null +++ b/app/credentials/identity.py @@ -0,0 +1,143 @@ +"""CM-01 identity model, independent of browser owners and agent bundles. + +All records are host-owned metadata. Never deserialize a CallerBinding from +agent/tool input: UUIDs and immutable records are identifiers, not credentials. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +import unicodedata +from uuid import UUID + + +def _uuid(value: UUID) -> None: + if not isinstance(value, UUID) or value.int == 0: + raise ValueError("A nonzero UUID is required") + + +def _label(value: str) -> None: + if ( + not isinstance(value, str) + or not value.strip() + or len(value) > 256 + or any( + unicodedata.category(char) in ("Cc", "Cf", "Cs", "Zl", "Zp") + for char in value + ) + ): + raise ValueError("A nonempty label of at most 256 characters is required") + + +@dataclass(frozen=True) +class VaultOwner: + """Human owner provisioned against a verified local OS account. + + os_identity is a platform-qualified stable account identifier, not a display + name, email, Telegram owner flag, session id, or environment variable. + CM-01 does not implement OS verification or owner provisioning. + """ + + owner_id: UUID + os_identity: str = field(repr=False) + + def __post_init__(self) -> None: + _uuid(self.owner_id) + _label(self.os_identity) + + +@dataclass(frozen=True) +class VaultScope: + owner_id: UUID + profile_id: UUID + + def __post_init__(self) -> None: + _uuid(self.owner_id) + _uuid(self.profile_id) + + +@dataclass(frozen=True) +class VaultProfile: + """Credential namespace; labels are not unique or security identifiers.""" + + scope: VaultScope + label: str + + def __post_init__(self) -> None: + if not isinstance(self.scope, VaultScope): + raise ValueError("A vault scope is required") + _label(self.label) + + +class CallerKind(str, Enum): + AGENT_SESSION = "agent_session" + SUBAGENT = "subagent" + AGENT_APP = "agent_app" + + +class AppDeployment(str, Enum): + LIVE = "live" + DEVELOPMENT = "development" + + +@dataclass(frozen=True) +class CallerIdentity: + """Host binding identity, pinned to an authenticated isolated process. + + instance_id rotates when the authenticated execution channel is replaced. + Agent Apps additionally bind the actual immutable snapshot hash. Subject, + kind and deployment are unverified caller labels, never trusted enrollment. + Neither a manifest capability nor knowing these values is authentication. + """ + + kind: CallerKind + subject_id: str + instance_id: UUID + app_revision: str | None = None + deployment: AppDeployment | None = None + + def __post_init__(self) -> None: + if not isinstance(self.kind, CallerKind): + raise ValueError("A caller kind is required") + _label(self.subject_id) + _uuid(self.instance_id) + if self.kind is CallerKind.AGENT_APP: + _label(self.app_revision) + if not isinstance(self.deployment, AppDeployment): + raise ValueError("An app deployment is required") + elif self.app_revision is not None or self.deployment is not None: + raise ValueError("App identity fields require an Agent App caller") + + +@dataclass(frozen=True) +class CallerBinding: + """Host-selected profile for an authenticated consumer channel. + + Multiple channels may bind the same app/session to different profiles, but + a channel's binding never changes underneath an in-flight request. + """ + + binding_id: UUID + caller: CallerIdentity + scope: VaultScope + + def __post_init__(self) -> None: + _uuid(self.binding_id) + if not isinstance(self.caller, CallerIdentity) or not isinstance( + self.scope, VaultScope + ): + raise ValueError("A caller identity and vault scope are required") + + +@dataclass(frozen=True) +class ExecutionContext: + """Out-of-band host context, never a tool's session/profile claims.""" + + binding: CallerBinding + execution_id: UUID + + def __post_init__(self) -> None: + if not isinstance(self.binding, CallerBinding): + raise ValueError("A host caller binding is required") + _uuid(self.execution_id) diff --git a/app/credentials/integrations.py b/app/credentials/integrations.py new file mode 100644 index 000000000..5841e7d46 --- /dev/null +++ b/app/credentials/integrations.py @@ -0,0 +1,435 @@ +"""Host adapter for source accounts. No raw source credential enters a provider.""" + +import asyncio +import json + +from .client import CredentialHostUnavailable +from .invocation import invoke +from .references import parse +from types import SimpleNamespace + + +METHODS = { + "figma": {"get_current_user": ("get_figma_current_user", (), {})}, + "slack": { + "list_channels": ( + "list_slack_channels", + ("types", "limit", "exclude_archived"), + { + "types": "public_channel,private_channel", + "limit": 100, + "exclude_archived": True, + }, + ), + "get_channel_info": ("get_slack_channel_info", ("channel",), {}), + "get_channel_history": ( + "get_slack_channel_history", + ("channel", "limit", "oldest", "latest"), + {"limit": 100}, + ), + "list_users": ("list_slack_users", ("limit",), {"limit": 100}), + "get_user_info": ("get_slack_user_info", ("user_id",), {}), + "get_thread_replies": ( + "get_slack_thread_replies", + ("channel", "ts", "limit", "cursor"), + {"limit": 100}, + ), + }, +} + + +class SourceOperationFacade: + """A named-operation interface with references only; no SDK/auth internals.""" + + def __init__(self, provider, reference, metadata): + parse(reference) + self._provider, self._reference = provider, reference + self.cred = SimpleNamespace( + **{ + key: metadata[key] + for key in ("user_id", "username", "workspace_id") + if key in metadata + } + ) + + def __getattr__(self, method): + rule = METHODS.get(self._provider, {}).get(method) + if rule is None: + raise AttributeError("credential_operation_helper_unavailable") + + def call(*args, **kwargs): + operation, parameters, defaults = rule + if len(args) > len(parameters) or set(parameters[: len(args)]) & set( + kwargs + ): + raise TypeError("credential_operation_arguments_invalid") + data = {**defaults, **dict(zip(parameters, args)), **kwargs} + if self._provider == "slack" and method == "get_user_info": + data["user"] = data.pop("user_id") + response = invoke( + self._reference, + f"integration_{self._provider}", + operation, + data or None, + subject=f"{self._provider}: {operation}", + ) + if not 200 <= response["status"] < 300: + return {"error": "credential_operation_failed"} + result = json.loads(response["body"]) + return ( + {"ok": True, "result": result} if self._provider == "figma" else result + ) + + if self._provider == "figma": + + async def async_call(*args, **kwargs): + return await asyncio.to_thread(call, *args, **kwargs) + + return async_call + return call + + +class RoutedSourceClient: + """Run the unchanged provider's normalizers with a dummy, unauthenticated client. + + Context routing only selects a transport. Authority remains the protected + host's real process binding and native consent, never the ContextVar. + """ + + def __init__(self, provider, reference, metadata): + self.provider, self.reference = provider.id, reference + self.sessions = {} + fake = { + **metadata, + **{ + key: "vault-managed" + for key in ( + "access_token", + "token", + "bot_token", + "api_key", + "personal_api_key", + "secret_key", + "channel_access_token", + ) + }, + } + if provider.id in ("figma", "supabase"): + fake["auth_kind"] = "pat" + if provider.id == "twitter": + fake.update( + api_key="vault-managed", + api_secret="vault-managed", + access_token=metadata.get("access_token", ""), + access_token_secret=metadata.get("access_token_secret", ""), + ) + if provider.id == "jira": + fake.update( + access_token="", + cloud_id="", + email="vault-managed@example.invalid", + api_token="vault-managed", + ) + self.client = provider.build_client(fake, lambda updated: None) + + def _request( + self, + method, + url, + *, + cancelled, + headers=None, + json=None, + params=None, + data=None, + files=None, + content=None, + repeat=False, + **transport_options, + ): + import httpx + from .model_transport import installed_model_helpers, multipart_data + from .route_policy import match_route + from urllib.parse import urlencode + + routes = [ + r + for r in installed_model_helpers() + if r["helper"] == "integration_" + self.provider + ] + if params: + if self.provider == "telegram_bot" and isinstance(params, dict): + import json as json_module + + params = { + k: json_module.dumps(v) if isinstance(v, list) else v + for k, v in params.items() + } + url += ("&" if "?" in url else "?") + urlencode(params, doseq=True) + if self.provider == "telegram_bot": + url = url.replace("/botvault-managed/", "/bot_TOKEN_/") + matched = match_route(routes, method.upper(), url) + if matched is None: + raise CredentialHostUnavailable("credential_destination_denied") + route, path, query = matched + body = json if json is not None else data + if content is not None: + import base64 + + if route.get("request_format") != "image" or not isinstance(content, bytes): + raise CredentialHostUnavailable("credential_request_invalid") + body = { + "data": base64.b64encode(content).decode(), + "content_type": (headers or {}).get("Content-Type"), + } + if files: + request = httpx.Request(method, url, data=data, files=files) + body = multipart_data(request.read(), request.headers["Content-Type"]) + payload = ( + { + "path": path, + "query": query, + "data": body, + "headers": { + k: v + for k, v in (headers or {}).items() + if k in route.get("header_fields", ()) + }, + } + if route.get("envelope") + else query + if method.upper() == "GET" + else body + ) + if self.provider == "twitter": + payload["session"] = { + name: getattr(self.client._cred, name) + for name in ("access_token", "access_token_secret") + } + arguments = ( + self.reference, + route["helper"], + route["operation"], + payload, + ) + if repeat and method.upper() == "GET": + from .operation_session import OperationSession + import time + + key = route["operation"] + session = self.sessions.get(key) + if session is None or session.closed or time.monotonic() >= session.expires: + if session is not None: + session.close() + session = OperationSession( + *arguments[:3], self.provider + " listener: " + key + ) + self.sessions[key] = session + result = session.invoke(payload, cancelled=cancelled) + else: + result = invoke( + *arguments, + subject=self.provider + ": " + route["operation"], + cancelled=cancelled, + ) + if route.get("response_format") == "binary" and 200 <= result["status"] < 300: + import base64 + import json as json_module + + binary = json_module.loads(result["body"]) + result = { + **result, + "body": base64.b64decode(binary["data"], validate=True), + "content_type": binary["media_type"], + } + return httpx.Response( + result["status"], + content=result["body"].encode() + if isinstance(result["body"], str) + else result["body"], + headers={"Content-Type": result["content_type"]}, + ) + + def make_source_listener(self, provider, cursor, emit): + from .listeners import SourceListener + + return SourceListener(self, provider, cursor, emit) + + def close_sessions(self): + for session in self.sessions.values(): + session.close() + self.sessions.clear() + + def __getattr__(self, name): + if name in ("_headers", "_bot_headers", "_load", "_ensure_token"): + raise AttributeError("credential_auth_client_unavailable") + value = getattr(self.client, name) + if self.provider == "discord" and name.startswith("user_"): + return value # Existing generated user session retains its transport. + if not callable(value): + return value + import inspect + from threading import Event + from functools import partial + from craftos_integrations.helpers.source_requests import using + + if inspect.iscoroutinefunction(value): + + async def asynchronous(*args, **kwargs): + cancelled = Event() + try: + with using(partial(self._request, cancelled=cancelled)): + return await value(*args, **kwargs) + except asyncio.CancelledError: + cancelled.set() + raise + + return asynchronous + + def synchronous(*args, **kwargs): + with using(partial(self._request, cancelled=Event())): + return value(*args, **kwargs) + + return synchronous + + +class SavedSourceOperations: + def __init__(self, providers=()): + self.providers = {p.id: p for p in providers} + + def client_for(self, provider_id, reference, metadata): + if provider_id in self.providers: + return RoutedSourceClient(self.providers[provider_id], reference, metadata) + return SourceOperationFacade(provider_id, reference, metadata) + + def connect(self, provider_id, reference, *, public_metadata=None): + parse(reference) + public_metadata = public_metadata or {} + metadata_fields = { + "whatsapp_business": {"phone_number_id"}, + "twitter": {"access_token", "access_token_secret"}, + } + if public_metadata and set(public_metadata) != metadata_fields.get( + provider_id, set() + ): + raise CredentialHostUnavailable("credential_account_verification_failed") + body = None + if provider_id == "twitter": + if set(public_metadata) != metadata_fields[provider_id]: + raise CredentialHostUnavailable( + "credential_account_verification_failed" + ) + body = {"path": {}, "query": {}, "data": None, "session": public_metadata} + if provider_id == "whatsapp_business": + import re + + phone = public_metadata.get("phone_number_id") + if not isinstance(phone, str) or not re.fullmatch(r"[0-9]{5,32}", phone): + raise CredentialHostUnavailable( + "credential_account_verification_failed" + ) + body = {"path": {"phone_number_id": phone}, "query": {}, "data": None} + response = invoke( + reference, + f"integration_{provider_id}", + "verify", + body, + subject=f"Connect {provider_id} saved account", + ) + if response["status"] != 200: + raise CredentialHostUnavailable("credential_account_verification_failed") + data = json.loads(response["body"]) + if type(data) is not dict or data.get("ok") is False: + raise CredentialHostUnavailable("credential_account_verification_failed") + if provider_id == "twitter": + identity = data.get("data", {}).get("id") + metadata = { + "auth_kind": "vault", + "user_id": identity, + "username": data.get("data", {}).get("username", ""), + **public_metadata, + } + elif provider_id == "github": + identity = data.get("login") + metadata = {"auth_kind": "vault", "username": identity} + elif provider_id == "figma": + identity = str(data["id"]) if type(data.get("id")) in (str, int) else None + metadata = {"auth_kind": "vault", "user_id": identity} + elif provider_id == "slack" and data.get("ok") is True: + identity = data.get("team_id") + metadata = { + "auth_kind": "vault", + "workspace_id": identity, + "user_id": data.get("user_id"), + } + else: + if provider_id not in self.providers: + raise CredentialHostUnavailable( + "credential_account_verification_failed" + ) + if provider_id == "telegram_bot": + data = data.get("result", {}) + identity = str( + data.get("id") + or data.get("accountId") + or data.get("portalId") + or data.get("userId") + or data.get("account_id") + or data.get("workspace_id") + or "vault-" + str(parse(reference).credential_id) + ) + metadata = {"auth_kind": "vault", "account_id": identity} + if provider_id == "whatsapp_business": + if identity != public_metadata["phone_number_id"]: + raise CredentialHostUnavailable( + "credential_account_verification_failed" + ) + metadata["phone_number_id"] = identity + from .model_transport import installed_model_helpers + from urllib.parse import urlsplit + + route = next( + ( + r + for r in installed_model_helpers() + if r["helper"] == "integration_" + provider_id + and r["operation"] == "verify" + ), + None, + ) + if route: + origin = urlsplit(route["endpoint"]) + metadata.update( + host=f"https://{origin.netloc}", + domain=origin.netloc, + site_url=f"https://{origin.netloc}", + ) + for field in ("bot_id", "bot_user_id", "hub_id", "user_id"): + metadata[field] = identity + if isinstance(data.get("username"), str): + metadata["bot_username"] = data["username"] + if not isinstance(identity, str) or not identity or len(identity) > 256: + raise CredentialHostUnavailable("credential_account_verification_failed") + return identity, metadata + + async def execute(self, provider_id, operation, reference, input_data): + # UI shaping flags never become arbitrary provider query parameters. + data = dict(input_data) + data.pop("include_metadata", None) + if provider_id == "figma" and operation == "get_figma_current_user": + data = None + from .invocation import invoke_async + + response = await invoke_async( + reference, + f"integration_{provider_id}", + operation, + data, + subject=f"{provider_id}: {operation}", + ) + if not 200 <= response["status"] < 300: + return {"error": "credential_operation_failed"} + result = json.loads(response["body"]) + if provider_id == "slack" and result.get("ok") is not True: + return {"error": "credential_operation_failed"} + return {"status": "success", "result": result} diff --git a/app/credentials/invocation.py b/app/credentials/invocation.py new file mode 100644 index 000000000..619f80ab6 --- /dev/null +++ b/app/credentials/invocation.py @@ -0,0 +1,216 @@ +"""Host-blind controllers execute fixed operations through an isolated worker. + +Only source references, request data and sanitized responses cross this module. +The native host confirms launch, then independently confirms the one-use grant. +The public SDK never obtains a raw credential or an authenticated HTTP client. +""" + +import json +import hashlib +import time + +from .client import CredentialHostUnavailable, request +from .references import parse + + +def open_response( + reference: str, + helper: str, + operation: str, + body=None, + *, + subject="credential operation", + cancelled=None, +): + ref = parse(reference) + serialized = json.dumps( + body, ensure_ascii=True, allow_nan=False, separators=(",", ":") + ) + if len(serialized) > 32_000_000: + raise CredentialHostUnavailable("credential_use_limit") + data = { + "helper": helper, + "operation": operation, + "credential_id": str(ref.credential_id), + "credential_version": ref.version, + } + source = ( + "import credential_client, time\ndata = " + + repr(data) + + "\nexpected = " + + repr(hashlib.sha256(serialized.encode("ascii")).hexdigest()) + + """ +try: + deadline = time.monotonic() + 120 + while time.monotonic() < deadline: + info = credential_client.request('payload_info') + if info['ready']: + break + time.sleep(0.1) + if not info['ready'] or info['sha256'] != expected: + raise RuntimeError() + approval = credential_client.request('request_use', **data) + credential_client.request('start_use_staged', capability=approval['capability']) + while not credential_client.request('operation_status')['done']: + time.sleep(0.1) + credential_client.complete({'outcome': 'completed'}) +except Exception: + credential_client.complete({'error': 'credential_operation_failed'}) +""" + ) + if len(json.dumps({"call.py": source}).encode("utf-8")) > 47000: + raise CredentialHostUnavailable("credential_use_limit") + launch = request( + "launch", + profile_id=str(ref.profile_id), + kind="agent_session", + subject=subject, + deployment=None, + files={"call.py": source}, + entry="call.py", + ) + try: + for offset in range(0, len(serialized), 8000): + chunk = serialized[offset : offset + 8000] + request( + "stage_payload", + run_id=launch["run_id"], + offset=offset, + data=chunk, + done=offset + len(chunk) == len(serialized), + ) + deadline = time.monotonic() + 900 # Includes interactive owner prompts. + while time.monotonic() < deadline: + if cancelled is not None and cancelled.is_set(): + raise CredentialHostUnavailable("credential_use_interrupted") + result = request("result", run_id=launch["run_id"]) + if "response" in result: + response = result["response"] + length = response["length"] + if type(length) is not int or not 0 <= length <= 96_000_000: + raise CredentialHostUnavailable("credential_response_invalid") + return CredentialResponse(launch["run_id"], response, cancelled) + if ( + "result" in result + and type(result["result"]) is dict + and result["result"].get("outcome") + in ("accepted", "rejected", "unavailable") + ): + outcome = result["result"]["outcome"] + request("stop", run_id=launch["run_id"]) + return StaticResponse( + {"accepted": 200, "rejected": 401, "unavailable": 503}[outcome], + json.dumps({"outcome": outcome}), + ) + if "result" in result or "exited" in result: + raise CredentialHostUnavailable("credential_operation_failed") + time.sleep(0.15) + raise CredentialHostUnavailable("credential_operation_timeout") + except BaseException: + try: + request("stop", run_id=launch["run_id"]) + except CredentialHostUnavailable: + pass + raise + + +class CredentialResponse: + def __init__(self, run_id, metadata, cancelled=None, *, stop_on_close=True): + self.run_id, self.status, self.content_type = ( + run_id, + metadata["status"], + metadata["content_type"], + ) + self.initial, self.cancelled, self.closed = metadata, cancelled, False + self.stop_on_close = stop_on_close + + def __iter__(self): + offset, deadline = 0, time.monotonic() + 900 + try: + while time.monotonic() < deadline: + if self.cancelled is not None and self.cancelled.is_set(): + raise CredentialHostUnavailable("credential_use_interrupted") + result = request("response", run_id=self.run_id, offset=offset) + if result.get("error"): + raise CredentialHostUnavailable("credential_operation_failed") + chunk = result["chunk"] + if not isinstance(chunk, str) or len(chunk) > 8000: + raise CredentialHostUnavailable("credential_response_invalid") + if chunk: + offset += len(chunk) + if offset > 96_000_000: + raise CredentialHostUnavailable("credential_use_limit") + yield chunk + done = result.get("done", self.initial.get("done", True)) + length = result.get("length", self.initial["length"]) + if done and offset >= length: + return + if not chunk: + time.sleep(0.025) + raise CredentialHostUnavailable("credential_operation_timeout") + finally: + self.close() + + def close(self): + if not self.closed: + self.closed = True + if self.stop_on_close: + try: + request("stop", run_id=self.run_id) + except CredentialHostUnavailable: + pass + + +class StaticResponse: + content_type = "application/json" + + def __init__(self, status, body): + self.status, self.body = status, body + + def __iter__(self): + yield self.body + + def close(self): + pass + + +def invoke( + reference, + helper, + operation, + body=None, + *, + subject="credential operation", + cancelled=None, +): + response = open_response( + reference, helper, operation, body, subject=subject, cancelled=cancelled + ) + try: + return { + "status": response.status, + "content_type": response.content_type, + "body": "".join(response), + } + finally: + response.close() + + +async def invoke_async(*args, **kwargs): + import asyncio + from threading import Event + + cancelled = Event() + task = asyncio.create_task( + asyncio.to_thread(invoke, *args, **kwargs, cancelled=cancelled) + ) + try: + return await asyncio.shield(task) + except asyncio.CancelledError: + cancelled.set() + # The host closes an active socket when stop reaches the control pipe. + # Dispatched provider effects cannot be retracted. + task.add_done_callback( + lambda future: future.exception() if not future.cancelled() else None + ) + raise diff --git a/app/credentials/keys.py b/app/credentials/keys.py new file mode 100644 index 000000000..7140851cf --- /dev/null +++ b/app/credentials/keys.py @@ -0,0 +1,101 @@ +"""Host key facility and owner-verification ports; no raw-file fallback. + +Windows stores the encryption key as a generic OS credential, NOT beside the +vault. Same-user hostile code still requires the CM-03 isolation boundary. +""" + +from __future__ import annotations + +import sys +from typing import Protocol + +from .errors import VaultError, VaultErrorCode as Code +from .identity import VaultOwner, VaultScope +from .secrets import Secret + + +def key_target(scope: VaultScope) -> str: + return f"CraftBot/Vault/v1/{scope.owner_id}/{scope.profile_id}" + + +class KeyStore(Protocol): + """Internal OS adapter. create is called under the profile write lock.""" + + def load(self, scope: VaultScope) -> Secret | None: ... + + def create(self, scope: VaultScope, key: Secret) -> None: ... + + +class OwnerVerifier(Protocol): + """Must perform real owner verification; caller flags are not evidence.""" + + def verify(self, owner: VaultOwner) -> None: ... + + +class VerificationUnavailable: + def verify(self, owner: VaultOwner) -> None: + raise VaultError(Code.VERIFICATION_REQUIRED) + + +class WindowsCredentialKeyStore: + """Uses the already-declared Windows pywin32 dependency, loaded lazily. + + No credential enumeration, user login-password access or key deletion. + A missing Windows/logon/backend fails closed. Keys stay device-bound. + """ + + @staticmethod + def _api(): + if sys.platform != "win32": + raise VaultError(Code.BACKEND_UNAVAILABLE) + try: + import win32cred + + return win32cred + except ImportError: + raise VaultError(Code.BACKEND_UNAVAILABLE) from None + + def load(self, scope: VaultScope) -> Secret | None: + api = self._api() + try: + record = Secret(api.CredRead(key_target(scope), api.CRED_TYPE_GENERIC, 0)) + except Exception as exc: + if getattr(exc, "winerror", None) == 1168: # ERROR_NOT_FOUND only + return None + raise VaultError(Code.BACKEND_UNAVAILABLE) from None + key = Secret(record.reveal().get("CredentialBlob")) + # pywin32 writes generic CredentialBlob strings as UTF-16LE and reads + # bytes back. Fernet itself takes a 44-byte ASCII base64 key. + if not isinstance(key.reveal(), bytes) or len(key.reveal()) != 88: + raise VaultError(Code.UNREADABLE) + try: + key = Secret(key.reveal().decode("utf-16-le").encode("ascii")) + except UnicodeError: + raise VaultError(Code.UNREADABLE) from None + if len(key.reveal()) != 44: + raise VaultError(Code.UNREADABLE) + return key + + def create(self, scope: VaultScope, key: Secret) -> None: + if ( + not isinstance(key, Secret) + or not isinstance(key.reveal(), bytes) + or len(key.reveal()) != 44 + ): + raise VaultError(Code.INVALID) + if self.load(scope) is not None: + raise VaultError(Code.CONFLICT) + api = self._api() + try: + api.CredWrite( + { + "Type": api.CRED_TYPE_GENERIC, + "TargetName": key_target(scope), + "UserName": "CraftBot vault key", + "CredentialBlob": key.reveal().decode("ascii"), + "Persist": api.CRED_PERSIST_LOCAL_MACHINE, + }, + 0, + ) + except Exception: + raise VaultError(Code.BACKEND_UNAVAILABLE) from None diff --git a/app/credentials/listeners.py b/app/credentials/listeners.py new file mode 100644 index 000000000..8099716aa --- /dev/null +++ b/app/credentials/listeners.py @@ -0,0 +1,93 @@ +"""Source listeners retain normal event shaping; their transport has no sources.""" + +import asyncio +import json +from functools import partial +from threading import Event + +from craftos_integrations.helpers.source_requests import using +from .invocation import open_response + + +class SourceListener: + def __init__(self, source, provider, cursor, emit): + self.source, self.provider, self.emit = source, provider, emit + self.cancelled, self.task = Event(), None + self.inner = ( + None + if provider.id == "discord" + else provider.make_listener(source.client, cursor, emit) + ) + self.poll_interval = getattr(self.inner, "poll_interval", 3) + + async def start(self): + if self.provider.id == "discord": + if self.task is None: + self.task = asyncio.create_task(self._discord()) + if self.task.done(): + await self.task + return + if self.inner is not None: + with using( + partial(self.source._request, cancelled=self.cancelled, repeat=True) + ): + await self.inner.start() + + async def stop(self): + self.cancelled.set() + try: + if self.task is not None: + self.task.cancel() + try: + await self.task + except asyncio.CancelledError: + pass + if self.inner is not None: + await self.inner.stop() + finally: + await asyncio.to_thread(self.source.close_sessions) + + def cursor(self): + return self.inner.cursor() if self.inner is not None else None + + async def _discord(self): + from craftos_integrations.providers._shared import emit_callback + + client = self.source.client + client._message_callback = emit_callback(self.emit) + response = None + try: + response = await asyncio.to_thread( + open_response, + self.source.reference, + "integration_discord", + "gateway_events", + subject="Discord listener", + cancelled=self.cancelled, + ) + iterator, buffer = iter(response), "" + while not self.cancelled.is_set(): + part = await asyncio.to_thread(next, iterator, None) + if part is None: + break + buffer += part + while "\n\n" in buffer: + frame, buffer = buffer.split("\n\n", 1) + data = json.loads(frame.removeprefix("data: ")) + if data.get("t") == "READY": + client._bot_user_id = data["d"]["user"]["id"] + client._catchup_done = True + elif data.get("t") == "MESSAGE_CREATE": + with using( + partial( + self.source._request, + cancelled=self.cancelled, + repeat=True, + ) + ): + await client._handle_message_create(data["d"]) + raise RuntimeError("credential_listener_expired") + finally: + self.cancelled.set() + if response is not None: + await asyncio.to_thread(response.close) diff --git a/app/credentials/media_clients.py b/app/credentials/media_clients.py new file mode 100644 index 000000000..cf623d8b6 --- /dev/null +++ b/app/credentials/media_clients.py @@ -0,0 +1,65 @@ +"""Bounded inline video understanding; ordinary file bytes, never vault values.""" + +import base64 +import mimetypes +from pathlib import Path +from .references import parse +from .model_transport import requests_response + + +def analyse_video(reference, filename, model, prompt): + parse(reference) + path = Path(filename) + media_type = mimetypes.guess_type(path.name)[0] + if media_type not in ( + "video/mp4", + "video/quicktime", + "video/x-msvideo", + "video/webm", + "video/mpeg", + ): + raise ValueError("credential_video_format_unsupported") + with path.open("rb") as source: + data = source.read(23_000_001) + if len(data) > 23_000_000: + # The existing caller retains its keyframe fallback for larger inputs. + raise ValueError("credential_video_input_limit") + payload = { + "contents": [ + { + "role": "user", + "parts": [ + { + "inlineData": { + "mimeType": media_type, + "data": base64.b64encode(data).decode(), + } + }, + {"text": prompt}, + ], + } + ] + } + response = requests_response( + "gemini", + reference, + "POST", + "https://generativelanguage.googleapis.com/v1beta/models/" + + model + + ":generateContent", + payload, + ) + response.raise_for_status() + result = response.json() + if result.get("promptFeedback", {}).get("blockReason"): + raise ValueError("credential_video_no_content") + for candidate in result.get("candidates", []): + if candidate.get("finishReason") == "SAFETY": + continue + parts = (candidate.get("content") or {}).get("parts", []) + text = "".join( + part.get("text", "") for part in parts if isinstance(part, dict) + ).strip() + if text: + return text + raise ValueError("credential_video_no_content") diff --git a/app/credentials/migration.py b/app/credentials/migration.py new file mode 100644 index 000000000..2e3b33018 --- /dev/null +++ b/app/credentials/migration.py @@ -0,0 +1,739 @@ +"""Host-only, resumable source migration. Never import this into a consumer. + +Destination writes precede the single source commit. The host storage adapter +must compare the source revision and atomically publish the reference document; +failure leaves the old source intact and retries reuse the same destination IDs. +No plaintext rollback file or raw-secret-returning resolver is produced here. +""" + +from __future__ import annotations + +import copy +from dataclasses import dataclass +from uuid import uuid5 + +from .catalog import model_helpers +from .contracts import CredentialKind +from .errors import VaultError, VaultErrorCode as Code, sanitized_errors +from .identity import _label +from .references import SourceReference, is_reference +from .secrets import Secret +from .records import origins as validate_origins, validate_fields +from .vault import SourceImport + + +@dataclass(frozen=True) +class MigrationResult: + imported: int + retained_references: int + review_required: int = 0 + retained_derived: int = 0 + + +def classify_integration(provider: str, credential: dict) -> str: + """Provider/provenance classification; never guess from a 'token' key.""" + if "source_reference" in credential: + return "reference" + if provider == "figma": + return {"pat": "source", "oauth": "derived"}.get( + credential.get("auth_kind"), "review_required" + ) + if provider == "github": + return "source" # This repository's GitHub provider is PAT-only. + if provider in {"stripe", "posthog", "telegram_bot", "line"}: + return "source" + if provider == "discord": + return "source" if credential.get("bot_token") else "derived" + if provider in {"supabase", "hubspot"}: + return {"pat": "source", "private_app": "source", "oauth": "derived"}.get( + credential.get("auth_kind"), "review_required" + ) + if provider in { + "gmail", + "google_calendar", + "google_docs", + "google_drive", + "google_youtube", + "outlook", + "whatsapp_web", + "telegram_user", + }: + return "derived" + # OAuth-generated Slack bot credentials and manually entered Slack bot + # credentials can share the same fields. Missing provenance requires review. + return "review_required" + + +@sanitized_errors +def migrate_model_document( + vault, source_id: str, document: Secret, commit, *, helper_catalog=None +): + """Trusted compare-and-swap adapter receives the new Secret document only. + + This callable is host-internal, never a registered helper or a consumer + callback. Unknown providers, malformed pools and static AWS credentials + abort before any source retirement; token/session sections are untouched. + """ + _label(source_id) + if not isinstance(document, Secret) or type(document.reveal()) is not dict: + raise VaultError(Code.INVALID) + candidate = Secret(copy.deepcopy(document.reveal())) + aws = candidate.reveal().get("aws_credentials", {}) + if type(aws) is not dict: + raise VaultError(Code.INVALID) + origins = {} + aws_origins = set() + from urllib.parse import urlsplit + + for helper in model_helpers() if helper_catalog is None else helper_catalog: + if not helper["helper"].startswith("model_"): + continue + provider = helper["helper"][len("model_") :] + endpoint = urlsplit(helper["endpoint"]) + if helper.get("protocol") == "aws_bedrock": + aws_origins.add(f"https://{endpoint.netloc}") + origins.setdefault("google" if provider == "gemini" else provider, set()).add( + f"https://{endpoint.netloc}" + ) + jobs, retained = [], 0 + for block, pool in (("api_keys", False), ("extra_api_keys", True)): + entries = candidate.reveal().get(block, {}) + if type(entries) is not dict: + raise VaultError(Code.INVALID) + for provider, values in entries.items(): + if not isinstance(provider, str): + raise VaultError(Code.INVALID) + if pool and type(values) is not list: + raise VaultError(Code.INVALID) + for index, value in enumerate(values if pool else [values]): + if not isinstance(value, str): + raise VaultError(Code.INVALID) + if not value: + continue + if is_reference(value): + retained += 1 + continue + if provider not in origins: + raise VaultError(Code.INVALID) + locator = f"{block}/{provider}/{index}" + jobs.append( + ( + block, + provider, + index, + Secret(value), + locator, + tuple(sorted(origins[provider])), + ) + ) + imports = list( + SourceImport( + uuid5(vault.profile.scope.profile_id, source_id + "/" + locator), + CredentialKind.API_KEY, + provider, + "Primary" if block == "api_keys" else f"Pool {index + 1}", + Secret({"api_key": value.reveal()}), + allowed, + ) + for block, provider, index, value, locator, allowed in jobs + ) + aws_import = False + if aws.get("source_reference"): + if not is_reference(aws["source_reference"]): + raise VaultError(Code.INVALID) + retained += 1 + if (aws.get("access_key_id") or aws.get("secret_access_key")) and not aws.get( + "session_token" + ): + if aws.get("source_reference"): + raise VaultError(Code.CONFLICT) + region = ( + candidate.reveal().get("endpoints", {}).get("aws_region") or "us-east-1" + ) + origin = f"https://bedrock-runtime.{region}.amazonaws.com" + if origin not in aws_origins: + raise VaultError(Code.INVALID) + fields = Secret( + {key: aws.get(key) for key in ("access_key_id", "secret_access_key")} + ) + validate_fields(CredentialKind.SERVICE_SECRET, fields) + imports.append( + SourceImport( + uuid5(vault.profile.scope.profile_id, source_id + "/aws/static"), + CredentialKind.SERVICE_SECRET, + "AWS Bedrock", + "Static AWS account", + fields, + (origin,), + ) + ) + aws_import = True + imported = vault.import_sources(tuple(imports)) if imports else () + for (block, provider, index, value, locator, allowed), item in zip( + jobs, imported[: len(jobs)] + ): + ref = item.summary.reference + encoded = SourceReference( + ref.scope.profile_id, ref.credential_id, ref.version + ).encode() + if block == "api_keys": + candidate.reveal()[block][provider] = encoded + else: + candidate.reveal()[block][provider][index] = encoded + if aws_import: + ref = imported[-1].summary.reference + aws["source_reference"] = SourceReference( + ref.scope.profile_id, ref.credential_id, ref.version + ).encode() + aws["auth_mode"] = "vault" + del aws["access_key_id"], aws["secret_access_key"] + # One commit, after every destination has been read back successfully. + # Existing generated-token/session values remain exactly equal by deep copy. + commit(candidate) + return MigrationResult(len(imports), retained) + + +@sanitized_errors +def migrate_browser_document(vault, source_id: str, document: Secret): + """Strict legacy login conversion. Returns an ID map, never login fields.""" + _label(source_id) + value = document.reveal() + if ( + type(value) is not dict + or type(value.get("version")) is not int + or value["version"] != 1 + or set(value) != {"version", "entries"} + or type(value["entries"]) is not list + or len(value["entries"]) > 5000 + ): + raise VaultError(Code.INVALID) + jobs, seen = [], set() + allowed = { + "id", + "site", + "username", + "password", + "label", + "createdAt", + "updatedAt", + "lastUsedAt", + } + for entry in map(Secret, value["entries"]): + item = entry.reveal() + if ( + type(item) is not dict + or set(item) - allowed + or not {"id", "site", "username", "password"} <= set(item) + ): + raise VaultError(Code.INVALID) + old_id, site = item["id"], item["site"] + if ( + not isinstance(old_id, str) + or not 1 <= len(old_id) <= 64 + or old_id in seen + or not isinstance(site, str) + ): + raise VaultError(Code.INVALID) + seen.add(old_id) + origin = validate_origins( + (site if site.startswith("https://") else "https://" + site,) + ) + fields = Secret({"username": item["username"], "password": item["password"]}) + validate_fields(CredentialKind.LOGIN, fields) + label = item.get("label") or "Imported login " + old_id[:8] + _label(label) + jobs.append((old_id, origin, fields, label)) + mapping = {} + imported = ( + vault.import_sources( + tuple( + SourceImport( + uuid5( + vault.profile.scope.profile_id, source_id + "/browser/" + old_id + ), + CredentialKind.LOGIN, + "Browser login", + label, + fields, + origin, + ) + for old_id, origin, fields, label in jobs + ) + ) + if jobs + else () + ) + for (old_id, origin, fields, label), details in zip(jobs, imported): + ref = details.summary.reference + mapping[old_id] = SourceReference( + ref.scope.profile_id, ref.credential_id, ref.version + ).encode() + return mapping + + +SOURCE_FIELDS = { + "twitter": ("api_secret", ()), + **{p: (None, ()) for p in ("lark", "lark_drive", "lark_calendar", "telegram_user")}, + "github": ("access_token", ()), + "figma": ("access_token", ()), + "slack": ("bot_token", ()), + "discord": ("bot_token", ()), + "stripe": ("api_key", ()), + "notion": ("token", ()), + "hubspot": ("access_token", ()), + "posthog": ("api_key", ()), + "supabase": ("access_token", ()), + "line": ("channel_access_token", ("channel_secret",)), + "telegram_bot": ("bot_token", ()), + "whatsapp_business": ("access_token", ("app_secret", "verify_token")), + "jira": ("api_token", ()), + **{ + p: (None, ()) + for p in ( + "gmail", + "google_drive", + "google_calendar", + "google_docs", + "google_youtube", + "linkedin", + "outlook", + ) + }, +} + +OAUTH_PROVIDERS = { + **{ + p: "google" + for p in ( + "gmail", + "google_drive", + "google_calendar", + "google_docs", + "google_youtube", + ) + }, + **{p: p for p in ("slack", "linkedin", "hubspot", "figma", "notion", "supabase")}, +} + + +@sanitized_errors +def migrate_integration_document( + vault, + source_id, + provider, + document, + commit, + *, + source_accounts=(), + helper_catalog=None, +): + """Retire only classified manual sources; preserve generated records verbatim. + + Ambiguous legacy modes require explicit account IDs at the native owner + action. An OAuth mode or refresh token can never be overridden by that list. + Account identities, primary, aliases, timestamps and session data are copied. + """ + from .catalog import integration_helpers + from urllib.parse import urlsplit + + _label(source_id) + if provider not in SOURCE_FIELDS or not isinstance(document, Secret): + raise VaultError(Code.INVALID) + candidate = Secret(copy.deepcopy(document.reveal())) + value = candidate.reveal() + if ( + type(value) is not dict + or type(value.get("version")) is not int + or value["version"] != 2 + or type(value.get("accounts")) is not dict + or len(value["accounts"]) > 5000 + ): + raise VaultError(Code.INVALID) + selected = set(source_accounts) + if selected - set(value["accounts"]): + raise VaultError(Code.INVALID) + catalog = integration_helpers() if helper_catalog is None else helper_catalog + allowed = tuple( + sorted( + { + f"https://{urlsplit(r['endpoint']).netloc}" + for r in (catalog) + if r["helper"] == "integration_" + provider + } + ) + ) + if not allowed and SOURCE_FIELDS[provider][0] is not None: + raise VaultError(Code.INVALID) + jobs, bindings, retained = [], [], 0 + review_required, retained_derived = 0, 0 + primary, auxiliary = SOURCE_FIELDS[provider] + for identity, record in value["accounts"].items(): + _label(identity) + if type(record) is not dict or type(record.get("credential")) is not dict: + raise VaultError(Code.INVALID) + credential = Secret(record["credential"]) + data = credential.reveal() + if provider in ( + "lark", + "lark_drive", + "lark_calendar", + "telegram_user", + "twitter", + ): + source_name = ( + "api_hash" + if provider == "telegram_user" + else "api_secret" + if provider == "twitter" + else "app_secret" + ) + source = data.get(source_name) + if not source: + if provider == "twitter" and is_reference(data.get("source_reference")): + retained += 1 + continue + retained_derived += 1 + continue + if is_reference(source): + retained += 1 + continue + helper = ( + "auth_telegram_user" + if provider == "telegram_user" + else "integration_twitter" + if provider == "twitter" + else "auth_lark" + ) + special_origins = tuple( + sorted( + { + f"https://{urlsplit(r['endpoint']).netloc}" + for r in catalog + if r["helper"] == helper + } + ) + ) + if not special_origins: + raise VaultError(Code.INVALID) + kind = ( + CredentialKind.SERVICE_SECRET + if provider in ("telegram_user", "twitter") + else CredentialKind.API_KEY + ) + secret = Secret( + {"api_key": data.get("api_key"), "api_secret": source} + if provider == "twitter" + else {"api_hash": source} + if provider == "telegram_user" + else {"api_key": source} + ) + validate_fields(kind, secret) + jobs.append( + SourceImport( + uuid5( + vault.profile.scope.profile_id, + source_id + "/" + provider + "/" + identity + "/" + source_name, + ), + kind, + provider, + identity + " app", + secret, + special_origins, + ) + ) + bindings.append((data, source_name, provider == "twitter")) + continue # Generated tokens, expiry and sessions remain byte-for-byte values. + oauth_secret = data.get("client_secret") + if oauth_secret and provider in OAUTH_PROVIDERS: + if is_reference(oauth_secret): + retained += 1 + else: + oauth_allowed = tuple( + sorted( + { + f"https://{urlsplit(r['endpoint']).netloc}" + for r in catalog + if r["helper"] == "oauth_" + OAUTH_PROVIDERS[provider] + } + ) + ) + if not oauth_allowed: + raise VaultError(Code.INVALID) + jobs.append( + SourceImport( + uuid5( + vault.profile.scope.profile_id, + source_id + + "/" + + provider + + "/" + + identity + + "/client_secret", + ), + CredentialKind.API_KEY, + provider, + identity + " OAuth client", + Secret({"api_key": oauth_secret}), + oauth_allowed, + ) + ) + bindings.append((data, "client_secret", False)) + if primary is None: + retained_derived += 1 + continue + if data.get("source_reference"): + if not is_reference(data["source_reference"]): + raise VaultError(Code.INVALID) + retained += 1 + continue + mode = classify_integration(provider, data) + if data.get("auth_kind") == "oauth" or data.get("refresh_token"): + if identity in selected: + raise VaultError(Code.INVALID) + retained_derived += 1 + continue + if mode == "review_required" and identity not in selected: + # Retain unknown provenance; report it without echoing values. + review_required += 1 + continue + if mode == "derived": + retained_derived += 1 + continue + if not data.get(primary): + raise VaultError(Code.INVALID) + if provider in ("jira", "posthog"): + host = ( + data.get("domain") + if provider == "jira" + else data.get("host", "https://us.posthog.com") + ) + if not isinstance(host, str) or not host: + raise VaultError(Code.INVALID) + origin = host if host.startswith("https://") else "https://" + host + origin = origin.rstrip("/") + if ( + validate_origins((origin,)) != (origin,) + or origin not in allowed + or origin.endswith(".invalid") + ): + raise VaultError(Code.INVALID) + fields = [(primary, CredentialKind.API_KEY, Secret({"api_key": data[primary]}))] + if provider == "jira": + fields = [ + ( + primary, + CredentialKind.SERVICE_SECRET, + Secret({"api_token": data[primary], "username": data.get("email")}), + ) + ] + fields.extend( + (name, CredentialKind.API_KEY, Secret({"api_key": data[name]})) + for name in auxiliary + if data.get(name) + ) + for name, kind, secret in fields: + validate_fields(kind, secret) + jobs.append( + SourceImport( + uuid5( + vault.profile.scope.profile_id, + source_id + "/" + provider + "/" + identity + "/" + name, + ), + kind, + provider, + identity if name == primary else identity + " " + name, + secret, + allowed, + ) + ) + bindings.append((data, name, name == primary)) + imported = vault.import_sources(tuple(jobs)) if jobs else () + for (data, name, primary_field), item in zip(bindings, imported): + ref = item.summary.reference + encoded = SourceReference( + ref.scope.profile_id, ref.credential_id, ref.version + ).encode() + if name in ("client_secret", "app_secret", "api_hash"): + data[name] = encoded + continue + if primary_field: + data["source_reference"], data["auth_kind"] = encoded, "vault" + if provider == "jira": + data.pop("email", None) + if provider == "twitter": + data.pop("api_key", None) + else: + data.setdefault("source_auxiliary", {})[name] = encoded + del data[name] + commit(candidate) + return MigrationResult(len(imported), retained, review_required, retained_derived) + + +ENV_MODEL_KEYS = { + "OPENAI_API_KEY": "openai", + "ANTHROPIC_API_KEY": "anthropic", + "GOOGLE_API_KEY": "google", + "GEMINI_API_KEY": "google", + "BYTEPLUS_API_KEY": "byteplus", + "OPENROUTER_API_KEY": "openrouter", + "GROK_API_KEY": "grok", + "XAI_API_KEY": "grok", + "DEEPSEEK_API_KEY": "deepseek", + "MINIMAX_API_KEY": "minimax", + "MOONSHOT_API_KEY": "moonshot", + "GLM_API_KEY": "glm", + "ZAI_API_KEY": "glm", + "FUGU_API_KEY": "fugu", + "SAKANA_API_KEY": "fugu", + "GROQ_API_KEY": "groq", + "MISTRAL_API_KEY": "mistral", + "TOGETHER_API_KEY": "together", + "FIREWORKS_API_KEY": "fireworks", + "CEREBRAS_API_KEY": "cerebras", + "QWEN_API_KEY": "qwen", + "DASHSCOPE_API_KEY": "qwen", + "NVIDIA_API_KEY": "nvidia", + "PERPLEXITY_API_KEY": "perplexity", + "HF_TOKEN": "huggingface", +} + +ENV_OAUTH_KEYS = { + "TELEGRAM_API_HASH": "telegram_user", + "GOOGLE_CLIENT_SECRET": "google", + "SLACK_SHARED_CLIENT_SECRET": "slack", + "LINKEDIN_CLIENT_SECRET": "linkedin", + "HUBSPOT_SHARED_CLIENT_SECRET": "hubspot", + "FIGMA_CLIENT_SECRET": "figma", + "NOTION_SHARED_CLIENT_SECRET": "notion", + "SUPABASE_SHARED_CLIENT_SECRET": "supabase", +} + + +@sanitized_errors +def migrate_environment_document( + vault, source_id, document, commit, *, helper_catalog=None +): + """Explicit CraftBot .env conversion; never read/mutate process/OS environments. + + Only known model key assignments are eligible. Other settings, generated + tokens, sessions, comments and unsupported/dynamic assignments stay exact. + Duplicate eligible variables abort, avoiding hidden active source copies. + """ + import json + import re + + _label(source_id) + raw = document.reveal() + if not isinstance(raw, str): + raise VaultError(Code.INVALID) + lines, values, locations = raw.splitlines(keepends=True), {}, {} + review_required = 0 + for index, line in enumerate(lines): + match = re.fullmatch( + r"(\ufeff?[ \t]*(?:export[ \t]+)?)([A-Z][A-Z0-9_]*)([ \t]*=[ \t]*)(.*?)(\r?\n)?", + line, + ) + if not match: + continue + if match[2] not in (ENV_MODEL_KEYS.keys() | ENV_OAUTH_KEYS.keys()): + if match[4].strip() and re.search( + r"(?:_API_KEY|_CLIENT_SECRET|_APP_SECRET|_API_HASH|_BOT_TOKEN|_SECRET_ACCESS_KEY)$", + match[2], + ): + review_required += 1 + continue + key, text = match[2], match[4].strip() + if key in locations: + raise VaultError(Code.CONFLICT) + tail = "" + locations[key] = (index, match, tail) + if not text: + continue + if text.startswith('"'): + value, end = json.JSONDecoder().raw_decode(text) + tail = text[end:] + elif text.startswith("'"): + end = text.find("'", 1) + if end < 0: + raise VaultError(Code.INVALID) + value, tail = text[1:end], text[end + 1 :] + else: + comment = re.search(r"[ \t]+#", text) + value, tail = ( + (text[: comment.start()].rstrip(), text[comment.start() :]) + if comment + else (text, "") + ) + if tail.strip() and not tail.lstrip().startswith("#"): + raise VaultError(Code.INVALID) + locations[key] = (index, match, tail) + if "${" in value or "`" in value or "$(" in value: + raise VaultError(Code.INVALID) + values[key] = value + # Each variable is kept distinct even where two names map to one provider. + from .catalog import oauth_helpers, authentication_helpers + + allowed_catalog = ( + model_helpers() + oauth_helpers() + authentication_helpers() + if helper_catalog is None + else helper_catalog + ) + imports = [] + retained = 0 + from urllib.parse import urlsplit + + for key, value in values.items(): + if not value: + continue + if is_reference(value): + retained += 1 + continue + provider = ENV_OAUTH_KEYS[key] if key in ENV_OAUTH_KEYS else ENV_MODEL_KEYS[key] + allowed = tuple( + sorted( + { + f"https://{urlsplit(r['endpoint']).netloc}" + for r in allowed_catalog + if r["helper"] + == ("oauth_" if key in ENV_OAUTH_KEYS else "model_") + provider + or key == "TELEGRAM_API_HASH" + and r["helper"] == "auth_telegram_user" + or key not in ENV_OAUTH_KEYS + and provider == "google" + and r["helper"] == "model_gemini" + } + ) + ) + if not allowed: + raise VaultError(Code.INVALID) + imports.append( + ( + key, + SourceImport( + uuid5(vault.profile.scope.profile_id, source_id + "/env/" + key), + CredentialKind.SERVICE_SECRET + if key == "TELEGRAM_API_HASH" + else CredentialKind.API_KEY, + provider, + "Environment " + key, + Secret( + {"api_hash" if key == "TELEGRAM_API_HASH" else "api_key": value} + ), + allowed, + ), + ) + ) + imported = ( + vault.import_sources(tuple(job for key, job in imports)) if imports else () + ) + for (key, job), item in zip(imports, imported): + ref = item.summary.reference + encoded = SourceReference( + ref.scope.profile_id, ref.credential_id, ref.version + ).encode() + index, match, tail = locations[key] + lines[index] = ( + match[1] + key + match[3] + json.dumps(encoded) + tail + (match[5] or "") + ) + commit(Secret("".join(lines))) + return MigrationResult(len(imported), retained, review_required=review_required) diff --git a/app/credentials/migration_source.py b/app/credentials/migration_source.py new file mode 100644 index 000000000..bfa9e7c9a --- /dev/null +++ b/app/credentials/migration_source.py @@ -0,0 +1,140 @@ +"""Offline owner-file adapter for model Settings migration on Windows. + +Reads/writes impersonate the unelevated enrolled owner. Vault/key operations run +as SYSTEM after reverting. Never use a SYSTEM file read on a caller-selected +path. Normal application writers must be stopped before this owner action. +""" + +from contextlib import contextmanager, closing +import hashlib +import json +import os +from pathlib import Path +import stat +import re +from uuid import uuid4 + +from .errors import VaultError, VaultErrorCode as Code +from .secrets import Secret +from .windows_host import api + + +class OwnerSettingsSource: + def __init__(self, path, owner_sid, session, *, filename="settings.json"): + self.path = Path(path) + if ( + not self.path.is_absolute() + or not ( + filename + in ( + "settings.json", + "mini_browser_vault.enc", + "mini_browser_vault.enc.bak", + "mini_browser_vault.key", + ".env", + ".env.local", + ) + or re.fullmatch( + r"(?:github|figma|slack|discord|stripe|notion|hubspot|posthog|supabase|line|telegram_bot|telegram_user|twitter|lark|lark_drive|lark_calendar|whatsapp_business|jira|gmail|google_drive|google_calendar|google_docs|google_youtube|linkedin|outlook)\.accounts\.json", + filename, + ) + ) + or self.path.name != filename + or len(self.path.drive) != 2 + or self.path.drive[1] != ":" + or ".." in self.path.parts + or any(ord(c) < 32 for c in str(path)) + ): + raise VaultError(Code.INVALID) + self.owner_sid, self.session = owner_sid, session + self.handle = None + self.digest = None + + @contextmanager + def _owner(self): + _, _, _, _, security, ts, _ = api() + with closing(ts.WTSQueryUserToken(self.session)) as token: + from .windows_host import token_value + + if security.ConvertSidToStringSid( + security.GetTokenInformation(token, 1)[0] + ) != self.owner_sid or token_value(token, 20): + raise VaultError(Code.VERIFICATION_REQUIRED) + security.ImpersonateLoggedOnUser(token) + try: + yield + finally: + try: + if security.RevertToSelf() is False: + os._exit(1) + except BaseException: + os._exit(1) # Never continue in an unknown security context. + + def read(self): + return Secret(json.loads(self.read_bytes().reveal())) + + def read_bytes(self, limit=4_000_000): + import win32file + + with self._owner(): + for path in (self.path, *self.path.parents): + attributes = path.stat(follow_symlinks=False) + if ( + stat.S_ISLNK(attributes.st_mode) + or attributes.st_file_attributes & 0x400 + ): + raise VaultError(Code.INVALID) + self.handle = win32file.CreateFile( + str(self.path), 0x80000000, 1 | 4, None, 3, 0x00200000, None + ) + info = win32file.GetFileInformationByHandle(self.handle) + if info[0] & 0x400: + raise VaultError(Code.INVALID) + raw = Secret(win32file.ReadFile(self.handle, limit + 1)[1]) + if len(raw.reveal()) > limit: + raise VaultError(Code.INVALID) + self.digest = Secret(hashlib.sha256(raw.reveal()).digest()) + return raw + + def commit(self, document: Secret): + encoded = Secret( + json.dumps( + document.reveal(), indent=2, ensure_ascii=False, allow_nan=False + ).encode("utf-8") + ) + self.commit_bytes(encoded) + + def commit_text(self, document: Secret): + self.commit_bytes(Secret(document.reveal().encode("utf-8"))) + + def commit_bytes(self, encoded: Secret): + if self.handle is None or self.digest is None: + raise VaultError(Code.INVALID) + import hmac + import win32file + + temporary = self.path.with_name(".credential-migration-" + uuid4().hex + ".tmp") + with self._owner(): + # The retained source handle denies in-place writers. Check a second + # pathname read too, so an atomic replacement by another owner writer + # is detected. This is an offline migration, not a live refresh hook. + current = Secret(self.path.read_bytes()) + if not hmac.compare_digest( + self.digest.reveal(), hashlib.sha256(current.reveal()).digest() + ): + raise VaultError(Code.CONFLICT) + try: + with temporary.open("xb") as stream: + stream.write(encoded.reveal()) + stream.flush() + os.fsync(stream.fileno()) + win32file.MoveFileEx(str(temporary), str(self.path), 1 | 8) + finally: + temporary.unlink(missing_ok=True) + self.handle.Close() + self.handle = None + + def close(self): + if self.handle is not None: + self.handle.Close() + self.handle = None diff --git a/app/credentials/model_transport.py b/app/credentials/model_transport.py new file mode 100644 index 000000000..e5a8af351 --- /dev/null +++ b/app/credentials/model_transport.py @@ -0,0 +1,249 @@ +"""SDK HTTP adapter containing only source references and safe provider data.""" + +import json + +import httpx + +from .client import CredentialHostUnavailable, request as host_request +from .invocation import invoke, open_response +from .references import parse + + +def installed_model_helpers(): + """Read sealed metadata; custom settings never install a destination policy.""" + helpers, offset = [], 0 + while offset <= 1024: + result = host_request("catalog", offset=offset) + helpers.extend(item for item in result["helpers"] if item["type"] == "json") + if not result["more"]: + return helpers + offset = result.get("next_offset", offset + 8) + raise CredentialHostUnavailable("credential_response_invalid") + + +class VaultModelTransport(httpx.BaseTransport): + def __init__(self, provider, reference, resolve_reference=None): + parse(reference) + self.provider, self.reference = provider, reference + self.resolve_reference = resolve_reference + self.routes = [ + item + for item in installed_model_helpers() + if item["helper"] == f"model_{provider}" + ] + if not self.routes: + raise CredentialHostUnavailable("credential_model_helper_unavailable") + + def handle_request(self, request): + # Exact full URL: query changes, custom endpoints and redirects require + # their own reviewed descriptor, never a client-selected destination. + from .route_policy import match_route + + matched = match_route(self.routes, request.method, str(request.url)) + if matched is None: + raise CredentialHostUnavailable("credential_destination_denied") + route, path, query = matched + reference = ( + self.resolve_reference() if self.resolve_reference else self.reference + ) + parse(reference) + try: + raw = request.read() + body = ( + multipart_data(raw, request.headers.get("Content-Type", "")) + if route.get("request_format") == "multipart" + else json.loads(raw) + if raw + else None + ) + except Exception: + raise CredentialHostUnavailable("credential_request_invalid") from None + if route.get("envelope"): + body = {"path": path, "query": query, "data": body} + response = open_response( + reference, + route["helper"], + route["operation"], + body, + subject=f"{self.provider} model: {route['operation']}", + ) + if route.get("response_format") == "binary" and 200 <= response.status < 300: + import base64 + + try: + media = json.loads("".join(response)) + return httpx.Response( + response.status, + content=base64.b64decode(media["data"], validate=True), + headers={"Content-Type": media["media_type"]}, + request=request, + ) + finally: + response.close() + return httpx.Response( + response.status, + headers={"Content-Type": response.content_type}, + stream=CredentialByteStream(response), + request=request, + ) + + +class CredentialByteStream(httpx.SyncByteStream): + def __init__(self, response): + self.response = response + + def __iter__(self): + for part in self.response: + yield part.encode("utf-8") + + def close(self): + self.response.close() + + +def multipart_data(raw, content_type): + from email.parser import BytesParser + from email.policy import default + import base64 + + if len(raw) > 32_000_000: + raise ValueError("credential_use_limit") + message = BytesParser(policy=default).parsebytes( + ("Content-Type: " + content_type + "\r\nMIME-Version: 1.0\r\n\r\n").encode() + + raw + ) + if not message.is_multipart(): + raise ValueError("credential_request_invalid") + fields, files = {}, [] + for part in message.iter_parts(): + field = part.get_param("name", header="content-disposition") + data = part.get_payload(decode=True) + if part.get_filename() is not None: + files.append( + { + "field": field, + "content_type": part.get_content_type(), + "data": base64.b64encode(data).decode("ascii"), + "filename": part.get_filename(), + } + ) + else: + if field in fields: + raise ValueError("credential_request_invalid") + fields[field] = data.decode("utf-8") + return {"fields": fields, "files": files} + + +def http_client(provider, reference, resolve_reference=None): + return httpx.Client( + transport=VaultModelTransport(provider, reference, resolve_reference), + follow_redirects=False, + trust_env=False, + timeout=240, + ) + + +def openai_client(reference): + """Host injection for an SDK client containing only a saved reference.""" + parse(reference) + from openai import OpenAI + + return OpenAI(api_key="vault-managed", http_client=http_client("openai", reference)) + + +def requests_response(provider, reference, method, url, body=None): + """Adapt the existing REST consumers without handing them a source key. + + None means an unchanged legacy/generated-token path. Host errors propagate; + a reference must never fall back to the ordinary network transport. + """ + from .references import is_reference + + if not is_reference(reference): + return None + from .route_policy import match_route + + matched = match_route( + [r for r in installed_model_helpers() if r["helper"] == f"model_{provider}"], + method, + url, + ) + if matched is None: + raise CredentialHostUnavailable("credential_destination_denied") + route, path, query = matched + if route.get("envelope"): + body = {"path": path, "query": query, "data": body} + result = invoke( + reference, + route["helper"], + route["operation"], + body, + subject=f"{provider}: {route['operation']}", + ) + import requests + + response = requests.Response() + response.status_code, response.url = result["status"], url + response.headers["Content-Type"] = result["content_type"] + if route.get("response_format") == "binary" and 200 <= result["status"] < 300: + import base64 + + media = json.loads(result["body"]) + response._content = base64.b64decode(media["data"], validate=True) + response.headers["Content-Type"] = media["media_type"] + else: + response._content = result["body"].encode("utf-8") + response._content_consumed = True + response.encoding = "utf-8" + return response + + +class VaultBedrockClient: + """Converse facade: only a reference and model/region data, no boto3 keys.""" + + def __init__(self, reference, region): + parse(reference) + self.reference, self.region = reference, region + + def converse(self, **parameters): + import base64 + import re + + model = parameters.get("modelId") + if not isinstance(model, str) or not re.fullmatch(r"[A-Za-z0-9_.:-]+", model): + raise CredentialHostUnavailable("credential_destination_denied") + endpoint = f"https://bedrock-runtime.{self.region}.amazonaws.com/model/{model}/converse" + route = next( + ( + r + for r in installed_model_helpers() + if r["helper"] == "model_bedrock" + and r["method"] == "POST" + and r["endpoint"] == endpoint + ), + None, + ) + if route is None: + raise CredentialHostUnavailable("credential_destination_denied") + + def wire(value): + if isinstance(value, bytes): + return base64.b64encode(value).decode("ascii") + if type(value) is dict: + return {key: wire(child) for key, child in value.items()} + if type(value) is list: + return [wire(child) for child in value] + return value + + response = invoke( + self.reference, + route["helper"], + route["operation"], + wire({key: value for key, value in parameters.items() if key != "modelId"}), + subject="Bedrock Converse", + ) + if not 200 <= response["status"] < 300: + raise CredentialHostUnavailable("credential_operation_failed") + result = json.loads(response["body"]) + if type(result) is not dict: + raise CredentialHostUnavailable("credential_response_invalid") + return result diff --git a/app/credentials/mtproto_helper.py b/app/credentials/mtproto_helper.py new file mode 100644 index 000000000..1f0ebdfb8 --- /dev/null +++ b/app/credentials/mtproto_helper.py @@ -0,0 +1,239 @@ +"""Fixed Telegram login operations inside the protected host. + +No TL method, proxy, destination, file, code or SDK object is caller-selected. +Generated StringSessions return to their original owner; app hashes and saved +2FA passwords never leave this module. Only in-memory Telethon sessions exist. +""" + +import asyncio +import logging +import re + +from .helpers import JsonResult, _sanitize_json_payloads +from .use_errors import CredentialUseError, UseCode + +# Production DCs only. New DC addresses require a reviewed package update. +DC_ADDRESSES = { + 1: "149.154.175.50", + 2: "149.154.167.51", + 3: "149.154.175.100", + 4: "149.154.167.91", + 5: "149.154.171.5", +} + + +def validate(operation, body): + required = { + "send_code": {"api_id", "phone_number"}, + "complete": { + "api_id", + "phone_number", + "code", + "phone_code_hash", + "session_string", + }, + "complete_password": {"api_id", "session_string"}, + "qr_login": {"api_id"}, + }[operation] + if ( + type(body) is not dict + or set(body) != required + or any(not isinstance(v, str) or len(v) > 4096 for v in body.values()) + ): + raise CredentialUseError(UseCode.DENIED) + if ( + not re.fullmatch(r"[1-9][0-9]{0,9}", body["api_id"]) + or int(body["api_id"]) > 2147483647 + ): + raise CredentialUseError(UseCode.DENIED) + if "phone_number" in body and not re.fullmatch( + r"\+?[0-9]{5,20}", body["phone_number"] + ): + raise CredentialUseError(UseCode.DENIED) + if "code" in body and not re.fullmatch(r"[0-9]{3,10}", body["code"]): + raise CredentialUseError(UseCode.DENIED) + if "phone_code_hash" in body and not re.fullmatch( + r"[A-Za-z0-9_-]{1,256}", body["phone_code_hash"] + ): + raise CredentialUseError(UseCode.DENIED) + + +def execute(specification, fields, guard, payload, execution, after_send): + body, operation = payload["body"], specification.operation.operation + validate(operation, body) + if set(fields.reveal()) - {"api_hash", "password"} or not re.fullmatch( + r"[a-fA-F0-9]{32}", fields.reveal().get("api_hash", "") + ): + raise CredentialUseError(UseCode.DENIED) + if operation == "complete_password" and not fields.reveal().get("password"): + raise CredentialUseError(UseCode.DENIED) + return asyncio.run(_perform(operation, body, fields, guard, execution, after_send)) + + +async def _perform(operation, body, fields, guard, execution, after_send): + from telethon import TelegramClient, errors + from telethon.sessions import StringSession + from telethon.network.connection import ConnectionTcpFull + + class FixedConnection(ConnectionTcpFull): + async def _connect(self, timeout=None, ssl=None): + if ( + self._dc_id not in DC_ADDRESSES + or self._proxy + or self._local_addr + or ssl is not None + ): + raise CredentialUseError(UseCode.DENIED) + # Telethon also reads addresses from GetConfig during DC migration. + # Select the reviewed DC address again at every socket creation. + self._ip, self._port = DC_ADDRESSES[self._dc_id], 443 + return await super()._connect(timeout=min(timeout or 10, 10), ssl=None) + + class PrivateLogger(logging.Logger): + def getChild(self, suffix): + return self # Never create a child attached to the process root. + + logger = PrivateLogger("credential_mtproto_private") + logger.disabled = True + logger.addHandler(logging.NullHandler()) + logger.propagate = False + session = StringSession(body.get("session_string") or "") + dc = session.dc_id or 2 + if dc not in DC_ADDRESSES: + raise CredentialUseError(UseCode.DENIED) + # A staged StringSession can contain a caller-chosen address/port. Ignore + # both and retain only its DC and generated auth key. + session.set_dc(dc, DC_ADDRESSES[dc], 443) + client = TelegramClient( + session, + int(body["api_id"]), + fields.reveal()["api_hash"], + connection=FixedConnection, + proxy=None, + use_ipv6=False, + timeout=10, + request_retries=1, + connection_retries=1, + auto_reconnect=False, + flood_sleep_threshold=0, + receive_updates=operation == "qr_login", + base_logger=logger, + ) + + def checked(value): + return _sanitize_json_payloads([value], fields)[0] + + def emit(value): + text = checked(value) + if execution is not None: + execution.emit("data: " + text + "\n\n") + + async def authenticate(): + guard() + await client.connect() + guard() + # Commit this fixed dispatch; no further caller input is accepted. + if after_send is not None: + after_send() + try: + if operation == "send_code": + code = await client.send_code_request(body["phone_number"]) + return { + "ok": True, + "result": { + "phone_code_hash": code.phone_code_hash, + "phone_number": body["phone_number"], + "session_string": client.session.save(), + "status": "code_sent", + }, + } + if operation == "qr_login": + if execution is None: + raise CredentialUseError(UseCode.DENIED) + execution.start(200, "text/event-stream") + qr = await client.qr_login() + # The QR token is generated login state, not an app hash. + emit({"qr_url": qr.url}) + await qr.wait(timeout=120) + elif operation == "complete_password": + await client.sign_in(password=fields.reveal()["password"]) + else: + try: + await client.sign_in( + phone=body["phone_number"], + code=body["code"], + phone_code_hash=body["phone_code_hash"], + ) + except errors.SessionPasswordNeededError: + if not fields.reveal().get("password"): + raise + await client.sign_in(password=fields.reveal()["password"]) + me = await client.get_me() + return { + "ok": True, + "result": { + "session_string": client.session.save(), + "user_id": me.id, + "first_name": me.first_name or "", + "last_name": me.last_name or "", + "username": me.username or "", + "phone": me.phone or body.get("phone_number", ""), + "status": "authenticated", + }, + } + except errors.SessionPasswordNeededError: + return { + "error": "Two-factor authentication required", + "details": { + "status": "2fa_required", + "session_string": client.session.save(), + }, + } + except errors.PhoneCodeInvalidError: + return { + "error": "Invalid verification code", + "details": {"status": "invalid_code"}, + } + except errors.PhoneCodeExpiredError: + return { + "error": "Verification code expired", + "details": {"status": "code_expired"}, + } + except errors.PasswordHashInvalidError: + return { + "error": "Invalid 2FA password", + "details": {"status": "invalid_password"}, + } + except errors.FloodWaitError as error: + return { + "error": "Telegram rate limit", + "details": {"flood_wait_seconds": max(0, min(error.seconds, 86400))}, + } + + async def cancel_when_locked(task): + while not task.done(): + await asyncio.sleep(0.05) + if execution is not None and execution.cancelled.is_set(): + task.cancel() + return + + task = asyncio.create_task(authenticate()) + watcher = asyncio.create_task(cancel_when_locked(task)) + try: + result = await asyncio.wait_for(task, timeout=180) + if ( + operation == "qr_login" + and execution is not None + and execution.status is not None + ): + emit(result) + return JsonResult(200, "", "text/event-stream") + return JsonResult(200, checked(result), "application/json") + except asyncio.CancelledError: + raise CredentialUseError(UseCode.INTERRUPTED) from None + finally: + watcher.cancel() + try: + await asyncio.wait_for(client.disconnect(), 5) + except (Exception, asyncio.CancelledError): + pass diff --git a/app/credentials/native/CredentialHost.cs b/app/credentials/native/CredentialHost.cs new file mode 100644 index 000000000..89dcfa915 --- /dev/null +++ b/app/credentials/native/CredentialHost.cs @@ -0,0 +1,399 @@ +// Built with the Windows .NET Framework compiler. No third-party assemblies. +// The service never imports Python from a checkout or inherits its environment. +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Pipes; +using System.Runtime.InteropServices; +using System.Security.Principal; +using System.ServiceProcess; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Web.Script.Serialization; +using System.Windows.Forms; +using Microsoft.Win32.SafeHandles; + +class CredentialHost : ServiceBase { + const string Name = "CraftBotCredentialHost"; + readonly object gate = new object(); + Process worker; + SafeFileHandle workerJob; + SafeFileHandle listener; + volatile bool stopping; + static readonly JavaScriptSerializer Json = new JavaScriptSerializer { MaxJsonLength = 65536, RecursionLimit = 16 }; + // Trusted identity metadata and escaping expand an otherwise valid frame. + const int EnvelopeLimit = 400000; + static string Envelope(uint pid,long created,string sid,string app,object payload) { + var serializer=new JavaScriptSerializer { MaxJsonLength=EnvelopeLimit, RecursionLimit=20 }; + string encoded=serializer.Serialize(new { peer_pid=pid,peer_created=created.ToString(System.Globalization.CultureInfo.InvariantCulture),peer_sid=sid,peer_app_sid=app,payload=payload }); + var ascii=new StringBuilder(); + foreach(char ch in encoded) { if(ch>127) ascii.Append("\\u"+((int)ch).ToString("x4")); else ascii.Append(ch); } + if(ascii.Length>EnvelopeLimit) throw new IOException(); + return ascii.ToString(); + } + static string Root { get { return AppDomain.CurrentDomain.BaseDirectory; } } + + [DllImport("advapi32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + static extern bool ConvertStringSecurityDescriptorToSecurityDescriptor(string text, uint revision, out IntPtr descriptor, IntPtr size); + [DllImport("kernel32.dll", CharSet=CharSet.Unicode, SetLastError=true)] + static extern SafeFileHandle CreateNamedPipe(string name, uint access, uint mode, uint instances, uint output, uint input, uint timeout, ref SECURITY_ATTRIBUTES attributes); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool ConnectNamedPipe(SafeFileHandle pipe, IntPtr overlapped); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool DisconnectNamedPipe(SafeFileHandle pipe); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool CancelIoEx(SafeFileHandle pipe, IntPtr overlapped); + [DllImport("kernel32.dll")] static extern IntPtr CreateEvent(IntPtr attributes,bool manual,bool initial,string name); + [DllImport("kernel32.dll")] static extern uint WaitForSingleObject(IntPtr handle,uint milliseconds); + [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr handle); + [DllImport("kernel32.dll",SetLastError=true)] static extern IntPtr OpenProcess(uint access,bool inherit,uint pid); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool GetProcessTimes(IntPtr process,out long created,out long exited,out long system,out long user); + [DllImport("kernel32.dll",CharSet=CharSet.Unicode)] static extern SafeFileHandle CreateJobObject(IntPtr attributes,string name); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool SetInformationJobObject(SafeFileHandle job,int kind,ref JOB_LIMITS limits,int size); + [DllImport("kernel32.dll",SetLastError=true)] static extern bool AssignProcessToJobObject(SafeFileHandle job,IntPtr process); + [StructLayout(LayoutKind.Sequential)] struct BASIC_LIMITS { public long processTime,jobTime; public uint flags; public UIntPtr minimum,maximum; public uint active; public UIntPtr affinity; public uint priority,scheduling; } + [StructLayout(LayoutKind.Sequential)] struct IO_COUNTERS { public ulong a,b,c,d,e,f; } + [StructLayout(LayoutKind.Sequential)] struct JOB_LIMITS { public BASIC_LIMITS basic; public IO_COUNTERS io; public UIntPtr processMemory,jobMemory,peakProcess,peakJob; } + [DllImport("advapi32.dll",SetLastError=true)] static extern bool ImpersonateNamedPipeClient(SafeFileHandle pipe); + [DllImport("advapi32.dll")] static extern bool RevertToSelf(); + [DllImport("kernel32.dll")] static extern IntPtr GetCurrentThread(); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenThreadToken(IntPtr thread,uint rights,bool asSelf,out IntPtr token); + [DllImport("advapi32.dll",SetLastError=true)] static extern bool GetTokenInformation(IntPtr token,int kind,IntPtr data,uint size,out uint needed); + [DllImport("kernel32.dll", SetLastError=true)] static extern bool GetNamedPipeClientProcessId(SafeFileHandle pipe, out uint pid); + [DllImport("kernel32.dll")] static extern IntPtr LocalFree(IntPtr value); + [StructLayout(LayoutKind.Sequential)] struct SECURITY_ATTRIBUTES { public int size; public IntPtr descriptor; public int inherit; } + [StructLayout(LayoutKind.Sequential)] struct OVERLAPPED { public IntPtr a,b; public uint c,d; public IntPtr signal; } + + public CredentialHost() { ServiceName=Name; CanHandleSessionChangeEvent=true; CanHandlePowerEvent=true; } + protected override void OnStart(string[] args) { + if (WindowsIdentity.GetCurrent().User.Value != "S-1-5-18") throw new InvalidOperationException("protected_host_required"); + Task.Factory.StartNew(Serve, TaskCreationOptions.LongRunning); + } + protected override void OnStop() { stopping=true; try { if(listener!=null) { CancelIoEx(listener,IntPtr.Zero); listener.Dispose(); } } catch {} Reset(); } + protected override void OnSessionChange(SessionChangeDescription change) { + if(change.Reason==SessionChangeReason.SessionLock || change.Reason==SessionChangeReason.SessionLogoff) Reset(); + } + protected override bool OnPowerEvent(PowerBroadcastStatus power) { if(power==PowerBroadcastStatus.Suspend) Reset(); return true; } + void Reset() { + // Do not wait behind a hung resolver or owner prompt on lock/suspend. + Process current=Interlocked.Exchange(ref worker, null); + SafeFileHandle job=Interlocked.Exchange(ref workerJob,null); + if(job!=null) job.Dispose(); + if(current!=null) { try { current.Kill(); current.WaitForExit(5000); } catch {} current.Dispose(); } + } + Process Worker() { + if(worker!=null && !worker.HasExited) return worker; + Reset(); + var start=new ProcessStartInfo(Path.Combine(Root,"python","python.exe"), "-I -S \""+Path.Combine(Root,"bootstrap.py")+"\""); + start.UseShellExecute=false; start.CreateNoWindow=true; start.WorkingDirectory=Root; + start.RedirectStandardInput=true; start.RedirectStandardOutput=true; start.RedirectStandardError=true; + start.EnvironmentVariables.Clear(); + start.EnvironmentVariables["SYSTEMROOT"]=Environment.GetFolderPath(Environment.SpecialFolder.Windows); + start.EnvironmentVariables["PATH"]=Path.Combine(Root,"python"); + worker=Process.Start(start); + workerJob=CreateJobObject(IntPtr.Zero,null); + var limits=new JOB_LIMITS(); limits.basic.flags=0x2000|0x1000; + // The service owns this kill-on-close supervisor. Cross-session native + // dialogs/consumers break away and enter their own worker-owned jobs. + if(workerJob.IsInvalid || !SetInformationJobObject(workerJob,9,ref limits,Marshal.SizeOf(typeof(JOB_LIMITS))) || !AssignProcessToJobObject(workerJob,worker.Handle)) { Reset(); throw new IOException(); } + // Never log stderr, traceback locals, protocol data or child output. + worker.ErrorDataReceived += delegate {}; worker.BeginErrorReadLine(); + return worker; + } + static string[] PipeIdentity(SafeFileHandle pipe) { + if(!ImpersonateNamedPipeClient(pipe)) throw new IOException(); + IntPtr token=IntPtr.Zero,buffer=IntPtr.Zero; + try { + if(!OpenThreadToken(GetCurrentThread(),8,true,out token)) throw new IOException(); + string sid; using(var identity=new WindowsIdentity(token)) sid=identity.User.Value; + uint needed; + buffer=Marshal.AllocHGlobal(4); + if(!GetTokenInformation(token,29,buffer,4,out needed)) throw new IOException(); + bool container=Marshal.ReadInt32(buffer)!=0; + Marshal.FreeHGlobal(buffer); buffer=IntPtr.Zero; + string app=null; + if(container) { + GetTokenInformation(token,31,IntPtr.Zero,0,out needed); + if(needed<8||needed>4096) throw new IOException(); + buffer=Marshal.AllocHGlobal((int)needed); + if(!GetTokenInformation(token,31,buffer,needed,out needed)) throw new IOException(); + app=new SecurityIdentifier(Marshal.ReadIntPtr(buffer)).Value; + } + return new string[] {sid,app}; + } finally { + if(buffer!=IntPtr.Zero) Marshal.FreeHGlobal(buffer); + if(token!=IntPtr.Zero) CloseHandle(token); + if(!RevertToSelf()) Environment.Exit(1); + } + } + void Serve() { + while(!stopping) { + IntPtr sd=IntPtr.Zero; + IntPtr connected=IntPtr.Zero; + IntPtr pendingMemory=IntPtr.Zero; + bool connectionPending=false; + IntPtr peerProcess=IntPtr.Zero; + try { + // Exact data rights exclude FILE_CREATE_PIPE_INSTANCE. Low label + // allows AppContainer writes; kernel PID/job checks authorize use. + if(!ConvertStringSecurityDescriptorToSecurityDescriptor("O:SYD:P(A;;GA;;;SY)(A;;0x120003;;;AU)(A;;0x120003;;;AC)S:(ML;;NW;;;LW)",1,out sd,IntPtr.Zero)) throw new IOException(); + var sa=new SECURITY_ATTRIBUTES { size=Marshal.SizeOf(typeof(SECURITY_ATTRIBUTES)), descriptor=sd }; + if(listener==null || listener.IsClosed || listener.IsInvalid) listener=CreateNamedPipe(@"\\.\pipe\LOCAL\CraftBot.Credentials.v1", 3|0x80000|0x40000000, 8, 1,65536,65536,0,ref sa); + if(listener.IsInvalid) throw new IOException(); // no squatting fallback + connected=CreateEvent(IntPtr.Zero,true,false,null); + var pending=new OVERLAPPED { signal=connected }; + pendingMemory=Marshal.AllocHGlobal(Marshal.SizeOf(typeof(OVERLAPPED))); + Marshal.StructureToPtr(pending,pendingMemory,false); + if(!ConnectNamedPipe(listener,pendingMemory)) { + int error=Marshal.GetLastWin32Error(); + if(error!=535) { + if(error!=997) throw new IOException(); + connectionPending=true; + while(WaitForSingleObject(connected,1000)==258) { if(stopping) throw new IOException(); } + } + } + uint pid; + if(!GetNamedPipeClientProcessId(listener,out pid) || pid==0) throw new IOException(); + peerProcess=OpenProcess(0x101000,false,pid); + long created,exited,system,user; + if(peerProcess==IntPtr.Zero || !GetProcessTimes(peerProcess,out created,out exited,out system,out user) || WaitForSingleObject(peerProcess,0)!=258) throw new IOException(); + using(var stream=new FileStream(new SafeFileHandle(listener.DangerousGetHandle(),false),FileAccess.ReadWrite,4096,true)) { + var incoming=Task.Factory.StartNew(()=>ReadFrame(stream)); + if(!incoming.Wait(10000)) { CancelIoEx(listener,IntPtr.Zero); stream.Dispose(); continue; } + object payload=Json.DeserializeObject(incoming.Result); + if(WaitForSingleObject(peerProcess,0)!=258) throw new IOException(); + string[] identity=PipeIdentity(listener); + // Client-dependent validation must never reset an existing + // worker or start one. Only worker transport failures do so. + string envelope=Envelope(pid,created,identity[0],identity[1],payload); + string response; + lock(gate) { + try { + var current=Worker(); + current.StandardInput.WriteLine(envelope); current.StandardInput.Flush(); + var answer=Task.Factory.StartNew(()=>ReadLine(current.StandardOutput)); + // Unlock, owner authentication and private entry each + // have their own deadlines; permit their bounded sum. + if(!answer.Wait(300000)) { Reset(); throw new IOException(); } + response=answer.Result; + // Only a kernel-authenticated owner lock can request + // this supervisor action. Consumer results are nested + // and cannot forge the worker's reserved response. + var answered=Json.DeserializeObject(response) as Dictionary; + var requested=payload as Dictionary; + object requestedOp,ok,returned; + if(requested!=null && requested.TryGetValue("op",out requestedOp) && (requestedOp as string)=="lock" && answered!=null && answered.TryGetValue("ok",out ok) && ok is bool && (bool)ok && answered.TryGetValue("result",out returned)) { + var result=returned as Dictionary; object reset; + if(result!=null && result.Count==1 && result.TryGetValue("reset_host",out reset) && reset is bool && (bool)reset) { + Reset(); response="{\"ok\":true,\"result\":{\"state\":\"locked\",\"all_profiles\":true}}"; + } + } + } catch { Reset(); throw; } + } + WriteFrame(stream,response); + } + } catch {} // A disconnected/malformed client cannot reset other grants. + finally { + try { if(listener!=null && !listener.IsClosed && !listener.IsInvalid) { CancelIoEx(listener,IntPtr.Zero); DisconnectNamedPipe(listener); } } catch {} + // Keep overlapped storage alive until the kernel completes it. + if(!connectionPending || WaitForSingleObject(connected,5000)==0) { + if(pendingMemory!=IntPtr.Zero) Marshal.FreeHGlobal(pendingMemory); + if(connected!=IntPtr.Zero) CloseHandle(connected); + } + if(sd!=IntPtr.Zero) LocalFree(sd); + if(peerProcess!=IntPtr.Zero) CloseHandle(peerProcess); + } + } + } + static string ReadLine(StreamReader reader) { + var value=new StringBuilder(); int c; + while((c=reader.Read())!=-1 && c!='\n') { if(value.Length>=65536) throw new IOException(); value.Append((char)c); } + if(c==-1) throw new IOException(); return value.ToString(); + } + static byte[] Exact(Stream stream,int size) { var data=new byte[size]; for(int n=0;nmaximum) throw new IOException(); return Encoding.UTF8.GetString(Exact(stream,size)); } + static void WriteFrame(Stream stream,string message,int maximum=65536) { byte[] data=Encoding.UTF8.GetBytes(message); if(data.Length>maximum) throw new IOException(); byte[] size=BitConverter.GetBytes(data.Length); stream.Write(size,0,4); stream.Write(data,0,data.Length); stream.Flush(); } + + [StructLayout(LayoutKind.Sequential,CharSet=CharSet.Unicode)] struct CREDUI { public int size; public IntPtr parent; public string message; public string caption; public IntPtr banner; } + [DllImport("credui.dll",CharSet=CharSet.Unicode)] static extern uint CredUIPromptForWindowsCredentials(ref CREDUI info,uint error,ref uint package,IntPtr input,uint inputSize,out IntPtr output,out uint size,IntPtr save,uint flags); + [DllImport("credui.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern bool CredUnPackAuthenticationBuffer(uint flags,IntPtr auth,uint authSize,IntPtr user,ref uint userSize,IntPtr domain,ref uint domainSize,IntPtr password,ref uint passwordSize); + [DllImport("advapi32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern bool LogonUser(IntPtr user,IntPtr domain,IntPtr password,uint logon,uint provider,out SafeAccessTokenHandle token); + [DllImport("kernel32.dll",EntryPoint="RtlZeroMemory")] static extern void Zero(IntPtr buffer,UIntPtr size); + static bool Consent(string sid,string message) { + if(message.Length>1000 || WindowsIdentity.GetCurrent().User.Value!="S-1-5-18") return false; + IntPtr packed=IntPtr.Zero; uint length=0,package=0; + IntPtr user=Marshal.AllocHGlobal(2048),domain=Marshal.AllocHGlobal(2048),password=Marshal.AllocHGlobal(65536); + try { + var info=new CREDUI { size=Marshal.SizeOf(typeof(CREDUI)),message=message,caption="CraftBot credential authorization" }; + // Real secure desktop; no ordinary-web approval or generic prompt fallback. + if(CredUIPromptForWindowsCredentials(ref info,0,ref package,IntPtr.Zero,0,out packed,out length,IntPtr.Zero,0x1000)!=0) return false; + uint u=1024,d=1024,p=32768; + if(!CredUnPackAuthenticationBuffer(0,packed,length,user,ref u,domain,ref d,password,ref p)) return false; + SafeAccessTokenHandle token; + if(!LogonUser(user,domain,password,2,0,out token)) return false; + using(token) using(var identity=new WindowsIdentity(token.DangerousGetHandle())) return identity.User.Value==sid; + } finally { + Zero(user,(UIntPtr)2048); Zero(domain,(UIntPtr)2048); Zero(password,(UIntPtr)65536); + Marshal.FreeHGlobal(user); Marshal.FreeHGlobal(domain); Marshal.FreeHGlobal(password); + if(packed!=IntPtr.Zero) { Zero(packed,(UIntPtr)length); Marshal.FreeCoTaskMem(packed); } + } + } + static int OwnerDialog(string[] args) { + if(args.Length<3 || WindowsIdentity.GetCurrent().User.Value!="S-1-5-18") return 1; + string message=Encoding.UTF8.GetString(Convert.FromBase64String(args[2])); + if(!Consent(args[1],message)) return 1; + if(args[0]=="--consent") return 0; + if(args.Length==5 && (args[0]=="--view" || args[0]=="--review")) { + using(var pipe=new NamedPipeClientStream(".",args[3],PipeDirection.InOut)) { + pipe.Connect(5000); + var privateJson=new JavaScriptSerializer { MaxJsonLength=2100000,RecursionLimit=4 }; + var shown=privateJson.Deserialize>(ReadFrame(pipe,2100000)); + if(shown==null || shown.Count==0 || shown.Count>32) return 1; + try { return ProtectedEntry(args[4],shown,args[0]=="--review")==null?1:0; } + finally { shown.Clear(); } + } + } + if(args.Length!=5 || args[0]!="--entry") return 1; + Dictionary result=ProtectedEntry(args[4]); + if(result==null) return 1; + // This private SYSTEM-only pipe is created by the worker. The public + // agent transport never sees credential form values. + using(var pipe=new NamedPipeClientStream(".",args[3],PipeDirection.InOut)) { + var privateJson=new JavaScriptSerializer { MaxJsonLength=2100000,RecursionLimit=4 }; + pipe.Connect(5000); WriteFrame(pipe,privateJson.Serialize(result),2100000); + pipe.ReadByte(); + } + return 0; + } + [StructLayout(LayoutKind.Sequential)] struct SA { public int size; public IntPtr descriptor; public int inherit; } + [StructLayout(LayoutKind.Sequential,CharSet=CharSet.Unicode)] struct STARTUP { + public int size; public string reserved,desktop,title; public uint x,y,width,height,xchars,ychars,fill,flags; + public short show,reservedSize; public IntPtr reserved2,input,output,error; + } + [StructLayout(LayoutKind.Sequential)] struct PROCESS { public IntPtr process,thread; public uint pid,tid; } + [DllImport("user32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern IntPtr CreateDesktop(string name,IntPtr device,IntPtr mode,uint flags,uint access,ref SA attributes); + [DllImport("user32.dll",SetLastError=true)] static extern IntPtr OpenInputDesktop(uint flags,bool inherit,uint access); + [DllImport("user32.dll")] static extern bool SwitchDesktop(IntPtr desktop); + [DllImport("user32.dll")] static extern bool SetThreadDesktop(IntPtr desktop); + [DllImport("user32.dll")] static extern bool CloseDesktop(IntPtr desktop); + [DllImport("user32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern bool GetUserObjectInformation(IntPtr handle,int index,StringBuilder value,uint length,out uint needed); + [DllImport("kernel32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern bool CreateProcess(string app,StringBuilder command,IntPtr processAttributes,IntPtr threadAttributes,bool inherit,uint flags,IntPtr environment,string directory,ref STARTUP startup,out PROCESS result); + [DllImport("kernel32.dll",CharSet=CharSet.Unicode,EntryPoint="CreateEventW",SetLastError=true)] static extern IntPtr PrivateEvent(ref SA attributes,bool manual,bool initial,string name); + [DllImport("kernel32.dll",CharSet=CharSet.Unicode)] static extern IntPtr OpenEvent(uint rights,bool inherit,string name); + [DllImport("kernel32.dll")] static extern bool SetEvent(IntPtr handle); + [DllImport("kernel32.dll")] static extern bool TerminateProcess(IntPtr handle,uint code); + + static int EntryGuardian(string[] args) { + if(args.Length!=5 || WindowsIdentity.GetCurrent().User.Value!="S-1-5-18") return 1; + uint pid; long expected; + if(!uint.TryParse(args[1],out pid)||!long.TryParse(args[2],out expected)||!args[4].StartsWith("CraftBot.Entry.")||args[3]!="Local\\"+args[4]) return 1; + IntPtr parent=OpenProcess(0x100001|0x1000,false,pid), desktop=IntPtr.Zero,ready=IntPtr.Zero; + try { + long created,exited,kernel,user; + if(parent==IntPtr.Zero || !GetProcessTimes(parent,out created,out exited,out kernel,out user)||created!=expected) return 1; + desktop=OpenInputDesktop(0,false,0x100); + ready=OpenEvent(2,false,args[3]); + if(desktop==IntPtr.Zero||ready==IntPtr.Zero||!SetEvent(ready)) return 1; + uint outcome=WaitForSingleObject(parent,90000); + // Runs outside the UI job, with no source values or public RPC. + // The bounded guardian survives a killed UI/worker/service. + RestoreDesktop(desktop,args[4]); + if(outcome!=0) TerminateProcess(parent,1); + return 0; + } finally { + if(ready!=IntPtr.Zero) CloseHandle(ready); + if(desktop!=IntPtr.Zero) CloseDesktop(desktop); + if(parent!=IntPtr.Zero) CloseHandle(parent); + } + } + static void RestoreDesktop(IntPtr original,string privateName) { + IntPtr active=OpenInputDesktop(0,false,0x101); + try { + uint needed; var name=new StringBuilder(512); + if(active!=IntPtr.Zero && GetUserObjectInformation(active,2,name,1024,out needed) && name.ToString()==privateName) SwitchDesktop(original); + } finally { if(active!=IntPtr.Zero) CloseDesktop(active); } + } + static Dictionary ProtectedEntry(string fields,Dictionary shown=null,bool review=false) { + IntPtr descriptor=IntPtr.Zero,desktop=IntPtr.Zero,ready=IntPtr.Zero,original=IntPtr.Zero; + PROCESS guardian=new PROCESS(); + try { + if(!ConvertStringSecurityDescriptorToSecurityDescriptor("O:SYD:P(A;;GA;;;SY)",1,out descriptor,IntPtr.Zero)) throw new IOException(); + var attributes=new SA { size=Marshal.SizeOf(typeof(SA)),descriptor=descriptor }; + string name="CraftBot.Entry."+Guid.NewGuid().ToString("N"),eventName="Local\\"+name; + original=OpenInputDesktop(0,false,0x100); + desktop=CreateDesktop(name,IntPtr.Zero,IntPtr.Zero,0,0x1ff,ref attributes); + ready=PrivateEvent(ref attributes,true,false,eventName); + if(ready!=IntPtr.Zero && Marshal.GetLastWin32Error()==183) throw new IOException(); + if(original==IntPtr.Zero||desktop==IntPtr.Zero||ready==IntPtr.Zero) throw new IOException(); + using(var current=Process.GetCurrentProcess()) { + long created,exited,kernel,user; + if(!GetProcessTimes(current.Handle,out created,out exited,out kernel,out user)) throw new IOException(); + var startup=new STARTUP { size=Marshal.SizeOf(typeof(STARTUP)),desktop="winsta0\\default" }; + string exe=Path.Combine(Root,"CredentialHost.exe"); + if(!CreateProcess(exe,new StringBuilder("\""+exe+"\" --entry-guardian "+current.Id+" "+created+" "+eventName+" "+name),IntPtr.Zero,IntPtr.Zero,false,0x01000000|0x08000000,IntPtr.Zero,Root,ref startup,out guardian)) throw new IOException(); + } + if(WaitForSingleObject(ready,5000)!=0) throw new IOException(); + Dictionary result=null; + // New STA thread has no windows or hooks on the ordinary desktop. + var thread=new Thread(delegate() { + try { + if(!SetThreadDesktop(desktop)||!SwitchDesktop(desktop)) return; + result=EntryForm(fields,shown,review); + } catch { result=null; } + finally { RestoreDesktop(original,name); } + }); + thread.SetApartmentState(ApartmentState.STA); thread.Start(); + if(!thread.Join(85000)) { RestoreDesktop(original,name); Environment.Exit(1); } + return result; + } finally { + // The UI thread and bounded guardian restore only their own input + // desktop, never a later prompt or the OS lock screen. + if(original!=IntPtr.Zero) CloseDesktop(original); + if(desktop!=IntPtr.Zero) CloseDesktop(desktop); + if(ready!=IntPtr.Zero) CloseHandle(ready); + if(descriptor!=IntPtr.Zero) LocalFree(descriptor); + if(guardian.thread!=IntPtr.Zero) CloseHandle(guardian.thread); + if(guardian.process!=IntPtr.Zero) CloseHandle(guardian.process); + } + } + sealed class SourceTextBox : TextBox { + protected override void WndProc(ref Message message) { + if(message.Msg==0x300 || message.Msg==0x301) return; // No global clipboard copy/cut. + base.WndProc(ref message); + } + } + static Dictionary EntryForm(string fields,Dictionary shown=null,bool review=false) { + var result=new Dictionary(); + using(var form=new Form()) { + form.Text=review?"CraftBot — approve exact credential request":shown==null?"CraftBot — secure credential entry":"CraftBot — private credential view"; form.Width=review?760:480; form.Height=review?680:400; + var panel=new FlowLayoutPanel { Dock=DockStyle.Fill,FlowDirection=FlowDirection.TopDown,WrapContents=false,AutoScroll=true }; + panel.Controls.Add(new Label { Text=shown==null?"Values go only to the protected vault.":"Private owner view. Clipboard copy is disabled.",AutoSize=true }); + var inputs=new Dictionary(); + foreach(string field in fields.Split(',')) { + var box=new SourceTextBox { Width=review?690:420,UseSystemPasswordChar=!review,MaxLength=review?2100000:32768,ReadOnly=shown!=null }; + if(review) { box.Multiline=true; box.ScrollBars=ScrollBars.Both; box.WordWrap=false; box.Height=field=="Parameters"?240:60; } + if(shown!=null) { if(!shown.ContainsKey(field)) throw new IOException(); box.Text=shown[field]; } + inputs.Add(field,box); panel.Controls.Add(new Label { Text=field,AutoSize=true }); panel.Controls.Add(box); + var multiline=new CheckBox { Text=shown==null?"Multiple lines (visible on this protected desktop)":"Reveal on this protected desktop",AutoSize=true }; + multiline.CheckedChanged+=delegate { box.UseSystemPasswordChar=!multiline.Checked; box.Multiline=multiline.Checked; box.AcceptsReturn=multiline.Checked; box.Height=multiline.Checked?90:24; }; + if(shown==null) box.TextChanged+=delegate { multiline.Enabled=box.TextLength==0; }; + if(!review) panel.Controls.Add(multiline); + } + var save=new Button { Text=review?"Approve exact request":shown==null?"Save to vault":"Close private view",AutoSize=true,DialogResult=DialogResult.OK }; + if(review) { + save.Enabled=false; + var confirm=new CheckBox { Text="I reviewed the destination, parameters and content notice.",AutoSize=true }; + confirm.CheckedChanged+=delegate { save.Enabled=confirm.Checked; }; + panel.Controls.Add(confirm); + var cancel=new Button { Text="Deny",DialogResult=DialogResult.Cancel }; panel.Controls.Add(cancel); form.CancelButton=cancel; + } else form.AcceptButton=save; + panel.Controls.Add(save); form.Controls.Add(panel); + if(form.ShowDialog()!=DialogResult.OK) return null; + foreach(var input in inputs) { if(shown==null) result.Add(input.Key,input.Value.Text); input.Value.Clear(); } + } + return result; + } + [STAThread] static int Main(string[] args) { + try { if(args.Length>0 && args[0]=="--entry-guardian") return EntryGuardian(args); if(args.Length>0) return OwnerDialog(args); ServiceBase.Run(new CredentialHost()); return 0; } + catch { return 1; } + } +} diff --git a/app/credentials/native/bootstrap.py b/app/credentials/native/bootstrap.py new file mode 100644 index 000000000..7fceef223 --- /dev/null +++ b/app/credentials/native/bootstrap.py @@ -0,0 +1,19 @@ +"""Entrypoint copied into the administrator-protected install, never a checkout.""" + +import os +from pathlib import Path +import sys + +root = Path(__file__).resolve().parent +# -I -S already excluded environment, cwd, user site and .pth execution. +sys.path[:0] = [ + str(root), + str(root / "deps"), + str(root / "deps" / "win32"), + str(root / "deps" / "win32" / "lib"), + str(root / "deps" / "pywin32_system32"), +] +_dlls = [os.add_dll_directory(str(root / "deps" / "pywin32_system32"))] +from app.credentials.host_service import main # noqa: E402 -- protected import roots first + +main(root) diff --git a/app/credentials/oauth_transport.py b/app/credentials/oauth_transport.py new file mode 100644 index 000000000..6c3e50f74 --- /dev/null +++ b/app/credentials/oauth_transport.py @@ -0,0 +1,46 @@ +"""Execute confidential-client exchanges without releasing client secrets. + +Generated access/refresh tokens return to the existing provider and persistence +callback. The vault holds only the manually configured client secret. +""" + +import json +import httpx + +from .client import CredentialHostUnavailable +from .invocation import invoke +from .model_transport import installed_model_helpers +from .references import parse + + +def request(method, url, *, reference, body, basic=False, json_body=False): + parse(reference) + protocol = "oauth_basic" if basic else "oauth_json" if json_body else "oauth_form" + route = next( + ( + r + for r in installed_model_helpers() + if r["helper"].startswith("oauth_") + and r["endpoint"] == url + and r["method"] == method.upper() + and r.get("protocol") == protocol + ), + None, + ) + if route is None: + raise CredentialHostUnavailable("credential_destination_denied") + public_body = {k: v for k, v in body.items() if k != "client_secret"} + response = invoke( + reference, + route["helper"], + route["operation"], + {"path": {}, "query": {}, "data": public_body}, + subject="OAuth exchange: " + route["helper"], + ) + # Only sanitized JSON; no cookie, redirect or provider headers. + json.loads(response["body"]) + return httpx.Response( + response["status"], + content=response["body"].encode(), + headers={"Content-Type": "application/json"}, + ) diff --git a/app/credentials/operation_session.py b/app/credentials/operation_session.py new file mode 100644 index 000000000..cf20abf6e --- /dev/null +++ b/app/credentials/operation_session.py @@ -0,0 +1,109 @@ +"""Short-lived, exact-operation polling sessions; every grant needs native consent.""" + +import json +import time +from threading import Lock +from .client import CredentialHostUnavailable, request +from .invocation import CredentialResponse +from .references import parse + + +class OperationSession: + def __init__(self, reference, helper, operation, subject): + ref = parse(reference) + self.lock, self.closed = Lock(), False + arguments = dict( + helper=helper, + operation=operation, + credential_id=str(ref.credential_id), + credential_version=ref.version, + repeat=True, + ) + source = ( + "import credential_client, time\ndata = " + + repr(arguments) + + "\n" + + """ +grant = None +try: + while True: + while not credential_client.request('payload_info')['ready']: + time.sleep(0.05) + if grant is None: + approval = credential_client.request('request_use', **data) + grant = approval['grant_id'] + else: + approval = credential_client.request('authorize_grant', grant_id=grant) + credential_client.request('start_use_staged', capability=approval['capability']) + while not credential_client.request('operation_status')['done']: + time.sleep(0.05) + while credential_client.request('operation_status')['active']: + time.sleep(0.05) +except Exception: + credential_client.complete({'error': 'credential_operation_failed'}) +""" + ) + self.run_id = request( + "launch", + profile_id=str(ref.profile_id), + kind="agent_session", + subject=subject, + deployment=None, + files={"poll.py": source}, + entry="poll.py", + )["run_id"] + self.expires = time.monotonic() + 850 + + def invoke(self, body, *, cancelled): + with self.lock: + if self.closed or time.monotonic() >= self.expires: + raise CredentialHostUnavailable("credential_grant_expired") + serialized = json.dumps( + body, ensure_ascii=True, allow_nan=False, separators=(",", ":") + ) + if len(serialized) > 32_000_000: + raise CredentialHostUnavailable("credential_use_limit") + try: + for offset in range(0, len(serialized), 8000): + chunk = serialized[offset : offset + 8000] + request( + "stage_payload", + run_id=self.run_id, + offset=offset, + data=chunk, + done=offset + len(chunk) == len(serialized), + ) + deadline = time.monotonic() + 900 + while time.monotonic() < deadline: + if cancelled.is_set(): + raise CredentialHostUnavailable("credential_use_interrupted") + result = request("result", run_id=self.run_id) + if "response" in result: + response = CredentialResponse( + self.run_id, + result["response"], + cancelled, + stop_on_close=False, + ) + text = "".join(response) + request("reset_operation", run_id=self.run_id) + return dict( + status=response.status, + content_type=response.content_type, + body=text, + ) + if "exited" in result or "result" in result: + raise CredentialHostUnavailable("credential_operation_failed") + time.sleep(0.05) + raise CredentialHostUnavailable("credential_operation_timeout") + except BaseException: + self.close() + raise + + def close(self): + if not self.closed: + self.closed = True + try: + request("stop", run_id=self.run_id) + except CredentialHostUnavailable: + pass diff --git a/app/credentials/owner_transport.py b/app/credentials/owner_transport.py new file mode 100644 index 000000000..72621af2e --- /dev/null +++ b/app/credentials/owner_transport.py @@ -0,0 +1,261 @@ +"""Protected native consent/entry. Never use the browser bridge as owner proof.""" + +from __future__ import annotations + +import base64 +import json +from pathlib import Path +import struct +import subprocess +from uuid import uuid4 + +from .errors import VaultError, VaultErrorCode +from .secrets import Secret +from .windows_host import Peer, SYSTEM, api + + +class WindowsOwnerConsent: + def __init__(self, executable: Path, owner_sid: str): + self.executable = executable + self.owner_sid = owner_sid + self.session = None + self.message = None + self.review_request = None + + def verify(self, owner): + if ( + owner.os_identity != self.owner_sid + or self.session is None + or not self.message + ): + raise VaultError(VaultErrorCode.VERIFICATION_REQUIRED) + review, self.review_request = self.review_request, None + if review is None: + self.prompt() + else: + self._exchange(tuple(review), display=Secret(review), review=True) + + def _launch(self, arguments): + winapi, _, job, process, security, _, event = api() + if not isinstance(self.message, str) or len(self.message) > 1000: + raise VaultError(VaultErrorCode.VERIFICATION_REQUIRED) + text = base64.b64encode(self.message.encode("utf-8")).decode("ascii") + current = security.OpenProcessToken(winapi.GetCurrentProcess(), 0xF01FF) + token = security.DuplicateTokenEx(current, 2, 0xF01FF, 1) + current.Close() + group = job.CreateJobObject(None, "") + info = job.QueryInformationJobObject( + group, job.JobObjectExtendedLimitInformation + ) + # Only the fixed entry guardian may break away. It restores the prior + # desktop if this job/worker/service is killed while source entry runs. + info["BasicLimitInformation"]["LimitFlags"] = 0x2000 | ( + 0x800 if arguments[0] in ("--entry", "--view", "--review") else 0 + ) + job.SetInformationJobObject(group, job.JobObjectExtendedLimitInformation, info) + try: + security.SetTokenInformation(token, 12, self.session) + startup = process.STARTUPINFO() + startup.lpDesktop = "winsta0\\default" + handle, thread, pid, _ = process.CreateProcessAsUser( + token, + str(self.executable), + subprocess.list2cmdline( + [ + str(self.executable), + arguments[0], + self.owner_sid, + text, + *arguments[1:], + ] + ), + None, + None, + False, + 4 | 0x08000000, + {"SYSTEMROOT": winapi.GetWindowsDirectory()}, + str(self.executable.parent), + startup, + ) + try: + job.AssignProcessToJobObject(group, handle) + process.ResumeThread(thread) + finally: + thread.Close() + return handle, group, pid + except BaseException: + group.Close() + raise + finally: + token.Close() + + def prompt(self): + handle, group, _ = self._launch(["--consent"]) + try: + if ( + api()[6].WaitForSingleObject(handle, 90000) != 0 + or api()[3].GetExitCodeProcess(handle) != 0 + ): + raise VaultError(VaultErrorCode.VERIFICATION_REQUIRED) + finally: + group.Close() + handle.Close() + + def entry(self, fields: tuple[str, ...]) -> Secret: + """Native SYSTEM form -> private SYSTEM pipe -> vault; never caller JSON.""" + return self._exchange(fields) + + def view(self, fields: Secret) -> None: + """Owner-only display on the private desktop, with no global clipboard.""" + self._exchange(tuple(fields.reveal()), display=fields) + + def _exchange(self, fields, display=None, *, review=False): + import pywintypes + import win32file + import win32pipe + import win32security + + name = "CraftBot.CredentialEntry." + uuid4().hex + attributes = pywintypes.SECURITY_ATTRIBUTES() + attributes.SECURITY_DESCRIPTOR = ( + win32security.ConvertStringSecurityDescriptorToSecurityDescriptor( + "O:SYD:P(A;;GA;;;SY)", 1 + ) + ) + pipe = win32pipe.CreateNamedPipe( + "\\\\.\\pipe\\" + name, + 3 | 0x80000 | 0x40000000, + 8, + 1, + 65536, + 65536, + 0, + attributes, + ) + handle = group = None + pending = pywintypes.OVERLAPPED() + pending.hEvent = api()[6].CreateEvent(None, True, False, None) + try: + try: + win32pipe.ConnectNamedPipe(pipe, pending) + except pywintypes.error as error: + if error.winerror not in (997, 535): + raise + handle, group, pid = self._launch( + [ + "--review" + if review + else "--entry" + if display is None + else "--view", + name, + ",".join(fields), + ] + ) + if ( + api()[6].WaitForMultipleObjects([pending.hEvent, handle], False, 180000) + != 0 + ): + raise VaultError(VaultErrorCode.VERIFICATION_REQUIRED) + # Kernel process ID, not the JSON body or a claimed SYSTEM flag. + import ctypes + + connected = ctypes.c_ulong() + dll = ctypes.WinDLL("kernel32", use_last_error=True) + dll.GetNamedPipeClientProcessId.argtypes = [ + ctypes.c_void_p, + ctypes.POINTER(ctypes.c_ulong), + ] + if ( + not dll.GetNamedPipeClientProcessId(int(pipe), ctypes.byref(connected)) + or connected.value != pid + ): + raise VaultError(VaultErrorCode.VERIFICATION_REQUIRED) + peer = Peer(pid) + try: + if peer.sid != SYSTEM: + raise VaultError(VaultErrorCode.VERIFICATION_REQUIRED) + finally: + peer.close() + + if display is not None: + data = Secret( + json.dumps(display.reveal(), ensure_ascii=False).encode("utf-8") + ) + if len(data.reveal()) > 2_100_000: + raise VaultError(VaultErrorCode.INVALID) + framed = Secret(struct.pack(" 32000: + raise CredentialHostUnavailable("credential_use_limit") + while "\n\n" in pending: + record, pending = pending.split("\n\n", 1) + if not record.startswith("data: "): + raise CredentialHostUnavailable("credential_operation_failed") + value = json.loads(record[6:]) + if "qr_url" in value: + if callback: + callback(value["qr_url"]) + else: + result = value + if result is None: + raise CredentialHostUnavailable("credential_operation_failed") + return result + finally: + response.close() + + task = asyncio.create_task(asyncio.to_thread(qr)) + try: + return await asyncio.shield(task) + except asyncio.CancelledError: + cancelled.set() + task.add_done_callback( + lambda future: future.exception() if not future.cancelled() else None + ) + raise diff --git a/app/credentials/provider_routes.json b/app/credentials/provider_routes.json new file mode 100644 index 000000000..ccedda100 --- /dev/null +++ b/app/credentials/provider_routes.json @@ -0,0 +1,20072 @@ +[ + { + "type": "json", + "helper": "integration_twitter", + "operation": "get_me_ea712eb9", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/users/me", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "post_tweet_c71310a0", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/tweets", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "id", + "in_reply_to_tweet_id", + "limit", + "list.fields", + "media.fields", + "offset", + "page", + "per_page", + "reply", + "text", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "delete_tweet_3041bed1", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/tweets/:tweet_id", + "method": "DELETE", + "path_fields": [ + "tweet_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "list.fields", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "get_user_timeline_da3ecda5", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/users/:uid/tweets", + "method": "GET", + "path_fields": [ + "uid" + ], + "query_fields": [ + "cursor", + "error", + "expand", + "limit", + "list.fields", + "max_results", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "search_tweets_9b800415", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/tweets/search/recent", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "expansions", + "limit", + "list.fields", + "max_results", + "media.fields", + "offset", + "page", + "per_page", + "query", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "get_user_by_username_f4ac2f39", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/users/by/username/:username", + "method": "GET", + "path_fields": [ + "username" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "get_tweet_a804f822", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/tweets/:tweet_id", + "method": "GET", + "path_fields": [ + "tweet_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "lookup_tweets_bf43de6a", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/tweets", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "ids", + "limit", + "list.fields", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "get_user_mentions_ea2ed2f7", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/users/:uid/mentions", + "method": "GET", + "path_fields": [ + "uid" + ], + "query_fields": [ + "cursor", + "error", + "expand", + "expansions", + "limit", + "list.fields", + "max_results", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "hide_reply_f8408814", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/tweets/:reply_tweet_id/hidden", + "method": "PUT", + "path_fields": [ + "reply_tweet_id" + ], + "query_fields": [ + "cursor", + "expand", + "hidden", + "limit", + "list.fields", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "list_liking_users_217e11a1", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/tweets/:tweet_id/liking_users", + "method": "GET", + "path_fields": [ + "tweet_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "max_results", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "list_retweeted_by_0fb83c4c", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/tweets/:tweet_id/retweeted_by", + "method": "GET", + "path_fields": [ + "tweet_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "max_results", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "list_following_94726f9b", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/users/:uid/following", + "method": "GET", + "path_fields": [ + "uid" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "max_results", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "list_followers_a0a017c6", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/users/:uid/followers", + "method": "GET", + "path_fields": [ + "uid" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "max_results", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "create_list_9abf7742", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/lists", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "description", + "expand", + "limit", + "list.fields", + "media.fields", + "name", + "offset", + "page", + "per_page", + "private", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "get_list_ceb214e1", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/lists/:list_id", + "method": "GET", + "path_fields": [ + "list_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "update_list_9bd0ac37", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/lists/:list_id", + "method": "PUT", + "path_fields": [ + "list_id" + ], + "query_fields": [ + "cursor", + "description", + "error", + "expand", + "limit", + "list.fields", + "media.fields", + "name", + "offset", + "page", + "per_page", + "private", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "delete_list_f7191322", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/lists/:list_id", + "method": "DELETE", + "path_fields": [ + "list_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "list.fields", + "list_id", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "list_owned_lists_b8e64be0", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/users/:uid/owned_lists", + "method": "GET", + "path_fields": [ + "uid" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "max_results", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "add_list_member_7ff33de0", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/lists/:list_id/members", + "method": "POST", + "path_fields": [ + "list_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "remove_list_member_435923eb", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/lists/:list_id/members/:user_id", + "method": "DELETE", + "path_fields": [ + "list_id", + "user_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "list_list_members_8d93efa1", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/lists/:list_id/members", + "method": "GET", + "path_fields": [ + "list_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "max_results", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "list_list_tweets_8e7a4b5c", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/lists/:list_id/tweets", + "method": "GET", + "path_fields": [ + "list_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "max_results", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "send_dm_to_user_884e8cd6", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/dm_conversations/with/:participant_id/messages", + "method": "POST", + "path_fields": [ + "participant_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "media.fields", + "offset", + "page", + "per_page", + "text", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "send_dm_to_conversation_73b20561", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/dm_conversations/:dm_conversation_id/messages", + "method": "POST", + "path_fields": [ + "dm_conversation_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "media.fields", + "offset", + "page", + "per_page", + "text", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "create_group_dm_397a5045", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/dm_conversations", + "method": "POST", + "path_fields": [], + "query_fields": [ + "conversation_type", + "cursor", + "expand", + "limit", + "list.fields", + "media.fields", + "message", + "offset", + "page", + "participant_ids", + "per_page", + "text", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "list_dm_events_11e3db85", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/dm_events", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "max_results", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "list_dm_events_with_user_5bbb3a82", + "kind": "service_secret", + "endpoint": "https://api.twitter.com/2/dm_conversations/with/:participant_id/dm_events", + "method": "GET", + "path_fields": [ + "participant_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "list.fields", + "max_results", + "media.fields", + "offset", + "page", + "per_page", + "tweet.fields", + "user.fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "check_updates_60bf0c44", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/search/jql", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "error", + "expand", + "fields", + "jql", + "limit", + "maxResults", + "offset", + "page", + "per_page", + "result" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_myself_5446621a", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/myself", + "method": "GET", + "path_fields": [], + "query_fields": [ + "accountId", + "active", + "cursor", + "displayName", + "emailAddress", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_issue_2589c2f0", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key", + "method": "GET", + "path_fields": [ + "issue_key" + ], + "query_fields": [ + "cursor", + "expand", + "fields", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "create_issue_76346148", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue", + "method": "POST", + "path_fields": [], + "query_fields": [ + "accountId", + "assignee", + "cursor", + "description", + "expand", + "fields", + "id", + "issuetype", + "key", + "labels", + "limit", + "name", + "offset", + "page", + "per_page", + "priority", + "project", + "self", + "summary" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "update_issue_c678a8ae", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key", + "method": "PUT", + "path_fields": [ + "issue_key" + ], + "query_fields": [ + "cursor", + "expand", + "fields", + "key", + "limit", + "offset", + "page", + "per_page", + "updated" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "add_comment_9a6035ce", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key/comment", + "method": "POST", + "path_fields": [ + "issue_key" + ], + "query_fields": [ + "author", + "body", + "created", + "cursor", + "expand", + "id", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_transitions_43f1a667", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key/transitions", + "method": "GET", + "path_fields": [ + "issue_key" + ], + "query_fields": [ + "cursor", + "expand", + "id", + "limit", + "name", + "offset", + "page", + "per_page", + "to", + "transitions" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "transition_issue_7d484204", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key/transitions", + "method": "POST", + "path_fields": [ + "issue_key" + ], + "query_fields": [ + "add", + "body", + "comment", + "cursor", + "expand", + "id", + "key", + "limit", + "offset", + "page", + "per_page", + "transition", + "transitioned", + "update" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "assign_issue_aff52ffa", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key/assignee", + "method": "PUT", + "path_fields": [ + "issue_key" + ], + "query_fields": [ + "accountId", + "assigned", + "cursor", + "expand", + "key", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_projects_afcae16f", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/project/search", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "id", + "key", + "limit", + "maxResults", + "name", + "offset", + "page", + "per_page", + "projects", + "style" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "search_users_f33eb3d6", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/user/search", + "method": "GET", + "path_fields": [], + "query_fields": [ + "accountId", + "active", + "cursor", + "displayName", + "emailAddress", + "expand", + "limit", + "maxResults", + "offset", + "page", + "per_page", + "query", + "users" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_issue_comments_8fa11594", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key/comment", + "method": "GET", + "path_fields": [ + "issue_key" + ], + "query_fields": [ + "author", + "body", + "comments", + "created", + "cursor", + "expand", + "id", + "limit", + "maxResults", + "offset", + "orderBy", + "page", + "per_page", + "total", + "updated" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_statuses_5fe069ff", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/project/:project_key/statuses", + "method": "GET", + "path_fields": [ + "project_key" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "delete_issue_1c9714a3", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key", + "method": "DELETE", + "path_fields": [ + "issue_key" + ], + "query_fields": [ + "cursor", + "deleteSubtasks", + "deleted", + "expand", + "key", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "update_comment_1e13f35a", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key/comment/:comment_id", + "method": "PUT", + "path_fields": [ + "issue_key", + "comment_id" + ], + "query_fields": [ + "author", + "body", + "cursor", + "expand", + "id", + "limit", + "offset", + "page", + "per_page", + "updated" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "delete_comment_e8f664d5", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key/comment/:comment_id", + "method": "DELETE", + "path_fields": [ + "issue_key", + "comment_id" + ], + "query_fields": [ + "comment_id", + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_watchers_74395187", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key/watchers", + "method": "GET", + "path_fields": [ + "issue_key" + ], + "query_fields": [ + "accountId", + "active", + "cursor", + "displayName", + "expand", + "is_watching", + "limit", + "offset", + "page", + "per_page", + "watch_count", + "watchers" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "add_watcher_9e5a97b9", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key/watchers", + "method": "POST", + "path_fields": [ + "issue_key" + ], + "query_fields": [ + "account_id", + "added", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "remove_watcher_21449e0e", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key/watchers", + "method": "DELETE", + "path_fields": [ + "issue_key" + ], + "query_fields": [ + "accountId", + "account_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "removed" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "add_attachment_ef6a982c", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key/attachments", + "method": "POST", + "path_fields": [ + "issue_key" + ], + "query_fields": [ + "Accept", + "Authorization", + "attachments", + "content", + "cursor", + "details", + "error", + "expand", + "file", + "filename", + "id", + "limit", + "offset", + "page", + "per_page", + "size" + ], + "envelope": true, + "request_format": "multipart", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_attachment_130fb858", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/attachment/:attachment_id", + "method": "GET", + "path_fields": [ + "attachment_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "delete_attachment_ba8f1bc2", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/attachment/:attachment_id", + "method": "DELETE", + "path_fields": [ + "attachment_id" + ], + "query_fields": [ + "attachment_id", + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "add_worklog_0a1443fb", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key/worklog", + "method": "POST", + "path_fields": [ + "issue_key" + ], + "query_fields": [ + "author", + "comment", + "cursor", + "expand", + "id", + "limit", + "offset", + "page", + "per_page", + "started", + "timeSpent", + "timeSpentSeconds" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_worklogs_39be1730", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key/worklog", + "method": "GET", + "path_fields": [ + "issue_key" + ], + "query_fields": [ + "author", + "comment", + "cursor", + "expand", + "id", + "limit", + "offset", + "page", + "per_page", + "started", + "timeSpent", + "timeSpentSeconds", + "total", + "worklogs" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "update_worklog_b921cea1", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key/worklog/:worklog_id", + "method": "PUT", + "path_fields": [ + "issue_key", + "worklog_id" + ], + "query_fields": [ + "comment", + "cursor", + "expand", + "id", + "limit", + "offset", + "page", + "per_page", + "started", + "timeSpent", + "timeSpentSeconds", + "updated" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "delete_worklog_3ef35115", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issue/:issue_key/worklog/:worklog_id", + "method": "DELETE", + "path_fields": [ + "issue_key", + "worklog_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page", + "worklog_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "create_issue_link_96063bbc", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issueLink", + "method": "POST", + "path_fields": [], + "query_fields": [ + "body", + "comment", + "created", + "cursor", + "expand", + "inward", + "inwardIssue", + "key", + "limit", + "name", + "offset", + "outward", + "outwardIssue", + "page", + "per_page", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_issue_link_5f3dbb8b", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issueLink/:link_id", + "method": "GET", + "path_fields": [ + "link_id" + ], + "query_fields": [ + "cursor", + "expand", + "id", + "inwardIssue", + "key", + "limit", + "offset", + "outwardIssue", + "page", + "per_page", + "status", + "summary", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "delete_issue_link_14588bd7", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issueLink/:link_id", + "method": "DELETE", + "path_fields": [ + "link_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "link_id", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "list_issue_link_types_ea14a613", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issueLinkType", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "id", + "inward", + "limit", + "name", + "offset", + "outward", + "page", + "per_page", + "types" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "list_versions_bb00758c", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/project/:project_key/versions", + "method": "GET", + "path_fields": [ + "project_key" + ], + "query_fields": [ + "archived", + "cursor", + "expand", + "id", + "limit", + "name", + "offset", + "page", + "per_page", + "releaseDate", + "released", + "versions" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "create_version_40409a09", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/version", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "description", + "error", + "expand", + "id", + "limit", + "name", + "offset", + "page", + "per_page", + "projectId", + "releaseDate", + "released", + "startDate" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "update_version_486ba69a", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/version/:version_id", + "method": "PUT", + "path_fields": [ + "version_id" + ], + "query_fields": [ + "archived", + "cursor", + "description", + "expand", + "limit", + "name", + "offset", + "page", + "per_page", + "releaseDate", + "released" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "delete_version_0dfecaea", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/version/:version_id", + "method": "DELETE", + "path_fields": [ + "version_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page", + "version_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "list_components_11e00965", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/project/:project_key/components", + "method": "GET", + "path_fields": [ + "project_key" + ], + "query_fields": [ + "components", + "cursor", + "description", + "expand", + "id", + "lead", + "limit", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "create_component_f7095b9a", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/component", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "description", + "expand", + "id", + "leadAccountId", + "limit", + "name", + "offset", + "page", + "per_page", + "project" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "delete_component_270aec2d", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/component/:component_id", + "method": "DELETE", + "path_fields": [ + "component_id" + ], + "query_fields": [ + "component_id", + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_project_c7bc4fb8", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/project/:project_key", + "method": "GET", + "path_fields": [ + "project_key" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "list_priorities_f89e0fee", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/priority", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "id", + "limit", + "name", + "offset", + "page", + "per_page", + "priorities" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "list_issue_types_df73970f", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/api/3/issuetype", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "description", + "expand", + "id", + "issue_types", + "limit", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "list_boards_0eef9824", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/agile/1.0/board", + "method": "GET", + "path_fields": [], + "query_fields": [ + "boards", + "cursor", + "expand", + "id", + "limit", + "location", + "maxResults", + "name", + "offset", + "page", + "per_page", + "projectKeyOrId", + "total", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_board_815c130e", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/agile/1.0/board/:board_id", + "method": "GET", + "path_fields": [ + "board_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_board_issues_08ff37b5", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/agile/1.0/board/:board_id/issue", + "method": "GET", + "path_fields": [ + "board_id" + ], + "query_fields": [ + "cursor", + "expand", + "fields", + "issues", + "jql", + "limit", + "maxResults", + "offset", + "page", + "per_page", + "total" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_board_sprints_93030111", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/agile/1.0/board/:board_id/sprint", + "method": "GET", + "path_fields": [ + "board_id" + ], + "query_fields": [ + "cursor", + "endDate", + "expand", + "goal", + "id", + "limit", + "maxResults", + "name", + "offset", + "page", + "per_page", + "sprints", + "startDate", + "state", + "total" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_board_backlog_bea79f3f", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/agile/1.0/board/:board_id/backlog", + "method": "GET", + "path_fields": [ + "board_id" + ], + "query_fields": [ + "cursor", + "expand", + "fields", + "issues", + "limit", + "maxResults", + "offset", + "page", + "per_page", + "total" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_sprint_b09028f3", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/agile/1.0/sprint/:sprint_id", + "method": "GET", + "path_fields": [ + "sprint_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_sprint_issues_98b66050", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/agile/1.0/sprint/:sprint_id/issue", + "method": "GET", + "path_fields": [ + "sprint_id" + ], + "query_fields": [ + "cursor", + "expand", + "fields", + "issues", + "jql", + "limit", + "maxResults", + "offset", + "page", + "per_page", + "total" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "create_sprint_8f0125b1", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/agile/1.0/sprint", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "endDate", + "expand", + "goal", + "id", + "limit", + "name", + "offset", + "originBoardId", + "page", + "per_page", + "startDate", + "state" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "update_sprint_ee0d7ead", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/agile/1.0/sprint/:sprint_id", + "method": "POST", + "path_fields": [ + "sprint_id" + ], + "query_fields": [ + "cursor", + "endDate", + "expand", + "goal", + "limit", + "name", + "offset", + "page", + "per_page", + "startDate", + "state" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "delete_sprint_46ac798a", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/agile/1.0/sprint/:sprint_id", + "method": "DELETE", + "path_fields": [ + "sprint_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page", + "sprint_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "move_issues_to_sprint_fc5e2a2f", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/agile/1.0/sprint/:sprint_id/issue", + "method": "POST", + "path_fields": [ + "sprint_id" + ], + "query_fields": [ + "cursor", + "expand", + "issues", + "limit", + "moved", + "offset", + "page", + "per_page", + "sprint_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "move_issues_to_backlog_a39da8c5", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/agile/1.0/backlog/issue", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "issues", + "limit", + "moved", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_epic_4aaed319", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/agile/1.0/epic/:epic_id_or_key", + "method": "GET", + "path_fields": [ + "epic_id_or_key" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "get_epic_issues_c9bf6f5b", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/agile/1.0/epic/:epic_id_or_key/issue", + "method": "GET", + "path_fields": [ + "epic_id_or_key" + ], + "query_fields": [ + "cursor", + "expand", + "fields", + "issues", + "limit", + "maxResults", + "offset", + "page", + "per_page", + "total" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_jira", + "operation": "move_issues_to_epic_16c1a13a", + "kind": "service_secret", + "endpoint": "https://jira.example.invalid/rest/agile/1.0/epic/:epic_id_or_key/issue", + "method": "POST", + "path_fields": [ + "epic_id_or_key" + ], + "query_fields": [ + "cursor", + "epic", + "expand", + "issues", + "limit", + "moved", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_token", + "protocol": "basic" + }, + { + "type": "json", + "helper": "integration_github", + "operation": "check_notifications_sync_10bac9bf", + "kind": "api_key", + "endpoint": "https://api.github.com/notifications", + "method": "GET", + "path_fields": [], + "query_fields": [ + "all", + "cursor", + "expand", + "last_modified", + "limit", + "notifications", + "offset", + "page", + "participating", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "get_authenticated_user_a78c5cdf", + "kind": "api_key", + "endpoint": "https://api.github.com/user", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "id", + "limit", + "login", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_repos_84cda9eb", + "kind": "api_key", + "endpoint": "https://api.github.com/user/repos", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "description", + "expand", + "full_name", + "limit", + "name", + "offset", + "page", + "per_page", + "private", + "repos", + "sort" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "get_repo_397d5063", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo", + "method": "GET", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "archived", + "cursor", + "default_branch", + "description", + "expand", + "fork", + "forks_count", + "full_name", + "html_url", + "language", + "limit", + "name", + "offset", + "open_issues_count", + "owner", + "page", + "per_page", + "private", + "pushed_at", + "stargazers_count", + "topics" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_issues_eefb6999", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues", + "method": "GET", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "assignees", + "created_at", + "cursor", + "expand", + "is_pr", + "issues", + "labels", + "limit", + "number", + "offset", + "page", + "per_page", + "state", + "title", + "updated_at", + "user" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "get_issue_7c1d3957", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/:number", + "method": "GET", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "assignees", + "body", + "closed_at", + "comments", + "created_at", + "cursor", + "expand", + "html_url", + "is_pr", + "labels", + "limit", + "milestone", + "number", + "offset", + "page", + "per_page", + "state", + "title", + "updated_at", + "user" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "create_issue_e019cfa0", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues", + "method": "POST", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "assignees", + "body", + "cursor", + "expand", + "html_url", + "labels", + "limit", + "number", + "offset", + "page", + "per_page", + "title" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "create_comment_a293e0a9", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/:number/comments", + "method": "POST", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "body", + "cursor", + "expand", + "html_url", + "id", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_pull_requests_b9e6fef4", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls", + "method": "GET", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "base", + "created_at", + "cursor", + "draft", + "expand", + "head", + "limit", + "number", + "offset", + "page", + "per_page", + "pull_requests", + "state", + "title", + "user" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "search_issues_a3309c38", + "kind": "api_key", + "endpoint": "https://api.github.com/search/issues", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "html_url", + "items", + "limit", + "number", + "offset", + "page", + "per_page", + "q", + "repo", + "state", + "title", + "total_count", + "user" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "add_labels_83f2f3bf", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/:number/labels", + "method": "POST", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "cursor", + "expand", + "labels", + "labels_added", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "close_issue_10f7d874", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/:number", + "method": "PATCH", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "closed", + "cursor", + "expand", + "limit", + "number", + "offset", + "page", + "per_page", + "state" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "create_repo_6caaab32", + "kind": "api_key", + "endpoint": "https://api.github.com/user/repos", + "method": "POST", + "path_fields": [], + "query_fields": [ + "auto_init", + "cursor", + "description", + "expand", + "full_name", + "html_url", + "limit", + "name", + "offset", + "page", + "per_page", + "private" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "update_repo_b11bc9de", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo", + "method": "PATCH", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "archived", + "cursor", + "default_branch", + "description", + "expand", + "full_name", + "html_url", + "limit", + "name", + "offset", + "page", + "per_page", + "private" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "delete_repo_b3a8302b", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo", + "method": "DELETE", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page", + "repo" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "fork_repo_570f436b", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/forks", + "method": "POST", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "cursor", + "default_branch", + "default_branch_only", + "expand", + "full_name", + "html_url", + "limit", + "name", + "offset", + "organization", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_forks_80ce3d89", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/forks", + "method": "GET", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "cursor", + "expand", + "forks", + "full_name", + "html_url", + "limit", + "offset", + "owner", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_collaborators_6bb42ef0", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/collaborators", + "method": "GET", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "collaborators", + "cursor", + "expand", + "limit", + "login", + "offset", + "page", + "per_page", + "permissions" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "add_collaborator_43bc7c83", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/collaborators/:username", + "method": "PUT", + "path_fields": [ + "owner_repo", + "username" + ], + "query_fields": [ + "added", + "cursor", + "expand", + "invitation_id", + "limit", + "offset", + "page", + "per_page", + "permission", + "username" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "remove_collaborator_3e8c06d1", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/collaborators/:username", + "method": "DELETE", + "path_fields": [ + "owner_repo", + "username" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "removed", + "username" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "get_readme_d7fedf37", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/readme", + "method": "GET", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "content_b64", + "cursor", + "download_url", + "encoding", + "expand", + "limit", + "name", + "offset", + "page", + "path", + "per_page", + "ref" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_topics_779c4c31", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/topics", + "method": "GET", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "topics" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "set_topics_a5e90531", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/topics", + "method": "PUT", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "names", + "offset", + "page", + "per_page", + "topics" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "get_file_6c381eae", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/contents/:path", + "method": "GET", + "path_fields": [ + "owner_repo", + "path" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "ref" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "create_or_update_file_6816c240", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/contents/:path", + "method": "PUT", + "path_fields": [ + "owner_repo", + "path" + ], + "query_fields": [ + "branch", + "commit_html_url", + "commit_sha", + "content", + "content_html_url", + "content_sha", + "cursor", + "expand", + "limit", + "message", + "offset", + "page", + "path", + "per_page", + "sha" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "delete_file_065da4e6", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/contents/:path", + "method": "DELETE", + "path_fields": [ + "owner_repo", + "path" + ], + "query_fields": [ + "branch", + "commit_sha", + "cursor", + "deleted", + "expand", + "limit", + "message", + "offset", + "page", + "per_page", + "sha" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_branches_31deaff4", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/branches", + "method": "GET", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "branches", + "cursor", + "expand", + "limit", + "name", + "offset", + "page", + "per_page", + "protected", + "sha" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "get_branch_3802f403", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/branches/:branch", + "method": "GET", + "path_fields": [ + "owner_repo", + "branch" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "name", + "offset", + "page", + "per_page", + "protected", + "sha" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "create_branch_0b193e87", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/git/refs", + "method": "POST", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "ref", + "sha" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "delete_branch_2679891b", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/git/refs/heads/:branch", + "method": "DELETE", + "path_fields": [ + "owner_repo", + "branch" + ], + "query_fields": [ + "branch", + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_commits_330d6995", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/commits", + "method": "GET", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "author", + "commits", + "cursor", + "date", + "expand", + "html_url", + "limit", + "message", + "offset", + "page", + "path", + "per_page", + "sha" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "get_commit_6569438f", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/commits/:sha", + "method": "GET", + "path_fields": [ + "owner_repo", + "sha" + ], + "query_fields": [ + "additions", + "author", + "committer", + "cursor", + "date", + "deletions", + "email", + "expand", + "filename", + "files", + "html_url", + "limit", + "login", + "message", + "name", + "offset", + "page", + "parents", + "patch", + "per_page", + "sha", + "stats", + "status" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "get_pull_request_3ca661c7", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls/:number", + "method": "GET", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "additions", + "assignees", + "base", + "body", + "changed_files", + "closed_at", + "comments", + "commits", + "created_at", + "cursor", + "deletions", + "draft", + "expand", + "head", + "html_url", + "labels", + "limit", + "mergeable", + "merged", + "merged_at", + "merged_by", + "milestone", + "number", + "offset", + "page", + "per_page", + "ref", + "repo", + "requested_reviewers", + "sha", + "state", + "title", + "updated_at", + "user" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "create_pull_request_c6865d07", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls", + "method": "POST", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "base", + "body", + "cursor", + "draft", + "expand", + "head", + "html_url", + "limit", + "maintainer_can_modify", + "number", + "offset", + "page", + "per_page", + "state", + "title" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "update_pull_request_40e48b66", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls/:number", + "method": "PATCH", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "base", + "body", + "cursor", + "expand", + "html_url", + "limit", + "number", + "offset", + "page", + "per_page", + "state", + "title" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "merge_pull_request_36fcf780", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls/:number/merge", + "method": "PUT", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "commit_message", + "commit_title", + "cursor", + "expand", + "limit", + "merge_method", + "merged", + "message", + "offset", + "page", + "per_page", + "sha" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_pr_files_be3d68e2", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls/:number/files", + "method": "GET", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "additions", + "cursor", + "deletions", + "expand", + "filename", + "files", + "limit", + "offset", + "page", + "patch", + "per_page", + "status" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_pr_commits_ac738547", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls/:number/commits", + "method": "GET", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "author", + "commits", + "cursor", + "expand", + "limit", + "message", + "offset", + "page", + "per_page", + "sha" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "request_pr_reviewers_3a2ce7d1", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls/:number/requested_reviewers", + "method": "POST", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "requested", + "reviewers", + "team_reviewers" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "remove_pr_reviewers_63b7927f", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls/:number/requested_reviewers", + "method": "DELETE", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "removed", + "reviewers", + "team_reviewers" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "create_pr_review_b941ad52", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls/:number/reviews", + "method": "POST", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "body", + "comments", + "cursor", + "event", + "expand", + "html_url", + "id", + "limit", + "offset", + "page", + "per_page", + "state" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_pr_reviews_e89f45c1", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls/:number/reviews", + "method": "GET", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "body", + "cursor", + "expand", + "id", + "limit", + "offset", + "page", + "per_page", + "reviews", + "state", + "submitted_at", + "user" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "submit_pr_review_f9f1c88e", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls/:number/reviews/:review_id/events", + "method": "POST", + "path_fields": [ + "owner_repo", + "number", + "review_id" + ], + "query_fields": [ + "body", + "cursor", + "event", + "expand", + "id", + "limit", + "offset", + "page", + "per_page", + "state" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_pr_review_comments_93d682d6", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls/:number/comments", + "method": "GET", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "body", + "comments", + "cursor", + "expand", + "id", + "limit", + "line", + "offset", + "page", + "path", + "per_page", + "user" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "create_pr_review_comment_61a8cf38", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls/:number/comments", + "method": "POST", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "body", + "commit_id", + "cursor", + "expand", + "html_url", + "id", + "limit", + "line", + "offset", + "page", + "path", + "per_page", + "side" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "lock_issue_52338b9d", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/:number/lock", + "method": "PUT", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "lock_reason", + "locked", + "number", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "unlock_issue_466c7d2d", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/:number/lock", + "method": "DELETE", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "number", + "offset", + "page", + "per_page", + "unlocked" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_issue_comments_6730fbf8", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/:number/comments", + "method": "GET", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "body", + "comments", + "created_at", + "cursor", + "expand", + "id", + "limit", + "offset", + "page", + "per_page", + "user" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "update_issue_comment_b3934ec1", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/comments/:comment_id", + "method": "PATCH", + "path_fields": [ + "owner_repo", + "comment_id" + ], + "query_fields": [ + "body", + "cursor", + "expand", + "html_url", + "id", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "delete_issue_comment_287bd680", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/comments/:comment_id", + "method": "DELETE", + "path_fields": [ + "owner_repo", + "comment_id" + ], + "query_fields": [ + "comment_id", + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_issue_events_f7f72dd8", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/:number/events", + "method": "GET", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "actor", + "created_at", + "cursor", + "event", + "events", + "expand", + "id", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "remove_issue_label_498532b9", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/:number/labels/:name", + "method": "DELETE", + "path_fields": [ + "owner_repo", + "number", + "name" + ], + "query_fields": [ + "cursor", + "expand", + "labels", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "set_issue_labels_f2fcd85f", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/:number/labels", + "method": "PUT", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "cursor", + "expand", + "labels", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "add_assignees_47fa7b48", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/:number/assignees", + "method": "POST", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "assignees", + "cursor", + "expand", + "limit", + "number", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "remove_assignees_fa8a863b", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/:number/assignees", + "method": "DELETE", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "assignees", + "cursor", + "expand", + "limit", + "number", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_repo_labels_17c3bca8", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/labels", + "method": "GET", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "color", + "cursor", + "description", + "expand", + "labels", + "limit", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "create_label_2fada9bc", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/labels", + "method": "POST", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "color", + "cursor", + "description", + "expand", + "limit", + "name", + "offset", + "page", + "per_page", + "url" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "update_label_95da2099", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/labels/:name", + "method": "PATCH", + "path_fields": [ + "owner_repo", + "name" + ], + "query_fields": [ + "color", + "cursor", + "description", + "expand", + "limit", + "name", + "new_name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "delete_label_2ea78b86", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/labels/:name", + "method": "DELETE", + "path_fields": [ + "owner_repo", + "name" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_milestones_c1a60f94", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/milestones", + "method": "GET", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "closed_issues", + "cursor", + "due_on", + "expand", + "limit", + "milestones", + "number", + "offset", + "open_issues", + "page", + "per_page", + "state", + "title" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "create_milestone_4366636a", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/milestones", + "method": "POST", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "cursor", + "description", + "due_on", + "expand", + "html_url", + "limit", + "number", + "offset", + "page", + "per_page", + "state", + "title" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "update_milestone_f1132ea8", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/milestones/:number", + "method": "PATCH", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "cursor", + "description", + "due_on", + "expand", + "limit", + "number", + "offset", + "page", + "per_page", + "state", + "title" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "delete_milestone_092b879e", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/milestones/:number", + "method": "DELETE", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "number", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_releases_0ce2db6e", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/releases", + "method": "GET", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "cursor", + "draft", + "expand", + "html_url", + "id", + "limit", + "name", + "offset", + "page", + "per_page", + "prerelease", + "published_at", + "releases", + "tag_name" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "get_release_7cfad5df", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/releases/:release_id", + "method": "GET", + "path_fields": [ + "owner_repo", + "release_id" + ], + "query_fields": [ + "assets", + "author", + "body", + "browser_download_url", + "created_at", + "cursor", + "download_count", + "draft", + "error", + "expand", + "html_url", + "id", + "limit", + "name", + "offset", + "page", + "per_page", + "prerelease", + "published_at", + "size", + "tag_name" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "create_release_2800e591", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/releases", + "method": "POST", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "body", + "cursor", + "draft", + "expand", + "html_url", + "id", + "limit", + "name", + "offset", + "page", + "per_page", + "prerelease", + "tag_name", + "target_commitish" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "update_release_cc9b361c", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/releases/:release_id", + "method": "PATCH", + "path_fields": [ + "owner_repo", + "release_id" + ], + "query_fields": [ + "body", + "cursor", + "draft", + "expand", + "html_url", + "id", + "limit", + "name", + "offset", + "page", + "per_page", + "prerelease", + "tag_name" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "delete_release_728ed7c7", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/releases/:release_id", + "method": "DELETE", + "path_fields": [ + "owner_repo", + "release_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page", + "release_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_tags_ef00b15e", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/tags", + "method": "GET", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "name", + "offset", + "page", + "per_page", + "sha", + "tags" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "add_issue_reaction_b94b540d", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/:number/reactions", + "method": "POST", + "path_fields": [ + "owner_repo", + "number" + ], + "query_fields": [ + "content", + "cursor", + "expand", + "id", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "add_issue_comment_reaction_b395a890", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/comments/:comment_id/reactions", + "method": "POST", + "path_fields": [ + "owner_repo", + "comment_id" + ], + "query_fields": [ + "content", + "cursor", + "expand", + "id", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "add_pr_review_comment_reaction_e271355e", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls/comments/:comment_id/reactions", + "method": "POST", + "path_fields": [ + "owner_repo", + "comment_id" + ], + "query_fields": [ + "content", + "cursor", + "expand", + "id", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "delete_issue_reaction_4705eb3d", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/:number/reactions/:reaction_id", + "method": "DELETE", + "path_fields": [ + "owner_repo", + "number", + "reaction_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page", + "reaction_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "delete_issue_comment_reaction_1f80dcf1", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/issues/comments/:comment_id/reactions/:reaction_id", + "method": "DELETE", + "path_fields": [ + "owner_repo", + "comment_id", + "reaction_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page", + "reaction_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "delete_pr_review_comment_reaction_f8b831f4", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/pulls/comments/:comment_id/reactions/:reaction_id", + "method": "DELETE", + "path_fields": [ + "owner_repo", + "comment_id", + "reaction_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page", + "reaction_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "search_repos_730bece5", + "kind": "api_key", + "endpoint": "https://api.github.com/search/repositories", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "description", + "expand", + "full_name", + "html_url", + "items", + "language", + "limit", + "offset", + "page", + "per_page", + "q", + "stars", + "total_count" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "search_code_7ea83252", + "kind": "api_key", + "endpoint": "https://api.github.com/search/code", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "html_url", + "items", + "limit", + "name", + "offset", + "page", + "path", + "per_page", + "q", + "repo", + "total_count" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "search_users_5e5b0933", + "kind": "api_key", + "endpoint": "https://api.github.com/search/users", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "html_url", + "items", + "limit", + "login", + "offset", + "page", + "per_page", + "q", + "total_count", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "search_commits_0f994a59", + "kind": "api_key", + "endpoint": "https://api.github.com/search/commits", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "html_url", + "items", + "limit", + "message", + "offset", + "page", + "per_page", + "q", + "repo", + "sha", + "total_count" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "get_user_c90af863", + "kind": "api_key", + "endpoint": "https://api.github.com/users/:username", + "method": "GET", + "path_fields": [ + "username" + ], + "query_fields": [ + "bio", + "cursor", + "expand", + "followers", + "following", + "html_url", + "limit", + "login", + "name", + "offset", + "page", + "per_page", + "public_repos" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_user_repos_3d0a9c64", + "kind": "api_key", + "endpoint": "https://api.github.com/users/:username/repos", + "method": "GET", + "path_fields": [ + "username" + ], + "query_fields": [ + "cursor", + "description", + "expand", + "full_name", + "html_url", + "language", + "limit", + "offset", + "page", + "per_page", + "repos", + "sort", + "stars" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "follow_user_5fb0bc2d", + "kind": "api_key", + "endpoint": "https://api.github.com/user/following/:username", + "method": "PUT", + "path_fields": [ + "username" + ], + "query_fields": [ + "cursor", + "expand", + "followed", + "limit", + "offset", + "page", + "per_page", + "username" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "unfollow_user_716e8e46", + "kind": "api_key", + "endpoint": "https://api.github.com/user/following/:username", + "method": "DELETE", + "path_fields": [ + "username" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "unfollowed", + "username" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_followers_e2e9d536", + "kind": "api_key", + "endpoint": "https://api.github.com/user/followers", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "followers", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_following_7a09905d", + "kind": "api_key", + "endpoint": "https://api.github.com/user/following", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "following", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "star_repo_21725e7d", + "kind": "api_key", + "endpoint": "https://api.github.com/user/starred/:owner_repo", + "method": "PUT", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "repo", + "starred" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "unstar_repo_64e091ea", + "kind": "api_key", + "endpoint": "https://api.github.com/user/starred/:owner_repo", + "method": "DELETE", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "repo", + "unstarred" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_starred_111412c8", + "kind": "api_key", + "endpoint": "https://api.github.com/user/starred", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "full_name", + "html_url", + "limit", + "offset", + "page", + "per_page", + "starred" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_stargazers_8be04fd5", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/stargazers", + "method": "GET", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "stargazers" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_gists_0b14a723", + "kind": "api_key", + "endpoint": "https://api.github.com/gists", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "description", + "expand", + "files", + "gists", + "html_url", + "id", + "limit", + "offset", + "page", + "per_page", + "public" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "get_gist_83568830", + "kind": "api_key", + "endpoint": "https://api.github.com/gists/:gist_id", + "method": "GET", + "path_fields": [ + "gist_id" + ], + "query_fields": [ + "content", + "created_at", + "cursor", + "description", + "expand", + "filename", + "files", + "html_url", + "id", + "language", + "limit", + "offset", + "owner", + "page", + "per_page", + "public", + "size", + "truncated", + "updated_at" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "create_gist_e042caa9", + "kind": "api_key", + "endpoint": "https://api.github.com/gists", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "description", + "expand", + "files", + "html_url", + "id", + "limit", + "offset", + "page", + "per_page", + "public" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "update_gist_d0bf3519", + "kind": "api_key", + "endpoint": "https://api.github.com/gists/:gist_id", + "method": "PATCH", + "path_fields": [ + "gist_id" + ], + "query_fields": [ + "cursor", + "description", + "expand", + "files", + "html_url", + "id", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "delete_gist_acda7360", + "kind": "api_key", + "endpoint": "https://api.github.com/gists/:gist_id", + "method": "DELETE", + "path_fields": [ + "gist_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "gist_id", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "mark_all_notifications_read_12bde736", + "kind": "api_key", + "endpoint": "https://api.github.com/notifications", + "method": "PUT", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "last_read_at", + "limit", + "marked_read", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "mark_notification_read_b5966744", + "kind": "api_key", + "endpoint": "https://api.github.com/notifications/threads/:thread_id", + "method": "PATCH", + "path_fields": [ + "thread_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "marked_read", + "offset", + "page", + "per_page", + "thread_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "list_workflows_1bc0ba27", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/actions/workflows", + "method": "GET", + "path_fields": [ + "owner_repo" + ], + "query_fields": [ + "cursor", + "expand", + "id", + "limit", + "name", + "offset", + "page", + "path", + "per_page", + "state", + "workflows" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "get_workflow_run_16007a32", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/actions/runs/:run_id", + "method": "GET", + "path_fields": [ + "owner_repo", + "run_id" + ], + "query_fields": [ + "branch", + "conclusion", + "cursor", + "expand", + "html_url", + "id", + "limit", + "name", + "offset", + "page", + "per_page", + "status" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "trigger_workflow_4ae973c7", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/actions/workflows/:workflow_id/dispatches", + "method": "POST", + "path_fields": [ + "owner_repo", + "workflow_id" + ], + "query_fields": [ + "cursor", + "expand", + "inputs", + "limit", + "offset", + "page", + "per_page", + "ref", + "triggered", + "workflow_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "cancel_workflow_run_e3a4683e", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/actions/runs/:run_id/cancel", + "method": "POST", + "path_fields": [ + "owner_repo", + "run_id" + ], + "query_fields": [ + "cancelled", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "run_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "rerun_workflow_run_a988216f", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/actions/runs/:run_id/rerun", + "method": "POST", + "path_fields": [ + "owner_repo", + "run_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "rerun", + "run_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_github", + "operation": "get_workflow_run_logs_url_8b7206ca", + "kind": "api_key", + "endpoint": "https://api.github.com/repos/:owner_repo/actions/runs/:run_id/logs", + "method": "GET", + "path_fields": [ + "owner_repo", + "run_id" + ], + "query_fields": [ + "cursor", + "details", + "error", + "expand", + "limit", + "logs_url", + "offset", + "ok", + "page", + "per_page", + "result" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "refresh_access_token_70ce783c", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/oauth/refresh", + "method": "POST", + "path_fields": [], + "query_fields": [ + "Authorization", + "access_token", + "cursor", + "error", + "expand", + "limit", + "offset", + "ok", + "page", + "per_page", + "refresh_token", + "result", + "token_expiry" + ], + "envelope": true, + "request_format": "form", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "get_current_user_3e81c497", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/me", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "get_file_d35bb4df", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/files/:file_key", + "method": "GET", + "path_fields": [ + "file_key" + ], + "query_fields": [ + "branch_data", + "cursor", + "depth", + "expand", + "limit", + "offset", + "page", + "per_page", + "version" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "get_file_metadata_c4837f9c", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/files/:file_key/meta", + "method": "GET", + "path_fields": [ + "file_key" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "get_nodes_c7c52c73", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/files/:file_key/nodes", + "method": "GET", + "path_fields": [ + "file_key" + ], + "query_fields": [ + "cursor", + "depth", + "expand", + "ids", + "limit", + "offset", + "page", + "per_page", + "version" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "list_versions_051c83b6", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/files/:file_key/versions", + "method": "GET", + "path_fields": [ + "file_key" + ], + "query_fields": [ + "after", + "before", + "cursor", + "expand", + "limit", + "offset", + "page", + "page_size", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "export_images_d3cb7888", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/images/:file_key", + "method": "GET", + "path_fields": [ + "file_key" + ], + "query_fields": [ + "cursor", + "err", + "error", + "expand", + "failed_node_ids", + "file_key", + "format", + "ids", + "limit", + "offset", + "page", + "per_page", + "result", + "scale", + "temporary_urls", + "version" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "get_image_fills_32f1934d", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/files/:file_key/images", + "method": "GET", + "path_fields": [ + "file_key" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "list_comments_42e1c4ae", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/files/:file_key/comments", + "method": "GET", + "path_fields": [ + "file_key" + ], + "query_fields": [ + "as_md", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "post_comment_dcfcd610", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/files/:file_key/comments", + "method": "POST", + "path_fields": [ + "file_key" + ], + "query_fields": [ + "client_meta", + "comment_id", + "cursor", + "expand", + "limit", + "message", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "delete_comment_3f76bcbf", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/files/:file_key/comments/:comment_id", + "method": "DELETE", + "path_fields": [ + "file_key", + "comment_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "list_comment_reactions_280e6c1d", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/files/:file_key/comments/:comment_id/reactions", + "method": "GET", + "path_fields": [ + "file_key", + "comment_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "add_comment_reaction_93a31a97", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/files/:file_key/comments/:comment_id/reactions", + "method": "POST", + "path_fields": [ + "file_key", + "comment_id" + ], + "query_fields": [ + "cursor", + "emoji", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "delete_comment_reaction_9ef06212", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/files/:file_key/comments/:comment_id/reactions", + "method": "DELETE", + "path_fields": [ + "file_key", + "comment_id" + ], + "query_fields": [ + "cursor", + "emoji", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "list_team_folders_fc7a78a8", + "kind": "api_key", + "endpoint": "https://api.figma.com/v2/teams/:team_id/folders", + "method": "GET", + "path_fields": [ + "team_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "list_subfolders_036da9be", + "kind": "api_key", + "endpoint": "https://api.figma.com/v2/folders/:folder_id/folders", + "method": "GET", + "path_fields": [ + "folder_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "list_folder_files_15a54bcb", + "kind": "api_key", + "endpoint": "https://api.figma.com/v2/folders/:folder_id/files", + "method": "GET", + "path_fields": [ + "folder_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "get_folder_metadata_6b8ab416", + "kind": "api_key", + "endpoint": "https://api.figma.com/v2/folders/:folder_id/meta", + "method": "GET", + "path_fields": [ + "folder_id" + ], + "query_fields": [ + "cursor", + "expand", + "id", + "limit", + "name", + "offset", + "ok", + "page", + "partial_metadata", + "per_page", + "result", + "source", + "unavailable_fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "get_component_a0c6d9e9", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/components/:component_key", + "method": "GET", + "path_fields": [ + "component_key" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "get_component_set_8821a5c9", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/component_sets/:component_set_key", + "method": "GET", + "path_fields": [ + "component_set_key" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "get_style_921d62c0", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/styles/:style_key", + "method": "GET", + "path_fields": [ + "style_key" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "" + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "start_listening_42a58242", + "kind": "api_key", + "endpoint": "https://slack.com/api/auth.test", + "method": "POST", + "path_fields": [], + "query_fields": [ + "Authorization", + "cursor", + "error", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "get_joined_channels_a4e146c1", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.list", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "exclude_archived", + "expand", + "limit", + "offset", + "page", + "per_page", + "types" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "poll_channels_07d959df", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.history", + "method": "GET", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "oldest", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "send_message_35d1a464", + "kind": "api_key", + "endpoint": "https://slack.com/api/chat.postMessage", + "method": "POST", + "path_fields": [], + "query_fields": [ + "blocks", + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "text", + "thread_ts" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "get_channel_info_b4ec37c2", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.info", + "method": "GET", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "create_channel_84493189", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.create", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "is_private", + "limit", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "invite_to_channel_4c2a3e21", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.invite", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "users" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "list_users_98956b9e", + "kind": "api_key", + "endpoint": "https://slack.com/api/users.list", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "get_user_info_a2501367", + "kind": "api_key", + "endpoint": "https://slack.com/api/users.info", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "user" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "open_dm_3c1b987f", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.open", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "users" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "search_messages_0d9b0142", + "kind": "api_key", + "endpoint": "https://slack.com/api/search.messages", + "method": "GET", + "path_fields": [], + "query_fields": [ + "count", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "query", + "sort", + "sort_dir" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "upload_file_c3cdfcfd", + "kind": "api_key", + "endpoint": "https://slack.com/api/files.upload", + "method": "POST", + "path_fields": [], + "query_fields": [ + "Authorization", + "channels", + "content", + "cursor", + "expand", + "file", + "filename", + "initial_comment", + "limit", + "offset", + "page", + "per_page", + "title" + ], + "envelope": true, + "request_format": "multipart", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "update_message_da5a5422", + "kind": "api_key", + "endpoint": "https://slack.com/api/chat.update", + "method": "POST", + "path_fields": [], + "query_fields": [ + "attachments", + "blocks", + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "text", + "ts" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "delete_message_b3f25ac4", + "kind": "api_key", + "endpoint": "https://slack.com/api/chat.delete", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "ts" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "post_ephemeral_5bfc0c5d", + "kind": "api_key", + "endpoint": "https://slack.com/api/chat.postEphemeral", + "method": "POST", + "path_fields": [], + "query_fields": [ + "blocks", + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "text", + "thread_ts", + "user" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "schedule_message_e6cbd87b", + "kind": "api_key", + "endpoint": "https://slack.com/api/chat.scheduleMessage", + "method": "POST", + "path_fields": [], + "query_fields": [ + "blocks", + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "post_at", + "text", + "thread_ts" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "delete_scheduled_message_8755f676", + "kind": "api_key", + "endpoint": "https://slack.com/api/chat.deleteScheduledMessage", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "scheduled_message_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "list_scheduled_messages_449b847a", + "kind": "api_key", + "endpoint": "https://slack.com/api/chat.scheduledMessages.list", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "get_permalink_7e48efee", + "kind": "api_key", + "endpoint": "https://slack.com/api/chat.getPermalink", + "method": "GET", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "message_ts", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "get_thread_replies_857349ea", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.replies", + "method": "GET", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "ts" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "add_reaction_e8d53091", + "kind": "api_key", + "endpoint": "https://slack.com/api/reactions.add", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "name", + "offset", + "page", + "per_page", + "timestamp" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "remove_reaction_994973cb", + "kind": "api_key", + "endpoint": "https://slack.com/api/reactions.remove", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "name", + "offset", + "page", + "per_page", + "timestamp" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "get_reactions_48f1ab67", + "kind": "api_key", + "endpoint": "https://slack.com/api/reactions.get", + "method": "GET", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "full", + "limit", + "offset", + "page", + "per_page", + "timestamp" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "list_user_reactions_f8f0b196", + "kind": "api_key", + "endpoint": "https://slack.com/api/reactions.list", + "method": "GET", + "path_fields": [], + "query_fields": [ + "count", + "cursor", + "expand", + "full", + "limit", + "offset", + "page", + "per_page", + "user" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "pin_message_f99b1439", + "kind": "api_key", + "endpoint": "https://slack.com/api/pins.add", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "timestamp" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "unpin_message_fafebb32", + "kind": "api_key", + "endpoint": "https://slack.com/api/pins.remove", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "timestamp" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "list_pins_c406c33c", + "kind": "api_key", + "endpoint": "https://slack.com/api/pins.list", + "method": "GET", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "archive_channel_b836ed56", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.archive", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "unarchive_channel_809e8a6d", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.unarchive", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "rename_channel_df0c59b0", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.rename", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "set_channel_topic_38bd8cbe", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.setTopic", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "topic" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "set_channel_purpose_c408041d", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.setPurpose", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "purpose" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "join_channel_ef2aae67", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.join", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "leave_channel_94701cbd", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.leave", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "kick_user_eafcfebf", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.kick", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "user" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "close_conversation_77193316", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.close", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "list_channel_members_e05dbd74", + "kind": "api_key", + "endpoint": "https://slack.com/api/conversations.members", + "method": "GET", + "path_fields": [], + "query_fields": [ + "channel", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "list_files_4d6d538d", + "kind": "api_key", + "endpoint": "https://slack.com/api/files.list", + "method": "GET", + "path_fields": [], + "query_fields": [ + "channel", + "count", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "types", + "user" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "get_file_info_2bc0453a", + "kind": "api_key", + "endpoint": "https://slack.com/api/files.info", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "file", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "delete_file_189297ba", + "kind": "api_key", + "endpoint": "https://slack.com/api/files.delete", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "file", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "get_upload_url_external_9d79a0c3", + "kind": "api_key", + "endpoint": "https://slack.com/api/files.getUploadURLExternal", + "method": "GET", + "path_fields": [], + "query_fields": [ + "alt_txt", + "cursor", + "expand", + "filename", + "length", + "limit", + "offset", + "page", + "per_page", + "snippet_type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "complete_upload_external_23626e03", + "kind": "api_key", + "endpoint": "https://slack.com/api/files.completeUploadExternal", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel_id", + "cursor", + "expand", + "files", + "initial_comment", + "limit", + "offset", + "page", + "per_page", + "thread_ts" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "upload_file_v2_0452d01e", + "kind": "api_key", + "endpoint": "https://slack.com/api/:upload_url", + "method": "POST", + "path_fields": [ + "upload_url" + ], + "query_fields": [ + "cursor", + "details", + "error", + "expand", + "id", + "limit", + "offset", + "page", + "per_page", + "title" + ], + "envelope": true, + "request_format": "image", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "get_user_presence_ba0553a3", + "kind": "api_key", + "endpoint": "https://slack.com/api/users.getPresence", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "user" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "set_user_presence_1f9f8c79", + "kind": "api_key", + "endpoint": "https://slack.com/api/users.setPresence", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "presence" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "lookup_user_by_email_782979ce", + "kind": "api_key", + "endpoint": "https://slack.com/api/users.lookupByEmail", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "email", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "list_usergroups_1ea23eba", + "kind": "api_key", + "endpoint": "https://slack.com/api/usergroups.list", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "include_count", + "include_disabled", + "include_users", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "create_usergroup_cae0644f", + "kind": "api_key", + "endpoint": "https://slack.com/api/usergroups.create", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channels", + "cursor", + "description", + "expand", + "handle", + "limit", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "update_usergroup_45263337", + "kind": "api_key", + "endpoint": "https://slack.com/api/usergroups.update", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channels", + "cursor", + "description", + "expand", + "handle", + "limit", + "name", + "offset", + "page", + "per_page", + "usergroup" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "enable_usergroup_0a57e39f", + "kind": "api_key", + "endpoint": "https://slack.com/api/usergroups.enable", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "usergroup" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "disable_usergroup_4b4e5ee6", + "kind": "api_key", + "endpoint": "https://slack.com/api/usergroups.disable", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "usergroup" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "list_usergroup_users_f971f7d9", + "kind": "api_key", + "endpoint": "https://slack.com/api/usergroups.users.list", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "include_disabled", + "limit", + "offset", + "page", + "per_page", + "usergroup" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "update_usergroup_users_3eb1b1c3", + "kind": "api_key", + "endpoint": "https://slack.com/api/usergroups.users.update", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "usergroup", + "users" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "get_team_info_3559c3b0", + "kind": "api_key", + "endpoint": "https://slack.com/api/team.info", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "team" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "list_bookmarks_944b81e8", + "kind": "api_key", + "endpoint": "https://slack.com/api/bookmarks.list", + "method": "GET", + "path_fields": [], + "query_fields": [ + "channel_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "add_bookmark_245fb15b", + "kind": "api_key", + "endpoint": "https://slack.com/api/bookmarks.add", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel_id", + "cursor", + "emoji", + "entity_id", + "expand", + "limit", + "link", + "offset", + "page", + "per_page", + "title", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "edit_bookmark_b37b5ba8", + "kind": "api_key", + "endpoint": "https://slack.com/api/bookmarks.edit", + "method": "POST", + "path_fields": [], + "query_fields": [ + "bookmark_id", + "channel_id", + "cursor", + "emoji", + "expand", + "limit", + "link", + "offset", + "page", + "per_page", + "title" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "remove_bookmark_5b83b5e8", + "kind": "api_key", + "endpoint": "https://slack.com/api/bookmarks.remove", + "method": "POST", + "path_fields": [], + "query_fields": [ + "bookmark_id", + "channel_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "add_reminder_3028d6ef", + "kind": "api_key", + "endpoint": "https://slack.com/api/reminders.add", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "text", + "time", + "user" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "list_reminders_452bd27c", + "kind": "api_key", + "endpoint": "https://slack.com/api/reminders.list", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "complete_reminder_29492cb2", + "kind": "api_key", + "endpoint": "https://slack.com/api/reminders.complete", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "reminder" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "delete_reminder_bf9572f8", + "kind": "api_key", + "endpoint": "https://slack.com/api/reminders.delete", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "reminder" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "get_reminder_info_defa4d7e", + "kind": "api_key", + "endpoint": "https://slack.com/api/reminders.info", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "reminder" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "get_bot_user_31bc71a9", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/users/@me", + "method": "GET", + "path_fields": [], + "query_fields": [ + "avatar", + "bot", + "cursor", + "discriminator", + "expand", + "id", + "limit", + "offset", + "page", + "per_page", + "username" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "get_bot_guilds_641b7f8d", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/users/@me/guilds", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "guilds", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "get_guild_channels_1fdf6551", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/channels", + "method": "GET", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "all_channels", + "categories", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "text_channels", + "voice_channels" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "get_guild_roles_b949bb7a", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/roles", + "method": "GET", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "roles" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "get_channel_a1e4326b", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id", + "method": "GET", + "path_fields": [ + "channel_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "bot_send_message_61c1a386", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/messages", + "method": "POST", + "path_fields": [ + "channel_id" + ], + "query_fields": [ + "channel_id", + "content", + "cursor", + "embeds", + "expand", + "limit", + "message_id", + "message_reference", + "offset", + "page", + "per_page", + "timestamp" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "get_messages_c5127832", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/messages", + "method": "GET", + "path_fields": [ + "channel_id" + ], + "query_fields": [ + "after", + "attachments", + "author", + "before", + "bot", + "content", + "count", + "cursor", + "embeds", + "expand", + "id", + "limit", + "messages", + "offset", + "page", + "per_page", + "timestamp", + "username" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "edit_message_768204b5", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/messages/:message_id", + "method": "PATCH", + "path_fields": [ + "channel_id", + "message_id" + ], + "query_fields": [ + "content", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "delete_message_4c62e2ee", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/messages/:message_id", + "method": "DELETE", + "path_fields": [ + "channel_id", + "message_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "create_dm_channel_9809f752", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/users/@me/channels", + "method": "POST", + "path_fields": [], + "query_fields": [ + "channel_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "recipient_id", + "recipients", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "get_user_0f789588", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/users/:user_id", + "method": "GET", + "path_fields": [ + "user_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "get_guild_member_059b655b", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/members/:user_id", + "method": "GET", + "path_fields": [ + "guild_id", + "user_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "list_guild_members_61ec5dd5", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/members", + "method": "GET", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "members", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "add_reaction_7a3bfaf9", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/messages/:message_id/reactions/:encoded_emoji/@me", + "method": "PUT", + "path_fields": [ + "channel_id", + "message_id", + "encoded_emoji" + ], + "query_fields": [ + "added", + "cursor", + "emoji", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "user_get_dm_channels_f8314c3a", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/users/@me/channels", + "method": "GET", + "path_fields": [], + "query_fields": [ + "count", + "cursor", + "dm_channels", + "expand", + "id", + "last_message_id", + "limit", + "offset", + "page", + "per_page", + "recipients", + "type", + "username" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "user_get_relationships_464c5c1f", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/users/@me/relationships", + "method": "GET", + "path_fields": [], + "query_fields": [ + "blocked", + "cursor", + "expand", + "friends", + "id", + "incoming_requests", + "limit", + "offset", + "outgoing_requests", + "page", + "per_page", + "total_friends", + "username" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "user_search_guild_messages_dee61cc7", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/messages/search", + "method": "GET", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "content", + "cursor", + "expand", + "limit", + "messages", + "offset", + "page", + "per_page", + "total_results" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "bulk_delete_messages_dea032c7", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/messages/bulk-delete", + "method": "POST", + "path_fields": [ + "channel_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "messages", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "crosspost_message_9cde24a7", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/messages/:message_id/crosspost", + "method": "POST", + "path_fields": [ + "channel_id", + "message_id" + ], + "query_fields": [ + "cursor", + "expand", + "flags", + "id", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "pin_message_c8ea9c46", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/pins/:message_id", + "method": "PUT", + "path_fields": [ + "channel_id", + "message_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "message_id", + "offset", + "page", + "per_page", + "pinned" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "unpin_message_93ec17d0", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/pins/:message_id", + "method": "DELETE", + "path_fields": [ + "channel_id", + "message_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "message_id", + "offset", + "page", + "per_page", + "unpinned" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "list_pinned_messages_d4a76a2b", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/pins", + "method": "GET", + "path_fields": [ + "channel_id" + ], + "query_fields": [ + "count", + "cursor", + "expand", + "limit", + "messages", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "remove_user_reaction_cb00ae6b", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/messages/:message_id/reactions/:encoded/:user_id", + "method": "DELETE", + "path_fields": [ + "channel_id", + "message_id", + "encoded", + "user_id" + ], + "query_fields": [ + "cursor", + "emoji", + "expand", + "limit", + "offset", + "page", + "per_page", + "removed", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "remove_own_reaction_9e003d0f", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/messages/:message_id/reactions/:encoded/@me", + "method": "DELETE", + "path_fields": [ + "channel_id", + "message_id", + "encoded" + ], + "query_fields": [ + "cursor", + "emoji", + "expand", + "limit", + "offset", + "page", + "per_page", + "removed" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "list_reaction_users_3a5dd06c", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/messages/:message_id/reactions/:encoded", + "method": "GET", + "path_fields": [ + "channel_id", + "message_id", + "encoded" + ], + "query_fields": [ + "count", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "users" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "clear_reactions_499bbe1c", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/messages/:message_id/reactions", + "method": "DELETE", + "path_fields": [ + "channel_id", + "message_id" + ], + "query_fields": [ + "cleared", + "cursor", + "emoji", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "create_thread_from_message_dca4dc3a", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/messages/:message_id/threads", + "method": "POST", + "path_fields": [ + "channel_id", + "message_id" + ], + "query_fields": [ + "auto_archive_duration", + "cursor", + "expand", + "limit", + "name", + "offset", + "page", + "parent_id", + "per_page", + "thread_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "create_thread_172cde7a", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/threads", + "method": "POST", + "path_fields": [ + "channel_id" + ], + "query_fields": [ + "auto_archive_duration", + "cursor", + "expand", + "invitable", + "limit", + "name", + "offset", + "page", + "parent_id", + "per_page", + "rate_limit_per_user", + "thread_id", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "join_thread_87c4b3dc", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:thread_id/thread-members/@me", + "method": "PUT", + "path_fields": [ + "thread_id" + ], + "query_fields": [ + "cursor", + "expand", + "joined", + "limit", + "offset", + "page", + "per_page", + "thread_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "leave_thread_ad2c07b6", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:thread_id/thread-members/@me", + "method": "DELETE", + "path_fields": [ + "thread_id" + ], + "query_fields": [ + "cursor", + "expand", + "left", + "limit", + "offset", + "page", + "per_page", + "thread_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "add_thread_member_ebe119b1", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:thread_id/thread-members/:user_id", + "method": "PUT", + "path_fields": [ + "thread_id", + "user_id" + ], + "query_fields": [ + "added", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "remove_thread_member_14b44b7f", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:thread_id/thread-members/:user_id", + "method": "DELETE", + "path_fields": [ + "thread_id", + "user_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "removed", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "list_thread_members_f206de15", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:thread_id/thread-members", + "method": "GET", + "path_fields": [ + "thread_id" + ], + "query_fields": [ + "count", + "cursor", + "expand", + "limit", + "members", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "list_active_threads_49d9b1c0", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/threads/active", + "method": "GET", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "members", + "offset", + "page", + "per_page", + "threads" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "archive_thread_34068e86", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:thread_id", + "method": "PATCH", + "path_fields": [ + "thread_id" + ], + "query_fields": [ + "archived", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "thread_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "create_guild_channel_ccc9e5d9", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/channels", + "method": "POST", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "bitrate", + "channel_id", + "cursor", + "expand", + "limit", + "name", + "nsfw", + "offset", + "page", + "parent_id", + "per_page", + "permission_overwrites", + "position", + "rate_limit_per_user", + "topic", + "type", + "user_limit" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "modify_channel_c768423f", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id", + "method": "PATCH", + "path_fields": [ + "channel_id" + ], + "query_fields": [ + "archived", + "bitrate", + "channel_id", + "cursor", + "expand", + "limit", + "locked", + "name", + "nsfw", + "offset", + "page", + "parent_id", + "per_page", + "position", + "rate_limit_per_user", + "topic", + "user_limit" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "delete_channel_9112e39c", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id", + "method": "DELETE", + "path_fields": [ + "channel_id" + ], + "query_fields": [ + "channel_id", + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "edit_channel_permissions_801e731d", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/permissions/:overwrite_id", + "method": "PUT", + "path_fields": [ + "channel_id", + "overwrite_id" + ], + "query_fields": [ + "allow", + "cursor", + "deny", + "expand", + "limit", + "offset", + "overwrite_id", + "page", + "per_page", + "type", + "updated" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "delete_channel_permission_34b76548", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/permissions/:overwrite_id", + "method": "DELETE", + "path_fields": [ + "channel_id", + "overwrite_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "offset", + "overwrite_id", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "list_channel_invites_8c2b8fd9", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/invites", + "method": "GET", + "path_fields": [ + "channel_id" + ], + "query_fields": [ + "count", + "cursor", + "expand", + "invites", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "create_channel_invite_d40ecda2", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/invites", + "method": "POST", + "path_fields": [ + "channel_id" + ], + "query_fields": [ + "code", + "cursor", + "expand", + "limit", + "max_age", + "max_uses", + "offset", + "page", + "per_page", + "temporary", + "unique", + "url" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "delete_invite_b3cee9bf", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/invites/:invite_code", + "method": "DELETE", + "path_fields": [ + "invite_code" + ], + "query_fields": [ + "code", + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "list_channel_webhooks_499cc0cf", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/webhooks", + "method": "GET", + "path_fields": [ + "channel_id" + ], + "query_fields": [ + "count", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "webhooks" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "create_webhook_af7b2c28", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/channels/:channel_id/webhooks", + "method": "POST", + "path_fields": [ + "channel_id" + ], + "query_fields": [ + "avatar", + "cursor", + "expand", + "id", + "limit", + "name", + "offset", + "page", + "per_page", + "token", + "url" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "get_webhook_b4cf1d2f", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/webhooks/:webhook_id", + "method": "GET", + "path_fields": [ + "webhook_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "modify_webhook_6fc3b226", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/webhooks/:webhook_id", + "method": "PATCH", + "path_fields": [ + "webhook_id" + ], + "query_fields": [ + "avatar", + "channel_id", + "cursor", + "expand", + "id", + "limit", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "delete_webhook_f6384c78", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/webhooks/:webhook_id", + "method": "DELETE", + "path_fields": [ + "webhook_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page", + "webhook_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "execute_webhook_35fe80b9", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/webhooks/:webhook_id/:webhook_token", + "method": "POST", + "path_fields": [ + "webhook_id", + "webhook_token" + ], + "query_fields": [ + "avatar_url", + "content", + "cursor", + "embeds", + "expand", + "limit", + "message_id", + "offset", + "page", + "per_page", + "sent", + "username", + "wait" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "modify_guild_member_3774841d", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/members/:user_id", + "method": "PATCH", + "path_fields": [ + "guild_id", + "user_id" + ], + "query_fields": [ + "channel_id", + "communication_disabled_until", + "cursor", + "deaf", + "expand", + "limit", + "mute", + "nick", + "offset", + "page", + "per_page", + "roles" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "modify_current_member_nick_191003a3", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/members/@me", + "method": "PATCH", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "nick", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "add_guild_member_role_88ce086a", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/members/:user_id/roles/:role_id", + "method": "PUT", + "path_fields": [ + "guild_id", + "user_id", + "role_id" + ], + "query_fields": [ + "added", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "role_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "remove_guild_member_role_74c20f75", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/members/:user_id/roles/:role_id", + "method": "DELETE", + "path_fields": [ + "guild_id", + "user_id", + "role_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "removed", + "role_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "kick_guild_member_81a617f7", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/members/:user_id", + "method": "DELETE", + "path_fields": [ + "guild_id", + "user_id" + ], + "query_fields": [ + "cursor", + "expand", + "kicked", + "limit", + "offset", + "page", + "per_page", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "ban_guild_member_5b93bf3c", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/bans/:user_id", + "method": "PUT", + "path_fields": [ + "guild_id", + "user_id" + ], + "query_fields": [ + "banned", + "cursor", + "delete_message_seconds", + "expand", + "limit", + "offset", + "page", + "per_page", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "unban_guild_member_755deade", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/bans/:user_id", + "method": "DELETE", + "path_fields": [ + "guild_id", + "user_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "unbanned", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "list_guild_bans_4c44c708", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/bans", + "method": "GET", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "bans", + "count", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "search_guild_members_f0d6a357", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/members/search", + "method": "GET", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "count", + "cursor", + "expand", + "limit", + "members", + "offset", + "page", + "per_page", + "query" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "get_guild_0e83f2d8", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id", + "method": "GET", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "create_guild_role_3933ca1a", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/roles", + "method": "POST", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "color", + "cursor", + "expand", + "hoist", + "limit", + "mentionable", + "name", + "offset", + "page", + "per_page", + "permissions", + "role_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "modify_guild_role_8b2aa477", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/roles/:role_id", + "method": "PATCH", + "path_fields": [ + "guild_id", + "role_id" + ], + "query_fields": [ + "color", + "cursor", + "expand", + "hoist", + "limit", + "mentionable", + "name", + "offset", + "page", + "per_page", + "permissions" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "delete_guild_role_2c0df0e8", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/roles/:role_id", + "method": "DELETE", + "path_fields": [ + "guild_id", + "role_id" + ], + "query_fields": [ + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page", + "role_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "list_guild_emojis_8a66da4c", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/emojis", + "method": "GET", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "count", + "cursor", + "emojis", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "create_guild_emoji_621fdbf4", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/emojis", + "method": "POST", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "cursor", + "expand", + "id", + "image", + "limit", + "name", + "offset", + "page", + "per_page", + "roles" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "delete_guild_emoji_755e7f8e", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/emojis/:emoji_id", + "method": "DELETE", + "path_fields": [ + "guild_id", + "emoji_id" + ], + "query_fields": [ + "cursor", + "deleted", + "emoji_id", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "list_guild_stickers_2c2f7105", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/stickers", + "method": "GET", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "count", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "stickers" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "list_scheduled_events_76852609", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/scheduled-events", + "method": "GET", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "count", + "cursor", + "events", + "expand", + "limit", + "offset", + "page", + "per_page", + "with_user_count" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "create_scheduled_event_c9d6d7c7", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/scheduled-events", + "method": "POST", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "channel_id", + "cursor", + "description", + "entity_metadata", + "entity_type", + "event_id", + "expand", + "limit", + "name", + "offset", + "page", + "per_page", + "privacy_level", + "scheduled_end_time", + "scheduled_start_time" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "modify_scheduled_event_1b19f27b", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/scheduled-events/:event_id", + "method": "PATCH", + "path_fields": [ + "guild_id", + "event_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "delete_scheduled_event_3566bca1", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/scheduled-events/:event_id", + "method": "DELETE", + "path_fields": [ + "guild_id", + "event_id" + ], + "query_fields": [ + "cursor", + "deleted", + "event_id", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "get_audit_log_d9c3a170", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/audit-logs", + "method": "GET", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "action_type", + "audit_log_entries", + "before", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "user_id", + "users", + "webhooks" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_discord", + "operation": "list_guild_invites_4b742ce1", + "kind": "api_key", + "endpoint": "https://discord.com/api/v10/guilds/:guild_id/invites", + "method": "GET", + "path_fields": [ + "guild_id" + ], + "query_fields": [ + "count", + "cursor", + "expand", + "invites", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bot " + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_account_84e8fd55", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/account", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_customers_10f3961b", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/customers", + "method": "GET", + "path_fields": [], + "query_fields": [ + "created", + "cursor", + "email", + "expand", + "gte", + "limit", + "lte", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_customer_10eab417", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/customers/:customer_id", + "method": "GET", + "path_fields": [ + "customer_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_customer_dc46cdaa", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/customers", + "method": "POST", + "path_fields": [], + "query_fields": [ + "address", + "cursor", + "description", + "email", + "expand", + "invoice_settings", + "limit", + "metadata", + "name", + "offset", + "page", + "payment_method", + "per_page", + "phone", + "shipping", + "tax_exempt" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "update_customer_d64b2ab3", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/customers/:customer_id", + "method": "POST", + "path_fields": [ + "customer_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "delete_customer_90055825", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/customers/:customer_id", + "method": "DELETE", + "path_fields": [ + "customer_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "search_customers_b46dd236", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/customers/search", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "query" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_payment_intents_ebf4f40a", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_intents", + "method": "GET", + "path_fields": [], + "query_fields": [ + "created", + "cursor", + "customer", + "expand", + "gte", + "limit", + "lte", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_payment_intent_3685e177", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_intents/:payment_intent_id", + "method": "GET", + "path_fields": [ + "payment_intent_id" + ], + "query_fields": [ + "client_secret", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_payment_intent_8db23b74", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_intents", + "method": "POST", + "path_fields": [], + "query_fields": [ + "amount", + "application_fee_amount", + "automatic_payment_methods", + "capture_method", + "confirm", + "confirmation_method", + "currency", + "cursor", + "customer", + "description", + "enabled", + "expand", + "limit", + "metadata", + "offset", + "on_behalf_of", + "page", + "payment_method", + "payment_method_types", + "per_page", + "receipt_email", + "setup_future_usage", + "statement_descriptor", + "statement_descriptor_suffix", + "transfer_data" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "update_payment_intent_1a0f10ab", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_intents/:payment_intent_id", + "method": "POST", + "path_fields": [ + "payment_intent_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "confirm_payment_intent_2b09624c", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_intents/:payment_intent_id/confirm", + "method": "POST", + "path_fields": [ + "payment_intent_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "off_session", + "offset", + "page", + "payment_method", + "per_page", + "receipt_email", + "return_url", + "setup_future_usage" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "capture_payment_intent_4b51563b", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_intents/:payment_intent_id/capture", + "method": "POST", + "path_fields": [ + "payment_intent_id" + ], + "query_fields": [ + "amount_to_capture", + "application_fee_amount", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "statement_descriptor" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "cancel_payment_intent_70afe17b", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_intents/:payment_intent_id/cancel", + "method": "POST", + "path_fields": [ + "payment_intent_id" + ], + "query_fields": [ + "cancellation_reason", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "search_payment_intents_135fd964", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_intents/search", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "query" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_charges_3167acbb", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/charges", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "customer", + "expand", + "limit", + "offset", + "page", + "payment_intent", + "per_page", + "transfer_group" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_charge_d1bff1b2", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/charges/:charge_id", + "method": "GET", + "path_fields": [ + "charge_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_refund_4fdff6eb", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/refunds", + "method": "POST", + "path_fields": [], + "query_fields": [ + "amount", + "charge", + "cursor", + "error", + "expand", + "limit", + "metadata", + "offset", + "page", + "payment_intent", + "per_page", + "reason", + "refund_application_fee", + "reverse_transfer" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_refund_c7458149", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/refunds/:refund_id", + "method": "GET", + "path_fields": [ + "refund_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_refunds_40e42e38", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/refunds", + "method": "GET", + "path_fields": [], + "query_fields": [ + "charge", + "cursor", + "expand", + "limit", + "offset", + "page", + "payment_intent", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_payment_methods_64b0e69a", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_methods", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "customer", + "expand", + "limit", + "offset", + "page", + "per_page", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_payment_method_86a271ed", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_methods/:payment_method_id", + "method": "GET", + "path_fields": [ + "payment_method_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "attach_payment_method_bcc9e4d5", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_methods/:payment_method_id/attach", + "method": "POST", + "path_fields": [ + "payment_method_id" + ], + "query_fields": [ + "cursor", + "customer", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "detach_payment_method_98944a3c", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_methods/:payment_method_id/detach", + "method": "POST", + "path_fields": [ + "payment_method_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "update_payment_method_d35338c5", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_methods/:payment_method_id", + "method": "POST", + "path_fields": [ + "payment_method_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_products_8a6aa928", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/products", + "method": "GET", + "path_fields": [], + "query_fields": [ + "active", + "cursor", + "expand", + "ids", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_product_5b5bba6e", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/products/:product_id", + "method": "GET", + "path_fields": [ + "product_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_product_0979c970", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/products", + "method": "POST", + "path_fields": [], + "query_fields": [ + "active", + "cursor", + "default_price_data", + "description", + "expand", + "images", + "limit", + "metadata", + "name", + "offset", + "page", + "per_page", + "shippable", + "statement_descriptor", + "tax_code", + "unit_label", + "url" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "update_product_22bf552a", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/products/:product_id", + "method": "POST", + "path_fields": [ + "product_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "delete_product_4b0b124d", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/products/:product_id", + "method": "DELETE", + "path_fields": [ + "product_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_prices_f2c96d3e", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/prices", + "method": "GET", + "path_fields": [], + "query_fields": [ + "active", + "currency", + "cursor", + "expand", + "interval", + "limit", + "offset", + "page", + "per_page", + "product", + "recurring", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_price_0175f6e1", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/prices/:price_id", + "method": "GET", + "path_fields": [ + "price_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_price_44656a9d", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/prices", + "method": "POST", + "path_fields": [], + "query_fields": [ + "active", + "billing_scheme", + "currency", + "cursor", + "expand", + "limit", + "metadata", + "nickname", + "offset", + "page", + "per_page", + "product", + "product_data", + "recurring", + "tax_behavior", + "tiers", + "tiers_mode", + "transform_quantity", + "unit_amount", + "unit_amount_decimal" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "update_price_3ffb372b", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/prices/:price_id", + "method": "POST", + "path_fields": [ + "price_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_invoices_e976f883", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/invoices", + "method": "GET", + "path_fields": [], + "query_fields": [ + "collection_method", + "created", + "cursor", + "customer", + "expand", + "gte", + "limit", + "lte", + "offset", + "page", + "per_page", + "status", + "subscription" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_invoice_5cc8b180", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/invoices/:invoice_id", + "method": "GET", + "path_fields": [ + "invoice_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_invoice_ee87e2e8", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/invoices", + "method": "POST", + "path_fields": [], + "query_fields": [ + "auto_advance", + "collection_method", + "cursor", + "customer", + "days_until_due", + "default_payment_method", + "default_source", + "description", + "due_date", + "expand", + "footer", + "from_invoice", + "limit", + "metadata", + "offset", + "page", + "pending_invoice_items_behavior", + "per_page", + "statement_descriptor", + "subscription" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "update_invoice_7c4ea08c", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/invoices/:invoice_id", + "method": "POST", + "path_fields": [ + "invoice_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "delete_invoice_10f17e17", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/invoices/:invoice_id", + "method": "DELETE", + "path_fields": [ + "invoice_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "finalize_invoice_54197d52", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/invoices/:invoice_id/finalize", + "method": "POST", + "path_fields": [ + "invoice_id" + ], + "query_fields": [ + "auto_advance", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "send_invoice_c8d043d9", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/invoices/:invoice_id/send", + "method": "POST", + "path_fields": [ + "invoice_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "pay_invoice_7c12be29", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/invoices/:invoice_id/pay", + "method": "POST", + "path_fields": [ + "invoice_id" + ], + "query_fields": [ + "cursor", + "expand", + "forgive", + "limit", + "off_session", + "offset", + "page", + "paid_out_of_band", + "payment_method", + "per_page", + "source" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "void_invoice_4159348f", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/invoices/:invoice_id/void", + "method": "POST", + "path_fields": [ + "invoice_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "mark_invoice_uncollectible_ee3bef38", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/invoices/:invoice_id/mark_uncollectible", + "method": "POST", + "path_fields": [ + "invoice_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_upcoming_invoice_f47b2a54", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/invoices/upcoming", + "method": "GET", + "path_fields": [], + "query_fields": [ + "coupon", + "cursor", + "customer", + "expand", + "limit", + "offset", + "page", + "per_page", + "subscription", + "subscription_items" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_invoice_items_110c1445", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/invoiceitems", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "customer", + "expand", + "invoice", + "limit", + "offset", + "page", + "pending", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_invoice_item_06840806", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/invoiceitems", + "method": "POST", + "path_fields": [], + "query_fields": [ + "amount", + "currency", + "cursor", + "customer", + "description", + "discountable", + "discounts", + "expand", + "invoice", + "limit", + "metadata", + "offset", + "page", + "per_page", + "period", + "price", + "price_data", + "quantity", + "subscription", + "tax_rates" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "delete_invoice_item_b471f6d9", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/invoiceitems/:invoice_item_id", + "method": "DELETE", + "path_fields": [ + "invoice_item_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_subscriptions_9c3a1df7", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/subscriptions", + "method": "GET", + "path_fields": [], + "query_fields": [ + "collection_method", + "cursor", + "customer", + "expand", + "limit", + "offset", + "page", + "per_page", + "price", + "status" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_subscription_d2e8bfce", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/subscriptions/:subscription_id", + "method": "GET", + "path_fields": [ + "subscription_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_subscription_e883070c", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/subscriptions", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cancel_at_period_end", + "collection_method", + "coupon", + "cursor", + "customer", + "days_until_due", + "default_payment_method", + "default_tax_rates", + "expand", + "items", + "limit", + "metadata", + "offset", + "page", + "payment_behavior", + "payment_settings", + "per_page", + "promotion_code", + "proration_behavior", + "trial_end", + "trial_period_days" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "update_subscription_5624c045", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/subscriptions/:subscription_id", + "method": "POST", + "path_fields": [ + "subscription_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "cancel_subscription_7dabfa5f", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/subscriptions/:subscription_id", + "method": "DELETE", + "path_fields": [ + "subscription_id" + ], + "query_fields": [ + "cancellation_details", + "cursor", + "expand", + "invoice_now", + "limit", + "offset", + "page", + "per_page", + "prorate" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "resume_subscription_64eee2ee", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/subscriptions/:subscription_id/resume", + "method": "POST", + "path_fields": [ + "subscription_id" + ], + "query_fields": [ + "billing_cycle_anchor", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "proration_behavior" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_coupons_36b02205", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/coupons", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_coupon_d8655874", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/coupons/:coupon_id", + "method": "GET", + "path_fields": [ + "coupon_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_coupon_67f98517", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/coupons", + "method": "POST", + "path_fields": [], + "query_fields": [ + "amount_off", + "applies_to", + "currency", + "cursor", + "duration", + "duration_in_months", + "expand", + "id", + "limit", + "max_redemptions", + "metadata", + "name", + "offset", + "page", + "per_page", + "percent_off", + "redeem_by" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "update_coupon_859b54af", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/coupons/:coupon_id", + "method": "POST", + "path_fields": [ + "coupon_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "delete_coupon_4742d866", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/coupons/:coupon_id", + "method": "DELETE", + "path_fields": [ + "coupon_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_promotion_codes_1a622590", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/promotion_codes", + "method": "GET", + "path_fields": [], + "query_fields": [ + "active", + "code", + "coupon", + "cursor", + "customer", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_promotion_code_bb668d39", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/promotion_codes", + "method": "POST", + "path_fields": [], + "query_fields": [ + "active", + "code", + "coupon", + "cursor", + "customer", + "expand", + "expires_at", + "limit", + "max_redemptions", + "metadata", + "offset", + "page", + "per_page", + "restrictions" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "update_promotion_code_c1714886", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/promotion_codes/:promotion_code_id", + "method": "POST", + "path_fields": [ + "promotion_code_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_checkout_sessions_3c1dd4ec", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/checkout/sessions", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "customer", + "expand", + "limit", + "offset", + "page", + "payment_intent", + "per_page", + "status", + "subscription" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_checkout_session_d12b2969", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/checkout/sessions/:session_id", + "method": "GET", + "path_fields": [ + "session_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_checkout_session_a7f31bd6", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/checkout/sessions", + "method": "POST", + "path_fields": [], + "query_fields": [ + "allow_promotion_codes", + "automatic_tax", + "billing_address_collection", + "cancel_url", + "client_reference_id", + "cursor", + "customer", + "customer_email", + "discounts", + "expand", + "expires_at", + "limit", + "line_items", + "locale", + "metadata", + "mode", + "offset", + "page", + "payment_intent_data", + "payment_method_types", + "per_page", + "return_url", + "shipping_address_collection", + "subscription_data", + "success_url", + "ui_mode" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "expire_checkout_session_cdf1b06d", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/checkout/sessions/:session_id/expire", + "method": "POST", + "path_fields": [ + "session_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_checkout_line_items_0dde5c28", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/checkout/sessions/:session_id/line_items", + "method": "GET", + "path_fields": [ + "session_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_payment_links_6f2ea21b", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_links", + "method": "GET", + "path_fields": [], + "query_fields": [ + "active", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_payment_link_62908382", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_links/:payment_link_id", + "method": "GET", + "path_fields": [ + "payment_link_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_payment_link_ef44e015", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_links", + "method": "POST", + "path_fields": [], + "query_fields": [ + "after_completion", + "allow_promotion_codes", + "automatic_tax", + "billing_address_collection", + "currency", + "cursor", + "customer_creation", + "expand", + "limit", + "line_items", + "metadata", + "offset", + "page", + "payment_intent_data", + "payment_method_types", + "per_page", + "shipping_address_collection", + "shipping_options", + "subscription_data", + "tax_id_collection" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "update_payment_link_82d61402", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_links/:payment_link_id", + "method": "POST", + "path_fields": [ + "payment_link_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_payment_link_line_items_6d2e6f10", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payment_links/:payment_link_id/line_items", + "method": "GET", + "path_fields": [ + "payment_link_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_billing_portal_session_e83ef2db", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/billing_portal/sessions", + "method": "POST", + "path_fields": [], + "query_fields": [ + "configuration", + "cursor", + "customer", + "expand", + "flow_data", + "limit", + "locale", + "offset", + "page", + "per_page", + "return_url" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_disputes_9daa2dfc", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/disputes", + "method": "GET", + "path_fields": [], + "query_fields": [ + "charge", + "cursor", + "expand", + "limit", + "offset", + "page", + "payment_intent", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_dispute_7c8346b3", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/disputes/:dispute_id", + "method": "GET", + "path_fields": [ + "dispute_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "update_dispute_78c2e44a", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/disputes/:dispute_id", + "method": "POST", + "path_fields": [ + "dispute_id" + ], + "query_fields": [ + "cursor", + "evidence", + "expand", + "limit", + "metadata", + "offset", + "page", + "per_page", + "submit" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "close_dispute_c26783e0", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/disputes/:dispute_id/close", + "method": "POST", + "path_fields": [ + "dispute_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_payouts_9b7c5faa", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payouts", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "destination", + "expand", + "limit", + "offset", + "page", + "per_page", + "status" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_payout_c1d16888", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payouts/:payout_id", + "method": "GET", + "path_fields": [ + "payout_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_payout_03ffd1e8", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payouts", + "method": "POST", + "path_fields": [], + "query_fields": [ + "amount", + "currency", + "cursor", + "description", + "destination", + "expand", + "limit", + "metadata", + "method", + "offset", + "page", + "per_page", + "source_type", + "statement_descriptor" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "update_payout_cf8fb0bf", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payouts/:payout_id", + "method": "POST", + "path_fields": [ + "payout_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "cancel_payout_dff30da5", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/payouts/:payout_id/cancel", + "method": "POST", + "path_fields": [ + "payout_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_balance_72fe9252", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/balance", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_balance_transactions_369e8db7", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/balance_transactions", + "method": "GET", + "path_fields": [], + "query_fields": [ + "currency", + "cursor", + "expand", + "limit", + "offset", + "page", + "payout", + "per_page", + "source", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_balance_transaction_fda7a346", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/balance_transactions/:balance_transaction_id", + "method": "GET", + "path_fields": [ + "balance_transaction_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_quotes_3805740b", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/quotes", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "customer", + "expand", + "limit", + "offset", + "page", + "per_page", + "status" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_quote_2066e71c", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/quotes/:quote_id", + "method": "GET", + "path_fields": [ + "quote_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_quote_1124143f", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/quotes", + "method": "POST", + "path_fields": [], + "query_fields": [ + "application_fee_amount", + "application_fee_percent", + "automatic_tax", + "collection_method", + "cursor", + "customer", + "default_tax_rates", + "description", + "discounts", + "expand", + "expires_at", + "footer", + "from_quote", + "header", + "invoice_settings", + "limit", + "line_items", + "metadata", + "offset", + "on_behalf_of", + "page", + "per_page", + "subscription_data", + "test_clock", + "transfer_data" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "update_quote_1d584703", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/quotes/:quote_id", + "method": "POST", + "path_fields": [ + "quote_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "finalize_quote_f913b13a", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/quotes/:quote_id/finalize", + "method": "POST", + "path_fields": [ + "quote_id" + ], + "query_fields": [ + "cursor", + "expand", + "expires_at", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "accept_quote_0b23f904", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/quotes/:quote_id/accept", + "method": "POST", + "path_fields": [ + "quote_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "cancel_quote_eb7e8b42", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/quotes/:quote_id/cancel", + "method": "POST", + "path_fields": [ + "quote_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_events_0ca422b7", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/events", + "method": "GET", + "path_fields": [], + "query_fields": [ + "created", + "cursor", + "delivery_success", + "expand", + "gte", + "limit", + "lte", + "offset", + "page", + "per_page", + "type", + "types" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_event_b308b646", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/events/:event_id", + "method": "GET", + "path_fields": [ + "event_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_webhook_endpoints_d95be9fc", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/webhook_endpoints", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_webhook_endpoint_f37d55c9", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/webhook_endpoints/:endpoint_id", + "method": "GET", + "path_fields": [ + "endpoint_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "create_webhook_endpoint_eb6ed43b", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/webhook_endpoints", + "method": "POST", + "path_fields": [], + "query_fields": [ + "api_version", + "connect", + "cursor", + "description", + "enabled_events", + "expand", + "limit", + "metadata", + "offset", + "page", + "per_page", + "url" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "update_webhook_endpoint_d41e5629", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/webhook_endpoints/:endpoint_id", + "method": "POST", + "path_fields": [ + "endpoint_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "delete_webhook_endpoint_ecccb301", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/webhook_endpoints/:endpoint_id", + "method": "DELETE", + "path_fields": [ + "endpoint_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "upload_file_ef821083", + "kind": "api_key", + "endpoint": "https://files.stripe.com/v1/files", + "method": "POST", + "path_fields": [], + "query_fields": [ + "Authorization", + "cursor", + "error", + "expand", + "file", + "limit", + "offset", + "page", + "per_page", + "purpose" + ], + "envelope": true, + "request_format": "multipart", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "get_file_206e2ac7", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/files/:file_id", + "method": "GET", + "path_fields": [ + "file_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_stripe", + "operation": "list_files_ff99af2d", + "kind": "api_key", + "endpoint": "https://api.stripe.com/v1/files", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "purpose" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "header_fields": [ + "Idempotency-Key", + "Stripe-Account" + ] + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "search_9ad105e4", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/search", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "error", + "expand", + "filter", + "limit", + "offset", + "page", + "page_size", + "per_page", + "property", + "query", + "value" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "get_page_1ab98180", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/pages/:page_id", + "method": "GET", + "path_fields": [ + "page_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "get_database_e811bbac", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/databases/:database_id", + "method": "GET", + "path_fields": [ + "database_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "query_database_b9b85056", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/databases/:database_id/query", + "method": "POST", + "path_fields": [ + "database_id" + ], + "query_fields": [ + "cursor", + "expand", + "filter", + "limit", + "offset", + "page", + "page_size", + "per_page", + "sorts" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "create_page_83759b4c", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/pages", + "method": "POST", + "path_fields": [], + "query_fields": [ + "children", + "cursor", + "expand", + "limit", + "offset", + "page", + "parent", + "per_page", + "properties" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "update_page_602bbf24", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/pages/:page_id", + "method": "PATCH", + "path_fields": [ + "page_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "properties" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "get_block_children_536e1dfd", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/blocks/:block_id/children", + "method": "GET", + "path_fields": [ + "block_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "page_size", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "append_block_children_46a0c4a4", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/blocks/:block_id/children", + "method": "PATCH", + "path_fields": [ + "block_id" + ], + "query_fields": [ + "children", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "delete_block_610ea9a0", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/blocks/:block_id", + "method": "DELETE", + "path_fields": [ + "block_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "get_user_dc1ded24", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/users/:user_id", + "method": "GET", + "path_fields": [ + "user_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "get_page_property_e5ef0a35", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/pages/:page_id/properties/:property_id", + "method": "GET", + "path_fields": [ + "page_id", + "property_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "page_size", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "create_database_4534a410", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/databases", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cover", + "cursor", + "description", + "expand", + "icon", + "initial_data_source", + "is_inline", + "limit", + "offset", + "page", + "page_id", + "parent", + "per_page", + "properties", + "title" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "update_database_60e1816a", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/databases/:database_id", + "method": "PATCH", + "path_fields": [ + "database_id" + ], + "query_fields": [ + "archived", + "cursor", + "description", + "expand", + "is_inline", + "limit", + "offset", + "page", + "per_page", + "properties", + "title" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "get_block_74cd076c", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/blocks/:block_id", + "method": "GET", + "path_fields": [ + "block_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "update_block_d89c7b03", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/blocks/:block_id", + "method": "PATCH", + "path_fields": [ + "block_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "list_comments_c6bc282e", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/comments", + "method": "GET", + "path_fields": [], + "query_fields": [ + "block_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "page_size", + "per_page", + "start_cursor" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "create_comment_9693d6bb", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/comments", + "method": "POST", + "path_fields": [], + "query_fields": [ + "block_id", + "cursor", + "discussion_id", + "display_name", + "error", + "expand", + "limit", + "offset", + "page", + "page_id", + "parent", + "per_page", + "rich_text", + "validation" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "list_users_24643397", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/users", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "page_size", + "per_page", + "start_cursor" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "get_bot_info_9dca28b1", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/users/me", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "create_file_upload_1dfb7fd4", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/file_uploads", + "method": "POST", + "path_fields": [], + "query_fields": [ + "content_type", + "cursor", + "expand", + "external_url", + "filename", + "limit", + "mode", + "number_of_parts", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "send_file_upload_dd34e532", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/file_uploads/:file_upload_id/send", + "method": "POST", + "path_fields": [ + "file_upload_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "details", + "error", + "exception", + "expand", + "file", + "http", + "limit", + "offset", + "page", + "part_number", + "per_page", + "validation" + ], + "envelope": true, + "request_format": "multipart", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "complete_file_upload_9743196b", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/file_uploads/:file_upload_id/complete", + "method": "POST", + "path_fields": [ + "file_upload_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "get_file_upload_183495d9", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/file_uploads/:file_upload_id", + "method": "GET", + "path_fields": [ + "file_upload_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_notion", + "operation": "list_file_uploads_2c4b79d3", + "kind": "api_key", + "endpoint": "https://api.notion.com/v1/file_uploads", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "page_size", + "per_page", + "start_cursor", + "status" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "refresh_access_token_001b80db", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/oauth/v1/token", + "method": "POST", + "path_fields": [], + "query_fields": [ + "client_id", + "client_secret", + "cursor", + "expand", + "grant_type", + "limit", + "offset", + "page", + "per_page", + "refresh_token" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_objects_13f3bd10", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/objects/:object_type", + "method": "GET", + "path_fields": [ + "object_type" + ], + "query_fields": [ + "after", + "archived", + "associations", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "properties" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "get_object_42661705", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/objects/:object_type/:object_id", + "method": "GET", + "path_fields": [ + "object_type", + "object_id" + ], + "query_fields": [ + "associations", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "properties" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "create_object_b56730bb", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/objects/:object_type", + "method": "POST", + "path_fields": [ + "object_type" + ], + "query_fields": [ + "associations", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "properties" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "update_object_5e67819a", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/objects/:object_type/:object_id", + "method": "PATCH", + "path_fields": [ + "object_type", + "object_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "properties" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "delete_object_31b2c5de", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/objects/:object_type/:object_id", + "method": "DELETE", + "path_fields": [ + "object_type", + "object_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "search_objects_c0ed612e", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/objects/:object_type/search", + "method": "POST", + "path_fields": [ + "object_type" + ], + "query_fields": [ + "after", + "cursor", + "expand", + "filterGroups", + "limit", + "offset", + "page", + "per_page", + "properties", + "query", + "sorts" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "batch_read_1d4466ea", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/objects/:object_type/batch/read", + "method": "POST", + "path_fields": [ + "object_type" + ], + "query_fields": [ + "cursor", + "expand", + "id", + "idProperty", + "inputs", + "limit", + "offset", + "page", + "per_page", + "properties" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "batch_create_65ad0526", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/objects/:object_type/batch/create", + "method": "POST", + "path_fields": [ + "object_type" + ], + "query_fields": [ + "cursor", + "expand", + "inputs", + "limit", + "offset", + "page", + "per_page", + "properties" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "batch_update_c70f9fb4", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/objects/:object_type/batch/update", + "method": "POST", + "path_fields": [ + "object_type" + ], + "query_fields": [ + "cursor", + "expand", + "inputs", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "merge_contacts_91a82dda", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/objects/contacts/merge", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "objectIdToMerge", + "offset", + "page", + "per_page", + "primaryObjectId" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_lists_439888af", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/lists/search", + "method": "POST", + "path_fields": [], + "query_fields": [ + "count", + "cursor", + "expand", + "limit", + "listIds", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "get_list_3c8ec4d1", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/lists/:list_id", + "method": "GET", + "path_fields": [ + "list_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "create_list_2b04e411", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/lists", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "filterBranch", + "limit", + "name", + "objectTypeId", + "offset", + "page", + "per_page", + "processingType" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "delete_list_9c09a903", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/lists/:list_id", + "method": "DELETE", + "path_fields": [ + "list_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "add_contacts_to_list_af684a84", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/lists/:list_id/memberships/add", + "method": "PUT", + "path_fields": [ + "list_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "remove_contacts_from_list_6dbed206", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/lists/:list_id/memberships/remove", + "method": "PUT", + "path_fields": [ + "list_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_pipelines_dd79480e", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/pipelines/:object_type", + "method": "GET", + "path_fields": [ + "object_type" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "get_pipeline_d44754db", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/pipelines/:object_type/:pipeline_id", + "method": "GET", + "path_fields": [ + "object_type", + "pipeline_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "create_pipeline_a87aff93", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/pipelines/:object_type", + "method": "POST", + "path_fields": [ + "object_type" + ], + "query_fields": [ + "cursor", + "displayOrder", + "expand", + "label", + "limit", + "offset", + "page", + "per_page", + "stages" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_pipeline_stages_dd3aac08", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/pipelines/:object_type/:pipeline_id/stages", + "method": "GET", + "path_fields": [ + "object_type", + "pipeline_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "update_pipeline_stage_7cfc365c", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/pipelines/:object_type/:pipeline_id/stages/:stage_id", + "method": "PATCH", + "path_fields": [ + "object_type", + "pipeline_id", + "stage_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_owners_b44c0835", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/owners", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "email", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "get_owner_b0195c99", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/owners/:owner_id", + "method": "GET", + "path_fields": [ + "owner_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_properties_58fd3300", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/properties/:object_type", + "method": "GET", + "path_fields": [ + "object_type" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "get_property_6df45602", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/properties/:object_type/:property_name", + "method": "GET", + "path_fields": [ + "object_type", + "property_name" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "create_property_0060cced", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/properties/:object_type", + "method": "POST", + "path_fields": [ + "object_type" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "update_property_bd218e77", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/properties/:object_type/:property_name", + "method": "PATCH", + "path_fields": [ + "object_type", + "property_name" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "delete_property_58bcb5a0", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/properties/:object_type/:property_name", + "method": "DELETE", + "path_fields": [ + "object_type", + "property_name" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_property_groups_eb7446dc", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v3/properties/:object_type/groups", + "method": "GET", + "path_fields": [ + "object_type" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "create_association_e1b4f2aa", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v4/objects/:from_object_type/:from_object_id/associations/:to_object_type/:to_object_id", + "method": "PUT", + "path_fields": [ + "from_object_type", + "from_object_id", + "to_object_type", + "to_object_id" + ], + "query_fields": [ + "associationCategory", + "associationTypeId", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "create_association_86b55d57", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v4/objects/:from_object_type/:from_object_id/associations/default/:to_object_type/:to_object_id", + "method": "PUT", + "path_fields": [ + "from_object_type", + "from_object_id", + "to_object_type", + "to_object_id" + ], + "query_fields": [ + "associationCategory", + "associationTypeId", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_associations_46e47496", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v4/objects/:from_object_type/:from_object_id/associations/:to_object_type", + "method": "GET", + "path_fields": [ + "from_object_type", + "from_object_id", + "to_object_type" + ], + "query_fields": [ + "after", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "delete_association_eff08c9f", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v4/objects/:from_object_type/:from_object_id/associations/:to_object_type/:to_object_id", + "method": "DELETE", + "path_fields": [ + "from_object_type", + "from_object_id", + "to_object_type", + "to_object_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_association_types_8002b044", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/crm/v4/associations/:from_object_type/:to_object_type/labels", + "method": "GET", + "path_fields": [ + "from_object_type", + "to_object_type" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_forms_dac337c4", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/marketing/v3/forms", + "method": "GET", + "path_fields": [], + "query_fields": [ + "after", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "get_form_18a4d5b4", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/marketing/v3/forms/:form_id", + "method": "GET", + "path_fields": [ + "form_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "submit_form_ddf46c9b", + "kind": "api_key", + "endpoint": "https://api.hsforms.com/submissions/v3/integration/submit/:portal_id/:form_guid", + "method": "POST", + "path_fields": [ + "portal_id", + "form_guid" + ], + "query_fields": [ + "context", + "cursor", + "expand", + "fields", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_form_submissions_ee3f8444", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/form-integrations/v1/submissions/forms/:form_guid", + "method": "GET", + "path_fields": [ + "form_guid" + ], + "query_fields": [ + "after", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_marketing_emails_8b409a34", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/marketing/v3/emails", + "method": "GET", + "path_fields": [], + "query_fields": [ + "after", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "get_marketing_email_0bb5949a", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/marketing/v3/emails/:email_id", + "method": "GET", + "path_fields": [ + "email_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "send_single_email_e127523e", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/marketing/v3/transactional/single-email/send", + "method": "POST", + "path_fields": [], + "query_fields": [ + "contactProperties", + "cursor", + "customProperties", + "emailId", + "expand", + "limit", + "message", + "name", + "offset", + "page", + "per_page", + "to", + "value" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "get_marketing_email_statistics_5a71cf2f", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/marketing/v3/emails/:email_id/statistics", + "method": "GET", + "path_fields": [ + "email_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "upload_file_8103e05b", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/files/v3/files", + "method": "POST", + "path_fields": [], + "query_fields": [ + "Authorization", + "access", + "cursor", + "error", + "expand", + "file", + "folderPath", + "limit", + "offset", + "options", + "overwrite", + "page", + "per_page" + ], + "envelope": true, + "request_format": "multipart", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "get_file_e28be584", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/files/v3/files/:file_id", + "method": "GET", + "path_fields": [ + "file_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "delete_file_c5d973c4", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/files/v3/files/:file_id", + "method": "DELETE", + "path_fields": [ + "file_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_folders_d3876b03", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/files/v3/folders", + "method": "GET", + "path_fields": [], + "query_fields": [ + "after", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_conversations_42bcbb1b", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/conversations/v3/conversations/threads", + "method": "GET", + "path_fields": [], + "query_fields": [ + "after", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "get_conversation_d28d0593", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/conversations/v3/conversations/threads/:thread_id", + "method": "GET", + "path_fields": [ + "thread_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_conversation_messages_f218eda1", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/conversations/v3/conversations/threads/:thread_id/messages", + "method": "GET", + "path_fields": [ + "thread_id" + ], + "query_fields": [ + "after", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "send_conversation_message_97e09080", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/conversations/v3/conversations/threads/:thread_id/messages", + "method": "POST", + "path_fields": [ + "thread_id" + ], + "query_fields": [ + "channelAccountId", + "channelId", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "recipients", + "senderActorId", + "text", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "list_webhook_subscriptions_b9d23898", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/webhooks/v3/:app_id/subscriptions", + "method": "GET", + "path_fields": [ + "app_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "create_webhook_subscription_b9a98c0f", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/webhooks/v3/:app_id/subscriptions", + "method": "POST", + "path_fields": [ + "app_id" + ], + "query_fields": [ + "active", + "cursor", + "eventType", + "expand", + "limit", + "offset", + "page", + "per_page", + "propertyName" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_hubspot", + "operation": "delete_webhook_subscription_fdd523ab", + "kind": "api_key", + "endpoint": "https://api.hubapi.com/webhooks/v3/:app_id/subscriptions/:subscription_id", + "method": "DELETE", + "path_fields": [ + "app_id", + "subscription_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "run_query_64634b12", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/query/", + "method": "POST", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "expand", + "kind", + "limit", + "offset", + "page", + "per_page", + "query", + "refresh", + "variables" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_query_status_faec5843", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/query/:query_id/", + "method": "GET", + "path_fields": [ + "project_id", + "query_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "cancel_query_c24cdac4", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/query/:query_id/", + "method": "DELETE", + "path_fields": [ + "project_id", + "query_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "draft_sql_911f8a09", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/query/draft_sql/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "prompt" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_insights_537811a5", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/insights/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "expand", + "favorited", + "limit", + "offset", + "page", + "per_page", + "search" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_insight_932ce289", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/insights/:insight_id/", + "method": "GET", + "path_fields": [ + "project_id", + "insight_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "create_insight_586fd07c", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/insights/", + "method": "POST", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "dashboards", + "description", + "expand", + "favorited", + "limit", + "name", + "offset", + "page", + "per_page", + "query", + "tags" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "update_insight_24f70f93", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/insights/:insight_id/", + "method": "PATCH", + "path_fields": [ + "project_id", + "insight_id" + ], + "query_fields": [ + "cursor", + "dashboards", + "description", + "expand", + "favorited", + "limit", + "name", + "offset", + "page", + "per_page", + "query", + "tags" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_insight_sharing_bc2eb154", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/insights/:insight_id/sharing/", + "method": "GET", + "path_fields": [ + "project_id", + "insight_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_trending_insights_86483772", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/insights/trending/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_insight_activity_9a58d8ad", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/insights/:insight_id/activity/", + "method": "GET", + "path_fields": [ + "project_id", + "insight_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_dashboards_7529ca34", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/dashboards/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "search" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_dashboard_029a1f6f", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/dashboards/:dashboard_id/", + "method": "GET", + "path_fields": [ + "project_id", + "dashboard_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "create_dashboard_9596951e", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/dashboards/", + "method": "POST", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "description", + "expand", + "limit", + "name", + "offset", + "page", + "per_page", + "pinned", + "tags" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "update_dashboard_4451470d", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/dashboards/:dashboard_id/", + "method": "PATCH", + "path_fields": [ + "project_id", + "dashboard_id" + ], + "query_fields": [ + "cursor", + "description", + "expand", + "limit", + "name", + "offset", + "page", + "per_page", + "pinned", + "tags" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "create_dashboard_text_tile_85538379", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/dashboards/:dashboard_id/create_text_tile/", + "method": "POST", + "path_fields": [ + "project_id", + "dashboard_id" + ], + "query_fields": [ + "body", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "delete_dashboard_tile_53226874", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/dashboards/:dashboard_id/delete_tile/", + "method": "POST", + "path_fields": [ + "project_id", + "dashboard_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "tile_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_dashboard_templates_fa870a06", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/dashboard_templates/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_feature_flags_93dffe3f", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/feature_flags/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "active", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "search" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_feature_flag_675f991c", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/feature_flags/:flag_id/", + "method": "GET", + "path_fields": [ + "project_id", + "flag_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "create_feature_flag_68b0f78f", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/feature_flags/", + "method": "POST", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "active", + "cursor", + "ensure_experience_continuity", + "expand", + "filters", + "groups", + "key", + "limit", + "name", + "offset", + "page", + "per_page", + "properties", + "rollout_percentage" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "update_feature_flag_c16d48ab", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/feature_flags/:flag_id/", + "method": "PATCH", + "path_fields": [ + "project_id", + "flag_id" + ], + "query_fields": [ + "active", + "cursor", + "expand", + "filters", + "key", + "limit", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "enable_feature_flag_16501e72", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/feature_flags/:flag_id/enable/", + "method": "POST", + "path_fields": [ + "project_id", + "flag_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "disable_feature_flag_ec3b76e2", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/feature_flags/:flag_id/disable/", + "method": "POST", + "path_fields": [ + "project_id", + "flag_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "archive_feature_flag_ca71d73c", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/feature_flags/:flag_id/archive/", + "method": "POST", + "path_fields": [ + "project_id", + "flag_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "unarchive_feature_flag_4d2084ff", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/feature_flags/:flag_id/unarchive/", + "method": "POST", + "path_fields": [ + "project_id", + "flag_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_feature_flag_status_b3ca0f48", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/feature_flags/:flag_id/status/", + "method": "GET", + "path_fields": [ + "project_id", + "flag_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_feature_flag_blast_radius_c997d185", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/feature_flags/user_blast_radius/", + "method": "POST", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "condition", + "cursor", + "expand", + "group_type_index", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_my_feature_flags_606d65ea", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/feature_flags/my_flags/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_early_access_features_4d5c942d", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/early_access_feature/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "create_early_access_feature_ae590da3", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/early_access_feature/", + "method": "POST", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "description", + "expand", + "feature_flag_id", + "limit", + "name", + "offset", + "page", + "per_page", + "stage" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_persons_9b11a13e", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/persons/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "distinct_id", + "expand", + "limit", + "offset", + "page", + "per_page", + "properties", + "search" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_person_3a3082ec", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/persons/:person_id/", + "method": "GET", + "path_fields": [ + "project_id", + "person_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "update_person_20938204", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/persons/:person_id/", + "method": "PATCH", + "path_fields": [ + "project_id", + "person_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "properties" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "update_person_property_1544ffaf", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/persons/:person_id/update_property/", + "method": "POST", + "path_fields": [ + "project_id", + "person_id" + ], + "query_fields": [ + "cursor", + "expand", + "key", + "limit", + "offset", + "page", + "per_page", + "value" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "delete_person_property_71ead581", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/persons/:person_id/delete_property/", + "method": "POST", + "path_fields": [ + "project_id", + "person_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "delete_persons_3a0cb393", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/persons/bulk_delete/", + "method": "POST", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "delete_events", + "distinct_ids", + "expand", + "ids", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_person_cohorts_8f5f8f80", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/persons/cohorts/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "person_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_person_properties_timeline_4e27e3e4", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/persons/:person_id/properties_timeline/", + "method": "GET", + "path_fields": [ + "project_id", + "person_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_person_property_values_fcc866d0", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/persons/values/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "expand", + "key", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_cohorts_98fef557", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/cohorts/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_cohort_8a7d1c42", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/cohorts/:cohort_id/", + "method": "GET", + "path_fields": [ + "project_id", + "cohort_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "create_cohort_23984762", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/cohorts/", + "method": "POST", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "description", + "expand", + "groups", + "is_static", + "limit", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "update_cohort_b14a9044", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/cohorts/:cohort_id/", + "method": "PATCH", + "path_fields": [ + "project_id", + "cohort_id" + ], + "query_fields": [ + "cursor", + "description", + "expand", + "groups", + "limit", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_cohort_persons_3d2386d4", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/cohorts/:cohort_id/persons/", + "method": "GET", + "path_fields": [ + "project_id", + "cohort_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "add_persons_to_cohort_868fc741", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/cohorts/:cohort_id/add_persons_to_static_cohort/", + "method": "PATCH", + "path_fields": [ + "project_id", + "cohort_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "person_ids" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_annotations_d2afca92", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/annotations/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_annotation_6baca3e2", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/annotations/:annotation_id/", + "method": "GET", + "path_fields": [ + "project_id", + "annotation_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "create_annotation_ee918c79", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/annotations/", + "method": "POST", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "content", + "cursor", + "dashboard_item", + "date_marker", + "expand", + "limit", + "offset", + "page", + "per_page", + "scope" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "update_annotation_830c5943", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/annotations/:annotation_id/", + "method": "PATCH", + "path_fields": [ + "project_id", + "annotation_id" + ], + "query_fields": [ + "content", + "cursor", + "date_marker", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_event_definitions_99339dcd", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/event_definitions/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "search" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_event_definition_3eda9406", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/event_definitions/:definition_id/", + "method": "GET", + "path_fields": [ + "project_id", + "definition_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "update_event_definition_21ad3a7d", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/event_definitions/:definition_id/", + "method": "PATCH", + "path_fields": [ + "project_id", + "definition_id" + ], + "query_fields": [ + "cursor", + "description", + "expand", + "limit", + "offset", + "page", + "per_page", + "tags", + "verified" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_property_definitions_0717a4f3", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/property_definitions/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "event_names", + "expand", + "limit", + "offset", + "page", + "per_page", + "search" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "update_property_definition_06f9ea77", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/property_definitions/:definition_id/", + "method": "PATCH", + "path_fields": [ + "project_id", + "definition_id" + ], + "query_fields": [ + "cursor", + "description", + "expand", + "limit", + "offset", + "page", + "per_page", + "tags" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_actions_2c9c10d7", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/actions/", + "method": "GET", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_action_40c99eaa", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/actions/:action_id/", + "method": "GET", + "path_fields": [ + "project_id", + "action_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "create_action_42f44389", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/projects/:project_id/actions/", + "method": "POST", + "path_fields": [ + "project_id" + ], + "query_fields": [ + "cursor", + "description", + "expand", + "limit", + "name", + "offset", + "page", + "per_page", + "steps" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_current_user_f157f167", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/users/@me/", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_organization_0805ffee", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/organizations/:organization_id/", + "method": "GET", + "path_fields": [ + "organization_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_organization_members_7419c05a", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/organizations/:organization_id/members/", + "method": "GET", + "path_fields": [ + "organization_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "list_projects_89642ede", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/organizations/:organization_id/projects/", + "method": "GET", + "path_fields": [ + "organization_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_posthog", + "operation": "get_project_30d738c0", + "kind": "api_key", + "endpoint": "https://us.posthog.com/api/organizations/:organization_id/projects/:project_id/", + "method": "GET", + "path_fields": [ + "organization_id", + "project_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "refresh_sync_1af9fb37", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/oauth/token", + "method": "POST", + "path_fields": [], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "grant_type", + "limit", + "offset", + "page", + "per_page", + "refresh_token" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "keys_for_49567d26", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:ref/api-keys", + "method": "GET", + "path_fields": [ + "ref" + ], + "query_fields": [ + "cursor", + "error", + "expand", + "jwt", + "limit", + "offset", + "page", + "per_page", + "publishable", + "reveal", + "secret" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "rest_f40da060", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:ref/database/query", + "method": "POST", + "path_fields": [ + "ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "query" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "get_profile_d233b848", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/profile", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "list_organizations_edecc64d", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/organizations", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "name", + "offset", + "ok", + "page", + "per_page", + "projects", + "result", + "slug", + "source" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "list_organizations_7af4633a", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "name", + "offset", + "ok", + "page", + "per_page", + "projects", + "result", + "slug", + "source" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "list_organization_members_cdf0e139", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/organizations/:organization_slug/members", + "method": "GET", + "path_fields": [ + "organization_slug" + ], + "query_fields": [ + "cursor", + "details", + "error", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "get_project_81a973e8", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "list_regions_74a9d475", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/available-regions", + "method": "GET", + "path_fields": [], + "query_fields": [ + "continent", + "cursor", + "expand", + "limit", + "offset", + "organization_slug", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "create_project_f28c2e8c", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects", + "method": "POST", + "path_fields": [], + "query_fields": [ + "code", + "cursor", + "db_pass", + "desired_instance_size", + "expand", + "generated_db_password", + "limit", + "name", + "offset", + "organization_slug", + "page", + "per_page", + "region_selection", + "result", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "update_project_c7f9e43e", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref", + "method": "PATCH", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "delete_project_4507706e", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref", + "method": "DELETE", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "error", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "pause_project_c3296495", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/pause", + "method": "POST", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "restore_project_4a290ec7", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/restore", + "method": "POST", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "restart_project_b9076fe5", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/restart", + "method": "POST", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "get_project_health_4f44d2f5", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/health", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "services" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "get_service_config_752db9a7", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/:path", + "method": "GET", + "path_fields": [ + "project_ref", + "path" + ], + "query_fields": [ + "cursor", + "error", + "expand", + "limit", + "offset", + "page", + "per_page", + "result" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "run_sql_readonly_05b81c04", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/database/query/read-only", + "method": "POST", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "parameters", + "per_page", + "query" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "run_sql_75d05ab4", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/database/query", + "method": "POST", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "applied_as_migration", + "cursor", + "expand", + "limit", + "note", + "offset", + "ok", + "page", + "parameters", + "per_page", + "query", + "result" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "generate_typescript_types_285baf00", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/types/typescript", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "included_schemas", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "list_backups_c6bfb781", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/database/backups", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "list_migrations_57262f31", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/database/migrations", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "get_migration_0dc2d142", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/database/migrations/:version", + "method": "GET", + "path_fields": [ + "project_ref", + "version" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "apply_migration_61fe6b3a", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/database/migrations", + "method": "POST", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "name", + "offset", + "page", + "per_page", + "query", + "rollback" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "rollback_migrations_586e4851", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/database/migrations", + "method": "DELETE", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "gte", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "list_functions_fcc76d2b", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/functions", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "get_function_71e9d354", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/functions/:slug", + "method": "GET", + "path_fields": [ + "project_ref", + "slug" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "deploy_function_7d543b0b", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/functions/deploy", + "method": "POST", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "entrypoint_path", + "error", + "expand", + "import_map_path", + "limit", + "metadata", + "name", + "offset", + "page", + "per_page", + "slug", + "verify_jwt" + ], + "envelope": true, + "request_format": "multipart", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "update_function_67d24262", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/functions/:slug", + "method": "PATCH", + "path_fields": [ + "project_ref", + "slug" + ], + "query_fields": [ + "cursor", + "error", + "expand", + "limit", + "name", + "offset", + "page", + "per_page", + "verify_jwt" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "delete_function_abcf4462", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/functions/:slug", + "method": "DELETE", + "path_fields": [ + "project_ref", + "slug" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "list_secrets_9f420f50", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/secrets", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "name", + "offset", + "ok", + "page", + "per_page", + "result", + "updated_at" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "set_secrets_5037ad73", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/secrets", + "method": "POST", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "error", + "expand", + "limit", + "name", + "offset", + "page", + "per_page", + "result", + "set", + "value" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "delete_secrets_390061c0", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/secrets", + "method": "DELETE", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "deleted", + "error", + "expand", + "limit", + "offset", + "page", + "per_page", + "result" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "list_branches_82f4f266", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/branches", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "get_branch_b4c0d8f2", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/branches/:branch_id", + "method": "GET", + "path_fields": [ + "branch_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "create_branch_60b03364", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/branches", + "method": "POST", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "branch_name", + "cursor", + "expand", + "git_branch", + "limit", + "offset", + "page", + "per_page", + "persistent", + "region", + "with_data" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "update_branch_84bbfde6", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/branches/:branch_id", + "method": "PATCH", + "path_fields": [ + "branch_id" + ], + "query_fields": [ + "branch_name", + "cursor", + "error", + "expand", + "git_branch", + "limit", + "offset", + "page", + "per_page", + "persistent" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "delete_branch_d6ff7f11", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/branches/:branch_id", + "method": "DELETE", + "path_fields": [ + "branch_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "merge_branch_434a6b8a", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/branches/:branch_id/merge", + "method": "POST", + "path_fields": [ + "branch_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "migration_version", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "reset_branch_6d8bab6a", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/branches/:branch_id/reset", + "method": "POST", + "path_fields": [ + "branch_id" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "migration_version", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "get_logs_9fae9246", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/analytics/endpoints/logs", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "details", + "error", + "expand", + "iso_timestamp_end", + "iso_timestamp_start", + "limit", + "offset", + "page", + "per_page", + "sql" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "get_security_advisors_3433bbfe", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/advisors/security", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "get_performance_advisors_f91ee12e", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/advisors/performance", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "get_api_usage_4457291e", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/analytics/endpoints/usage.api-counts", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [ + "cursor", + "expand", + "interval", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "push_text_a94c93b4", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/message/push", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "messages", + "notificationDisabled", + "offset", + "page", + "per_page", + "text", + "to", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "reply_text_20582f8b", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/message/reply", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "messages", + "notificationDisabled", + "offset", + "page", + "per_page", + "replyToken", + "text", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "multicast_text_5f8005ae", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/message/multicast", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "messages", + "notificationDisabled", + "offset", + "page", + "per_page", + "text", + "to", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "broadcast_text_8511ec8f", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/message/broadcast", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "messages", + "notificationDisabled", + "offset", + "page", + "per_page", + "text", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_profile_2d8cba85", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/profile/:user_id", + "method": "GET", + "path_fields": [ + "user_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_bot_info_d0d4ab95", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/info", + "method": "GET", + "path_fields": [], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_quota_ef5c424c", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/message/quota", + "method": "GET", + "path_fields": [], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_message_content_90f73398", + "kind": "api_key", + "endpoint": "https://api-data.line.me/v2/bot/message/:message_id/content", + "method": "GET", + "path_fields": [ + "message_id" + ], + "query_fields": [ + "Authorization", + "bytes_written", + "cursor", + "details", + "error", + "expand", + "limit", + "mimetype", + "offset", + "ok", + "page", + "path", + "per_page", + "result" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "response_format": "binary", + "max_response_bytes": 64000000 + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_group_summary_e94faa0f", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/group/:group_id/summary", + "method": "GET", + "path_fields": [ + "group_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_group_member_count_66cc2109", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/group/:group_id/members/count", + "method": "GET", + "path_fields": [ + "group_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "list_group_member_user_ids_f4002f5b", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/group/:group_id/members/ids", + "method": "GET", + "path_fields": [ + "group_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "start" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_group_member_profile_516d8147", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/group/:group_id/member/:user_id", + "method": "GET", + "path_fields": [ + "group_id", + "user_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "leave_group_bb9f8e1c", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/group/:group_id/leave", + "method": "POST", + "path_fields": [ + "group_id" + ], + "query_fields": [ + "cursor", + "expand", + "group_id", + "left", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_room_member_count_f65de4bb", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/room/:room_id/members/count", + "method": "GET", + "path_fields": [ + "room_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "list_room_member_user_ids_2bb8c5a3", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/room/:room_id/members/ids", + "method": "GET", + "path_fields": [ + "room_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "start" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_room_member_profile_21bfdfed", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/room/:room_id/member/:user_id", + "method": "GET", + "path_fields": [ + "room_id", + "user_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "leave_room_4fb22467", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/room/:room_id/leave", + "method": "POST", + "path_fields": [ + "room_id" + ], + "query_fields": [ + "cursor", + "expand", + "left", + "limit", + "offset", + "page", + "per_page", + "room_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "create_rich_menu_a7eba8d7", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/richmenu", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_rich_menu_f95d2a05", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/richmenu/:rich_menu_id", + "method": "GET", + "path_fields": [ + "rich_menu_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "list_rich_menus_747e9188", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/richmenu/list", + "method": "GET", + "path_fields": [], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "delete_rich_menu_f16ecb97", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/richmenu/:rich_menu_id", + "method": "DELETE", + "path_fields": [ + "rich_menu_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page", + "rich_menu_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "upload_rich_menu_image_eb74d853", + "kind": "api_key", + "endpoint": "https://api-data.line.me/v2/bot/richmenu/:rich_menu_id/content", + "method": "POST", + "path_fields": [ + "rich_menu_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "details", + "error", + "expand", + "limit", + "offset", + "ok", + "page", + "per_page", + "result", + "rich_menu_id", + "size" + ], + "envelope": true, + "request_format": "image", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "set_default_rich_menu_61619a1b", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/user/all/richmenu/:rich_menu_id", + "method": "POST", + "path_fields": [ + "rich_menu_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "rich_menu_id", + "set_default" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_default_rich_menu_edd4b93b", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/user/all/richmenu", + "method": "GET", + "path_fields": [], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "cancel_default_rich_menu_851ec6c6", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/user/all/richmenu", + "method": "DELETE", + "path_fields": [], + "query_fields": [ + "Authorization", + "cancelled", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "link_rich_menu_to_user_c9ebec0e", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/user/:user_id/richmenu/:rich_menu_id", + "method": "POST", + "path_fields": [ + "user_id", + "rich_menu_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "linked", + "offset", + "page", + "per_page", + "rich_menu_id", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "unlink_rich_menu_from_user_92f39c16", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/user/:user_id/richmenu", + "method": "DELETE", + "path_fields": [ + "user_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "unlinked", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_user_rich_menu_59ca4d78", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/user/:user_id/richmenu", + "method": "GET", + "path_fields": [ + "user_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "bulk_link_rich_menu_49876325", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/richmenu/bulk/link", + "method": "POST", + "path_fields": [], + "query_fields": [ + "count", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "queued", + "richMenuId", + "userIds" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "bulk_unlink_rich_menu_64a9f5db", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/richmenu/bulk/unlink", + "method": "POST", + "path_fields": [], + "query_fields": [ + "count", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "queued", + "userIds" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "send_narrowcast_8f25279b", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/message/narrowcast", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "demographic", + "expand", + "filter", + "limit", + "messages", + "notificationDisabled", + "offset", + "page", + "per_page", + "queued", + "recipient", + "request_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_narrowcast_progress_5e131ad8", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/message/progress/narrowcast", + "method": "GET", + "path_fields": [], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "requestId" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "create_user_id_audience_2b0c611b", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/audienceGroup/upload", + "method": "POST", + "path_fields": [], + "query_fields": [ + "audiences", + "cursor", + "description", + "expand", + "isIfaAudience", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "update_audience_description_3792c3bc", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/audienceGroup/:audience_group_id/updateDescription", + "method": "PUT", + "path_fields": [ + "audience_group_id" + ], + "query_fields": [ + "audience_group_id", + "cursor", + "description", + "expand", + "limit", + "offset", + "page", + "per_page", + "updated" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "delete_audience_6de5539f", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/audienceGroup/:audience_group_id", + "method": "DELETE", + "path_fields": [ + "audience_group_id" + ], + "query_fields": [ + "Authorization", + "audience_group_id", + "cursor", + "deleted", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_audience_958fe8de", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/audienceGroup/:audience_group_id", + "method": "GET", + "path_fields": [ + "audience_group_id" + ], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "list_audiences_2f3e0caa", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/audienceGroup/list", + "method": "GET", + "path_fields": [], + "query_fields": [ + "Authorization", + "cursor", + "description", + "expand", + "limit", + "offset", + "page", + "per_page", + "size", + "status" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_number_of_followers_4434d7cd", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/insight/followers", + "method": "GET", + "path_fields": [], + "query_fields": [ + "Authorization", + "cursor", + "date", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_friend_demographics_e26c35ca", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/insight/demographic", + "method": "GET", + "path_fields": [], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_message_delivery_stats_4d009862", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/insight/message/delivery", + "method": "GET", + "path_fields": [], + "query_fields": [ + "Authorization", + "cursor", + "date", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_message_event_stats_186a6e1d", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/insight/message/event", + "method": "GET", + "path_fields": [], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "requestId" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_user_interaction_stats_5d476e9b", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/insight/message/event/aggregation", + "method": "GET", + "path_fields": [], + "query_fields": [ + "Authorization", + "cursor", + "customAggregationUnit", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "set_webhook_endpoint_8253183c", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/channel/webhook/endpoint", + "method": "PUT", + "path_fields": [], + "query_fields": [ + "cursor", + "endpoint", + "expand", + "limit", + "offset", + "page", + "per_page", + "updated" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "get_webhook_endpoint_0ec5b36c", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/channel/webhook/endpoint", + "method": "GET", + "path_fields": [], + "query_fields": [ + "Authorization", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "test_webhook_endpoint_0a36d69c", + "kind": "api_key", + "endpoint": "https://api.line.me/v2/bot/channel/webhook/test", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "endpoint", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "issue_channel_access_token_663a5d0b", + "kind": "api_key", + "endpoint": "https://api.line.me/oauth2/v3/token", + "method": "POST", + "path_fields": [], + "query_fields": [ + "client_id", + "client_secret", + "cursor", + "expand", + "grant_type", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "revoke_channel_access_token_3e237727", + "kind": "api_key", + "endpoint": "https://api.line.me/oauth2/v3/revoke", + "method": "POST", + "path_fields": [], + "query_fields": [ + "access_token", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "revoked" + ], + "envelope": true, + "request_format": "form", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_line", + "operation": "verify_access_token_a58cb859", + "kind": "api_key", + "endpoint": "https://api.line.me/oauth2/v3/verify", + "method": "GET", + "path_fields": [], + "query_fields": [ + "access_token", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_message_09dc036d", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendMessage", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "disable_notification", + "expand", + "limit", + "offset", + "page", + "parse_mode", + "per_page", + "reply_to_message_id", + "text" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "poll_updates_sync_c3500467", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/getUpdates", + "method": "GET", + "path_fields": [], + "query_fields": [ + "allowed_updates", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "result", + "timeout" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "get_me_2a0b547f", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/getMe", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_photo_7ef4cd34", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendPhoto", + "method": "POST", + "path_fields": [], + "query_fields": [ + "caption", + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "parse_mode", + "per_page", + "photo" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_document_a04ca360", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendDocument", + "method": "POST", + "path_fields": [], + "query_fields": [ + "caption", + "chat_id", + "cursor", + "document", + "expand", + "limit", + "offset", + "page", + "parse_mode", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "get_updates_5b46ae22", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/getUpdates", + "method": "POST", + "path_fields": [], + "query_fields": [ + "allowed_updates", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "timeout" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "get_chat_2ba529b4", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/getChat", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "get_chat_member_c8d59110", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/getChatMember", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "get_chat_members_count_924e301b", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/getChatMembersCount", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "forward_message_0513c22a", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/forwardMessage", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "disable_notification", + "expand", + "from_chat_id", + "limit", + "message_id", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "edit_message_text_d0e18189", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/editMessageText", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "disable_web_page_preview", + "entities", + "expand", + "limit", + "message_id", + "offset", + "page", + "parse_mode", + "per_page", + "reply_markup", + "text" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "edit_message_caption_9b6ae108", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/editMessageCaption", + "method": "POST", + "path_fields": [], + "query_fields": [ + "caption", + "chat_id", + "cursor", + "expand", + "limit", + "message_id", + "offset", + "page", + "parse_mode", + "per_page", + "reply_markup" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "edit_message_reply_markup_5739bea2", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/editMessageReplyMarkup", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "message_id", + "offset", + "page", + "per_page", + "reply_markup" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "delete_message_15e1237f", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/deleteMessage", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "message_id", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "delete_messages_531340ae", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/deleteMessages", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "message_ids", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "copy_message_02186968", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/copyMessage", + "method": "POST", + "path_fields": [], + "query_fields": [ + "caption", + "chat_id", + "cursor", + "disable_notification", + "expand", + "from_chat_id", + "limit", + "message_id", + "offset", + "page", + "parse_mode", + "per_page", + "reply_markup", + "reply_to_message_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "forward_messages_6cd7db36", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/forwardMessages", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "disable_notification", + "expand", + "from_chat_id", + "limit", + "message_ids", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "pin_message_75002f6e", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/pinChatMessage", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "disable_notification", + "expand", + "limit", + "message_id", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "unpin_message_28563267", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/unpinChatMessage", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "message_id", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "unpin_all_messages_4e1fb0d0", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/unpinAllChatMessages", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "set_message_reaction_7ba79326", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/setMessageReaction", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "is_big", + "limit", + "message_id", + "offset", + "page", + "per_page", + "reaction" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_chat_action_ba762119", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendChatAction", + "method": "POST", + "path_fields": [], + "query_fields": [ + "action", + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_video_96ffc55c", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendVideo", + "method": "POST", + "path_fields": [], + "query_fields": [ + "caption", + "chat_id", + "cursor", + "duration", + "expand", + "height", + "limit", + "offset", + "page", + "parse_mode", + "per_page", + "reply_markup", + "supports_streaming", + "video", + "width" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_audio_fc4c82a4", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendAudio", + "method": "POST", + "path_fields": [], + "query_fields": [ + "audio", + "caption", + "chat_id", + "cursor", + "duration", + "expand", + "limit", + "offset", + "page", + "parse_mode", + "per_page", + "performer", + "reply_markup", + "title" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_voice_0a0163e4", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendVoice", + "method": "POST", + "path_fields": [], + "query_fields": [ + "caption", + "chat_id", + "cursor", + "duration", + "expand", + "limit", + "offset", + "page", + "per_page", + "voice" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_video_note_e9d6dfc9", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendVideoNote", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "duration", + "expand", + "length", + "limit", + "offset", + "page", + "per_page", + "video_note" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_animation_5b70e092", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendAnimation", + "method": "POST", + "path_fields": [], + "query_fields": [ + "animation", + "caption", + "chat_id", + "cursor", + "duration", + "expand", + "height", + "limit", + "offset", + "page", + "parse_mode", + "per_page", + "width" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_sticker_15e0eb81", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendSticker", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "emoji", + "expand", + "limit", + "offset", + "page", + "per_page", + "sticker" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_location_b53223c6", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendLocation", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "heading", + "latitude", + "limit", + "live_period", + "longitude", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_venue_99c51ea2", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendVenue", + "method": "POST", + "path_fields": [], + "query_fields": [ + "address", + "chat_id", + "cursor", + "expand", + "foursquare_id", + "latitude", + "limit", + "longitude", + "offset", + "page", + "per_page", + "title" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_contact_aba136b4", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendContact", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "first_name", + "last_name", + "limit", + "offset", + "page", + "per_page", + "phone_number", + "vcard" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_dice_c322489a", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendDice", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "emoji", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_poll_98af5bf4", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendPoll", + "method": "POST", + "path_fields": [], + "query_fields": [ + "allows_multiple_answers", + "chat_id", + "correct_option_id", + "cursor", + "expand", + "explanation", + "is_anonymous", + "is_closed", + "limit", + "offset", + "open_period", + "options", + "page", + "per_page", + "question", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "stop_poll_31f88a7e", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/stopPoll", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "message_id", + "offset", + "page", + "per_page", + "reply_markup" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "send_media_group_f1b13c6f", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/sendMediaGroup", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "media", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "get_file_c6bec521", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/getFile", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "file_id", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "ban_chat_member_e4f8e672", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/banChatMember", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "revoke_messages", + "until_date", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "unban_chat_member_6a33cc80", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/unbanChatMember", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "only_if_banned", + "page", + "per_page", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "restrict_chat_member_d014b2fd", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/restrictChatMember", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "permissions", + "until_date", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "promote_chat_member_b11efc48", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/promoteChatMember", + "method": "POST", + "path_fields": [], + "query_fields": [ + "can_change_info", + "can_delete_messages", + "can_edit_messages", + "can_invite_users", + "can_manage_chat", + "can_manage_topics", + "can_manage_video_chats", + "can_pin_messages", + "can_post_messages", + "can_promote_members", + "can_restrict_members", + "chat_id", + "cursor", + "expand", + "is_anonymous", + "limit", + "offset", + "page", + "per_page", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "set_chat_administrator_custom_title_4a911d15", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/setChatAdministratorCustomTitle", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "custom_title", + "expand", + "limit", + "offset", + "page", + "per_page", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "set_chat_permissions_5efee794", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/setChatPermissions", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "permissions" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "set_chat_title_cf149fa5", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/setChatTitle", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "title" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "set_chat_description_b4344cc9", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/setChatDescription", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "description", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "delete_chat_photo_d92940c1", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/deleteChatPhoto", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "leave_chat_e9d4cc89", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/leaveChat", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "export_chat_invite_link_81b81947", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/exportChatInviteLink", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "create_chat_invite_link_3b27924b", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/createChatInviteLink", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "creates_join_request", + "cursor", + "expand", + "expire_date", + "limit", + "member_limit", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "edit_chat_invite_link_6c4bb3fb", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/editChatInviteLink", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "creates_join_request", + "cursor", + "expand", + "expire_date", + "invite_link", + "limit", + "member_limit", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "revoke_chat_invite_link_da91bbb1", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/revokeChatInviteLink", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "invite_link", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "approve_chat_join_request_7d04da33", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/approveChatJoinRequest", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "decline_chat_join_request_6dbf07cd", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/declineChatJoinRequest", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "user_id" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "get_chat_administrators_b5c4aaf0", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/getChatAdministrators", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "set_my_commands_3e8674e7", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/setMyCommands", + "method": "POST", + "path_fields": [], + "query_fields": [ + "commands", + "cursor", + "expand", + "language_code", + "limit", + "offset", + "page", + "per_page", + "scope" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "get_my_commands_63f69db9", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/getMyCommands", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "language_code", + "limit", + "offset", + "page", + "per_page", + "scope" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "delete_my_commands_469652e5", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/deleteMyCommands", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "language_code", + "limit", + "offset", + "page", + "per_page", + "scope" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "set_my_description_df4dbe21", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/setMyDescription", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "description", + "expand", + "language_code", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "get_my_description_42d318ef", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/getMyDescription", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "language_code", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "set_my_short_description_9d3304ac", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/setMyShortDescription", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "language_code", + "limit", + "offset", + "page", + "per_page", + "short_description" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "set_my_name_09964d13", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/setMyName", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "language_code", + "limit", + "name", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "set_chat_menu_button_48f5c164", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/setChatMenuButton", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "menu_button", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "get_chat_menu_button_fc776459", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/getChatMenuButton", + "method": "POST", + "path_fields": [], + "query_fields": [ + "chat_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "set_my_default_administrator_rights_5ef1298e", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/setMyDefaultAdministratorRights", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "for_channels", + "limit", + "offset", + "page", + "per_page", + "rights" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "get_my_default_administrator_rights_e3cf13de", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/getMyDefaultAdministratorRights", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "for_channels", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "answer_callback_query_5a095f8f", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/answerCallbackQuery", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cache_time", + "callback_query_id", + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page", + "show_alert", + "text", + "url" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "set_webhook_823e4df5", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/setWebhook", + "method": "POST", + "path_fields": [], + "query_fields": [ + "allowed_updates", + "cursor", + "drop_pending_updates", + "expand", + "ip_address", + "limit", + "max_connections", + "offset", + "page", + "per_page", + "secret_token", + "url" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "delete_webhook_c318758d", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/deleteWebhook", + "method": "POST", + "path_fields": [], + "query_fields": [ + "cursor", + "drop_pending_updates", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "get_webhook_info_b26b0b07", + "kind": "api_key", + "endpoint": "https://api.telegram.org/bot_TOKEN_/getWebhookInfo", + "method": "GET", + "path_fields": [], + "query_fields": [ + "cursor", + "expand", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram" + }, + { + "type": "json", + "helper": "integration_whatsapp_business", + "operation": "send_text_fac0338a", + "kind": "api_key", + "endpoint": "https://graph.facebook.com/v21.0/:phone_number_id/messages", + "method": "POST", + "path_fields": [ + "phone_number_id" + ], + "query_fields": [ + "body", + "cursor", + "expand", + "limit", + "messaging_product", + "offset", + "page", + "per_page", + "text", + "to", + "type" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_whatsapp_business", + "operation": "get_media_url_0f8570a8", + "kind": "api_key", + "endpoint": "https://graph.facebook.com/v21.0/:media_id", + "method": "GET", + "path_fields": [ + "media_id" + ], + "query_fields": [ + "cursor", + "expand", + "file_size", + "limit", + "mime_type", + "offset", + "page", + "per_page", + "url" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_whatsapp_business", + "operation": "get_business_profile_08cda6b1", + "kind": "api_key", + "endpoint": "https://graph.facebook.com/v21.0/:phone_number_id/whatsapp_business_profile", + "method": "GET", + "path_fields": [ + "phone_number_id" + ], + "query_fields": [ + "cursor", + "expand", + "fields", + "limit", + "offset", + "page", + "per_page" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer " + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "reviewed_dee99786f1ad", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/teams/:team_id/components", + "method": "GET", + "path_fields": [ + "team_id" + ], + "query_fields": [ + "page_size", + "after", + "before" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "reviewed_bff00ba21d61", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/files/:file_key/components", + "method": "GET", + "path_fields": [ + "file_key" + ], + "query_fields": [], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "reviewed_d3a76683809a", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/teams/:team_id/component_sets", + "method": "GET", + "path_fields": [ + "team_id" + ], + "query_fields": [ + "page_size", + "after", + "before" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "reviewed_addcfaadc2e7", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/files/:file_key/component_sets", + "method": "GET", + "path_fields": [ + "file_key" + ], + "query_fields": [], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "reviewed_7016b7bef252", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/teams/:team_id/styles", + "method": "GET", + "path_fields": [ + "team_id" + ], + "query_fields": [ + "page_size", + "after", + "before" + ], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_figma", + "operation": "reviewed_e6577de14def", + "kind": "api_key", + "endpoint": "https://api.figma.com/v1/files/:file_key/styles", + "method": "GET", + "path_fields": [ + "file_key" + ], + "query_fields": [], + "envelope": true, + "request_format": "json", + "auth_header": "X-Figma-Token", + "auth_prefix": "", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_twitter", + "operation": "reviewed_e7741efb4da1", + "kind": "service_secret", + "endpoint": "https://upload.twitter.com/1.1/media/upload.json", + "method": "POST", + "path_fields": [], + "query_fields": [], + "envelope": true, + "request_format": "multipart", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "secret_field": "api_secret", + "protocol": "twitter_oauth1", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "reviewed_45020708e2eb", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/config/auth", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "reviewed_7215572b5ac3", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/config/auth", + "method": "PATCH", + "path_fields": [ + "project_ref" + ], + "query_fields": [], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "reviewed_d329a7769579", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/postgrest", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "reviewed_dba3e6872d6a", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/postgrest", + "method": "PATCH", + "path_fields": [ + "project_ref" + ], + "query_fields": [], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "reviewed_62a6a35482cb", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/config/realtime", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "reviewed_388e70c8e974", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/config/realtime", + "method": "PATCH", + "path_fields": [ + "project_ref" + ], + "query_fields": [], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "reviewed_101bf90f8611", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/config/storage", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "reviewed_d5d84927cc6d", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/config/storage", + "method": "PATCH", + "path_fields": [ + "project_ref" + ], + "query_fields": [], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "reviewed_5eaf9781ab69", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/config/database/postgres", + "method": "GET", + "path_fields": [ + "project_ref" + ], + "query_fields": [], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_supabase", + "operation": "reviewed_b9d52af89a1d", + "kind": "api_key", + "endpoint": "https://api.supabase.com/v1/projects/:project_ref/config/database/postgres", + "method": "PUT", + "path_fields": [ + "project_ref" + ], + "query_fields": [], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_whatsapp_business", + "operation": "reviewed_3412812d2d34", + "kind": "api_key", + "endpoint": "https://graph.facebook.com/v21.0/:phone_number_id", + "method": "GET", + "path_fields": [ + "phone_number_id" + ], + "query_fields": [ + "fields" + ], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "response_format": "json" + }, + { + "type": "json", + "helper": "integration_telegram_bot", + "operation": "reviewed_cdc656be20ca", + "kind": "api_key", + "endpoint": "https://api.telegram.org/file/bot_TOKEN_/:file_path", + "method": "GET", + "path_fields": [ + "file_path" + ], + "query_fields": [], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "protocol": "telegram", + "response_format": "binary", + "max_response_bytes": 64000000 + }, + { + "type": "json", + "helper": "integration_slack", + "operation": "reviewed_5222b27d9c30", + "kind": "api_key", + "endpoint": "https://files.slack.com/files-pri/:team_file_id/download/:file_name", + "method": "GET", + "path_fields": [ + "team_file_id", + "file_name" + ], + "query_fields": [], + "envelope": true, + "request_format": "json", + "auth_header": "Authorization", + "auth_prefix": "Bearer ", + "response_format": "binary", + "max_response_bytes": 64000000 + } +] diff --git a/app/credentials/records.py b/app/credentials/records.py new file mode 100644 index 000000000..2a060315f --- /dev/null +++ b/app/credentials/records.py @@ -0,0 +1,220 @@ +"""Versioned encrypted document format and metadata-only owner results.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import ipaddress +import re +from urllib.parse import urlsplit +from uuid import UUID + +from .contracts import CredentialKind, CredentialReference, CredentialSummary +from .errors import VaultError, VaultErrorCode as Code +from .identity import VaultOwner, VaultProfile, VaultScope +from .secrets import Secret + +MAX_ITEMS = 5000 + + +@dataclass(frozen=True) +class CredentialDetails: + summary: CredentialSummary + allowed_origins: tuple[str, ...] + created_at: str + updated_at: str + + +@dataclass(repr=False) +class _Record: + details: CredentialDetails + fields: Secret + + def __repr__(self) -> str: + return "" + + +def validate_fields(kind: CredentialKind, fields: Secret) -> None: + if not isinstance(kind, CredentialKind): + raise VaultError(Code.INVALID) + if not isinstance(fields, Secret) or not isinstance(fields.reveal(), dict): + raise VaultError(Code.INVALID) + if not 1 <= len(fields.reveal()) <= 32: + raise VaultError(Code.INVALID) + for entry in map(Secret, fields.reveal().items()): + if ( + not isinstance(entry.reveal()[0], str) + or not re.fullmatch(r"[a-z][a-z0-9_]{0,63}", entry.reveal()[0]) + or not isinstance(entry.reveal()[1], str) + or not 0 < len(entry.reveal()[1]) <= 32768 + ): + raise VaultError(Code.INVALID) + expected = { + CredentialKind.LOGIN: {"username", "password"}, + CredentialKind.API_KEY: {"api_key"}, + }.get(kind) + if expected is not None and set(fields.reveal()) != expected: + raise VaultError(Code.INVALID) + + +def origins(values: tuple[str, ...]) -> tuple[str, ...]: + """Exact ASCII HTTPS origins; no www folding or inferred login domains. + + These are stored policy metadata, not authorization to issue a request. + Destination/network validation and browser canonicalization belong to CM-03/05. + """ + if not isinstance(values, tuple) or len(values) > 32: + raise VaultError(Code.INVALID) + result = set() + try: + for value in values: + if ( + not isinstance(value, str) + or not value.isascii() + or len(value) > 2048 + or any(char.isspace() or ord(char) < 32 for char in value) + or "\\" in value + ): + raise ValueError() + url = urlsplit(value) + if ( + url.scheme != "https" + or not url.hostname + or url.username is not None + or url.password is not None + or url.path not in ("", "/") + or url.query + or url.fragment + or not re.fullmatch(r"[a-zA-Z0-9.:-]+", url.hostname) + or url.port == 0 + ): + raise ValueError() + host = url.hostname.lower() + if ":" in host: + ipaddress.IPv6Address(host) + host = f"[{host}]" + else: + dns = host[:-1] if host.endswith(".") else host + if len(dns) > 253 or not all( + re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label) + for label in dns.split(".") + ): + raise ValueError() + port = f":{url.port}" if url.port not in (None, 443) else "" + result.add(f"https://{host}{port}") + except ValueError: + raise VaultError(Code.INVALID) from None + return tuple(sorted(result)) + + +def encode(owner: VaultOwner, profile: VaultProfile, records: list[_Record]) -> Secret: + document = Secret( + { + "format": 1, + "owner_id": str(owner.owner_id), + "os_identity": owner.os_identity, + "profile_id": str(profile.scope.profile_id), + "profile_label": profile.label, + "items": [ + { + "id": str(record.details.summary.reference.credential_id), + "version": record.details.summary.reference.version, + "kind": record.details.summary.kind.value, + "service": record.details.summary.service, + "account_label": record.details.summary.account_label, + "origins": record.details.allowed_origins, + "created_at": record.details.created_at, + "updated_at": record.details.updated_at, + "fields": record.fields.reveal(), + } + for record in records + ], + } + ) + return Secret(json.dumps(document.reveal(), ensure_ascii=True).encode("ascii")) + + +def decode(plaintext: Secret, owner: VaultOwner, scope: VaultScope): + """Reject corrupt/unknown documents, never repair by silently dropping data.""" + try: + document = Secret(json.loads(plaintext.reveal())) + if ( + not isinstance(document.reveal(), dict) + or set(document.reveal()) + != { + "format", + "owner_id", + "os_identity", + "profile_id", + "profile_label", + "items", + } + or type(document.reveal()["format"]) is not int + or document.reveal()["format"] != 1 + or document.reveal()["owner_id"] != str(scope.owner_id) + or document.reveal()["profile_id"] != str(scope.profile_id) + or document.reveal()["os_identity"] != owner.os_identity + ): + raise ValueError() + profile = VaultProfile(scope, document.reveal()["profile_label"]) + items = Secret(document.reveal()["items"]) + if not isinstance(items.reveal(), list) or len(items.reveal()) > MAX_ITEMS: + raise ValueError() + seen = set() + records = [] + for raw in map(Secret, items.reveal()): + if not isinstance(raw.reveal(), dict) or set(raw.reveal()) != { + "id", + "version", + "kind", + "service", + "account_label", + "origins", + "created_at", + "updated_at", + "fields", + }: + raise ValueError() + reference = CredentialReference( + scope, UUID(raw.reveal()["id"]), raw.reveal()["version"] + ) + if reference.credential_id in seen: + raise ValueError() + seen.add(reference.credential_id) + summary = CredentialSummary( + reference, + CredentialKind(raw.reveal()["kind"]), + raw.reveal()["service"], + raw.reveal()["account_label"], + ) + fields = Secret(raw.reveal()["fields"]) + validate_fields(summary.kind, fields) + if not isinstance(raw.reveal()["origins"], list): + raise ValueError() + allowed = origins(tuple(raw.reveal()["origins"])) + if summary.kind is CredentialKind.LOGIN and not allowed: + raise ValueError() + for name in ("created_at", "updated_at"): + if not isinstance(raw.reveal()[name], str) or not raw.reveal()[name]: + raise ValueError() + records.append( + _Record( + CredentialDetails( + summary, + allowed, + raw.reveal()["created_at"], + raw.reveal()["updated_at"], + ), + fields, + ) + ) + return profile, records + except ( + ValueError, + TypeError, + KeyError, + AttributeError, + RecursionError, + VaultError, + ): + raise VaultError(Code.UNREADABLE) from None diff --git a/app/credentials/references.py b/app/credentials/references.py new file mode 100644 index 000000000..2abadc424 --- /dev/null +++ b/app/credentials/references.py @@ -0,0 +1,40 @@ +"""Serializable source references. A reference is an identifier, never authority.""" + +from dataclasses import dataclass +import re +from uuid import UUID + +PREFIX = "craftbot-vault://" + + +@dataclass(frozen=True) +class SourceReference: + profile_id: UUID + credential_id: UUID + version: int + + def encode(self): + return f"{PREFIX}{self.profile_id}/{self.credential_id}/{self.version}" + + +def parse(value: str) -> SourceReference: + if not isinstance(value, str) or not value.startswith(PREFIX): + raise ValueError("credential_reference_required") + parts = value[len(PREFIX) :].split("/") + if len(parts) != 3 or not re.fullmatch(r"[1-9][0-9]{0,8}", parts[2]): + raise ValueError("credential_reference_invalid") + result = SourceReference(UUID(parts[0]), UUID(parts[1]), int(parts[2])) + if ( + not result.profile_id.int + or not result.credential_id.int + or result.encode() != value + ): + raise ValueError("credential_reference_invalid") + return result + + +def is_reference(value): + if not isinstance(value, str) or not value.startswith(PREFIX): + return False + parse(value) # Malformed references must never fall through to raw-key use. + return True diff --git a/app/credentials/request_review.py b/app/credentials/request_review.py new file mode 100644 index 000000000..7e5b663bb --- /dev/null +++ b/app/credentials/request_review.py @@ -0,0 +1,74 @@ +"""Host-derived request review. No credential or generated session disclosure.""" + +import hashlib +import json +from urllib.parse import urlsplit + +from .broker import canonical_request +from .helpers import HttpsJsonOperation +from .route_policy import target +from .use_errors import CredentialUseError, UseCode + + +def review_request(specification, payload, *, repeat=False): + _, digest = canonical_request(payload) + body = payload["body"] + destination = specification.endpoint + if type(specification) is HttpsJsonOperation and specification.envelope: + if type(body) is not dict: + raise CredentialUseError(UseCode.DENIED) + parsed = urlsplit(destination) + destination = f"{parsed.scheme}://{parsed.netloc}" + target( + destination, specification.path_fields, body.get("path") + ) + + def visible(value, depth=0): + if depth > 24: + raise CredentialUseError(UseCode.LIMIT) + if type(value) is dict: + # Authentication/session material stays at its existing owner. It + # is bound by the request hash, but never displayed or persisted. + return { + k: "[authentication value hidden]" + if k.lower() + in { + "session", + "authorization", + "access_token", + "access_token_secret", + "refresh_token", + "session_string", + "password", + "api_hash", + "app_secret", + } + else visible(v, depth + 1) + for k, v in value.items() + } + if type(value) is list: + return [visible(v, depth + 1) for v in value] + if isinstance(value, str) and len(value) > 8192: + return { + "content_omitted": True, + "characters": len(value), + "sha256": hashlib.sha256(value.encode("utf-8")).hexdigest(), + } + return value + + details = { + "Destination": destination, + "Method": getattr(specification, "method", "POST"), + "Request SHA256": digest.hex(), + "Scope": "Repeated GET; resource/query parameters may change within this installed route until lease expiry, lock or revocation." + if repeat + else "ONE attempt of this exact request. Changing any parameter requires new approval.", + "Parameters": json.dumps( + visible(body), ensure_ascii=True, sort_keys=True, indent=2 + ), + "Content notice": "Large values over 8192 characters are represented by length and SHA256; approving includes that exact content. Authentication values are hidden.", + } + # Do not truncate destinations, recipients or parameter lists. A request too + # large for complete meaningful review fails closed before owner approval. + if len(json.dumps(details, ensure_ascii=True).encode("utf-8")) > 1_900_000: + raise CredentialUseError(UseCode.LIMIT) + return details diff --git a/app/credentials/resolver.py b/app/credentials/resolver.py new file mode 100644 index 000000000..b5aa49323 --- /dev/null +++ b/app/credentials/resolver.py @@ -0,0 +1,36 @@ +"""Bound DNS waits without passing secret-bearing contexts to resolver threads.""" + +import queue +import socket +from threading import BoundedSemaphore, Thread + +_slots = BoundedSemaphore(4) + + +def _lookup(host, port, output): + try: + try: + result = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM) + except Exception: + result = None + output.put_nowait(result) + finally: + _slots.release() + + +def resolve(host, port): + if not _slots.acquire(blocking=False): + raise TimeoutError("credential_dns_busy") + output = queue.Queue(maxsize=1) + try: + Thread(target=_lookup, args=(host, port, output), daemon=True).start() + except BaseException: + _slots.release() + raise + try: + result = output.get(timeout=10) + except queue.Empty: + raise TimeoutError("credential_dns_timeout") from None + if result is None: + raise OSError("credential_dns_unavailable") + return result diff --git a/app/credentials/route_policy.py b/app/credentials/route_policy.py new file mode 100644 index 000000000..e4b05275c --- /dev/null +++ b/app/credentials/route_policy.py @@ -0,0 +1,99 @@ +"""Public, secret-free matching of sealed resource routes.""" + +import re +from urllib.parse import quote, unquote, urlsplit, parse_qsl + + +def path_names(endpoint): + return tuple(re.findall(r"(?<=/):([a-z][a-z0-9_]*)", urlsplit(endpoint).path)) + + +def parameter(value, name): + if type(value) is int: + value = str(value) + if not isinstance(value, str) or not 1 <= len(value) <= 1024: + raise ValueError("credential_path_invalid") + # Repository references and explicitly declared tail fields can contain + # bounded relative segments. There is never a host, URL, dot traversal, + # encoded slash, query or fragment in a resource parameter. + parts = value.split("/") + maximum = ( + 32 + if name in ("path", "file_path", "operation_name") + else 2 + if name == "owner_repo" + else 1 + ) + if len(parts) > maximum or any( + not re.fullmatch(r"[A-Za-z0-9_@.-]+(?::[A-Za-z0-9_.-]+)*", p) + or p in (".", "..") + for p in parts + ): + raise ValueError("credential_path_invalid") + return "/".join(quote(p, safe="@.:-_") for p in parts) + + +def target(endpoint, names, values): + if type(values) is not dict or set(values) != set(names): + raise ValueError("credential_path_invalid") + path = urlsplit(endpoint).path + for name in names: + path = re.sub( + r"/:(" + re.escape(name) + r")(?=/|$|:)", + lambda _: "/" + parameter(values[name], name), + path, + ) + return path or "/" + + +def match_route(routes, method, url): + parsed = urlsplit(url) + if ( + parsed.username is not None + or parsed.password is not None + or parsed.fragment + or parsed.scheme != "https" + ): + return None + for route in routes: + base = urlsplit(route["endpoint"]) + if ( + route["method"] != method + or base.scheme != parsed.scheme + or base.netloc != parsed.netloc + ): + continue + names = tuple(route.get("path_fields", ())) + pattern = re.escape(base.path) + for name in names: + pattern = pattern.replace( + re.escape(":" + name), + "(?P<" + + name + + ">" + + ( + ".+" + if name in ("owner_repo", "path", "file_path", "operation_name") + else "[^/]+" + ) + + ")", + 1, + ) + found = re.fullmatch(pattern, parsed.path) + if not found: + continue + try: + values = {k: unquote(v) for k, v in found.groupdict().items()} + # Round-trip canonicalization closes encoded separators and aliases. + if target(route["endpoint"], names, values) != parsed.path: + continue + pairs = parse_qsl(parsed.query, keep_blank_values=True, strict_parsing=True) + if len(dict(pairs)) != len(pairs) or any( + k.split("[", 1)[0] not in route.get("query_fields", ()) + for k, v in pairs + ): + continue + except ValueError: + continue + return route, values, dict(pairs) + return None diff --git a/app/credentials/secrets.py b/app/credentials/secrets.py new file mode 100644 index 000000000..b932358bf --- /dev/null +++ b/app/credentials/secrets.py @@ -0,0 +1,36 @@ +"""Redacting holders shared with the legacy browser vault. + +Internal host code only: this is not an agent API or an isolation mechanism. +Managed Python memory cannot provide guaranteed zeroization. +""" + +from typing import Any + + +class Secret: + """Wrap immediately at secure entry; never print or pickle the value.""" + + __slots__ = ("_value",) + + def __init__(self, value: Any) -> None: + self._value = value + + @classmethod + def wrap(cls, value: Any) -> "Secret": + return value if isinstance(value, Secret) else cls(value) + + def reveal(self) -> Any: + """Private-boundary plumbing; never expose through a consumer route.""" + return self._value + + def __bool__(self) -> bool: + return bool(self._value) + + def __repr__(self) -> str: + return "Secret('[redacted]')" + + def __str__(self) -> str: + return "[redacted]" + + def __reduce__(self): + raise TypeError("Secret values cannot be pickled") diff --git a/app/credentials/settings.py b/app/credentials/settings.py new file mode 100644 index 000000000..9d768f6c4 --- /dev/null +++ b/app/credentials/settings.py @@ -0,0 +1,65 @@ +"""Metadata-only UI adapter. The native service verifies every sensitive act. + +Never forward an arbitrary operation, user-provided context, source value or +capability through WebSocket. Replies belong only to the requesting connection. +""" + +from __future__ import annotations + +import asyncio + +from .client import CredentialHostUnavailable, request + +FIELDS = { + "profiles": (), + "action_profile": (), + "action_profile_set": ("profile_id",), + "profile_add": ("label",), + "initialize": ("profile_id",), + "unlock": ("profile_id",), + "lock": ("profile_id",), + "items": ("profile_id",), + "items_page": ("profile_id", "view_id", "offset"), + "activity": ("profile_id",), + "revoke": ("profile_id", "grant_id"), + "entry": ("profile_id", "kind", "service", "account", "origins", "fields"), + "edit": ( + "profile_id", + "credential_id", + "version", + "service", + "account", + "origins", + "replace_fields", + ), + "delete": ("profile_id", "credential_id", "version"), + "view": ("profile_id", "credential_id", "version"), +} + + +async def handle(adapter, ws, data): + operation = data.get("operation") if type(data) is dict else None + parameters = data.get("parameters", {}) if type(data) is dict else {} + request_id = data.get("request_id") if type(data) is dict else None + result = { + "operation": operation if isinstance(operation, str) else None, + "ok": False, + "error": "credential_use_denied", + } + if ( + isinstance(operation, str) + and operation in FIELDS + and type(parameters) is dict + and set(parameters) == set(FIELDS[operation]) + ): + try: + value = await asyncio.to_thread(request, operation, **parameters) + result = {"operation": operation, "ok": True, "value": value} + except CredentialHostUnavailable as error: + result["error"] = str(error) # Client already enforces fixed codes. + except Exception: + result["error"] = "credential_host_unavailable" + result["request_id"] = ( + request_id if isinstance(request_id, str) and len(request_id) <= 64 else None + ) + adapter._send_only_to(ws, {"type": "credentials_result", "data": result}) diff --git a/app/credentials/storage.py b/app/credentials/storage.py new file mode 100644 index 000000000..d49d699aa --- /dev/null +++ b/app/credentials/storage.py @@ -0,0 +1,135 @@ +"""Atomic profile snapshots. SQLite journals contain encrypted blobs only. + +One connection per operation, with BEGIN IMMEDIATE for cross-process writers. +No database, temporary file or backup ever holds plaintext vault data or keys. +""" + +from __future__ import annotations + +from contextlib import contextmanager +import os +from pathlib import Path +import sqlite3 +import stat + +from .errors import VaultError, VaultErrorCode as Code +from .identity import VaultScope + +MAX_SNAPSHOT_BYTES = 16 * 1024 * 1024 + + +def _private(path: Path, *, directory: bool) -> None: + info = path.lstat() + if stat.S_ISLNK(info.st_mode) or getattr(info, "st_file_attributes", 0) & 0x400: + raise VaultError(Code.IO) # Includes Windows junctions/reparse points. + if not (stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode)): + raise VaultError(Code.IO) + if os.name == "nt": + try: + import win32security + + # Grant the object's owner and SYSTEM full access; inherit to children. + sd = win32security.ConvertStringSecurityDescriptorToSecurityDescriptor( + "D:P(A;OICI;FA;;;OW)(A;OICI;FA;;;SY)", 1 + ) + win32security.SetFileSecurity( + str(path), + win32security.DACL_SECURITY_INFORMATION + | win32security.PROTECTED_DACL_SECURITY_INFORMATION, + sd, + ) + except Exception: + raise VaultError(Code.BACKEND_UNAVAILABLE) from None + else: + if info.st_uid != os.geteuid(): + raise VaultError(Code.IO) + os.chmod(path, 0o700 if directory else 0o600) + + +class SnapshotStore: + def __init__(self, root: Path, scope: VaultScope) -> None: + # Explicit host-owned root; never default to legacy .credentials or scan it. + self.root = Path(root).absolute() + self.directory = self.root / str(scope.owner_id) / str(scope.profile_id) + self.path = self.directory / "vault.sqlite3" + + @contextmanager + def transaction(self, *, write: bool = False, initialize: bool = False): + connection = None + try: + if initialize: + for directory in (self.root, self.directory.parent, self.directory): + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + _private(directory, directory=True) + else: + for directory in (self.root, self.directory.parent, self.directory): + _private(directory, directory=True) + if self.path.exists(): + _private(self.path, directory=False) + elif not initialize: + raise VaultError(Code.UNREADABLE) + connection = sqlite3.connect( + str(self.path) if initialize else self.path.as_uri() + "?mode=rw", + timeout=5, + uri=not initialize, + ) + _private(self.path, directory=False) + connection.execute("PRAGMA synchronous=FULL") + connection.execute("BEGIN IMMEDIATE" if write or initialize else "BEGIN") + tables = { + row[0] + for row in connection.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ) + } + version = connection.execute("PRAGMA user_version").fetchone()[0] + if initialize and not tables and version == 0: + connection.execute( + "CREATE TABLE vault (id INTEGER PRIMARY KEY CHECK(id=1), " + "current BLOB NOT NULL, previous BLOB)" + ) + connection.execute("PRAGMA user_version=1") + elif tables != {"vault"} or version != 1: + raise VaultError(Code.UNREADABLE) + elif initialize: + raise VaultError( + Code.ALREADY_EXISTS + if connection.execute("SELECT 1 FROM vault WHERE id=1").fetchone() + else Code.UNREADABLE + ) + yield connection + connection.commit() + except sqlite3.Error as exc: + code = getattr(exc, "sqlite_errorcode", 0) & 0xFF + raise VaultError( + Code.UNREADABLE + if code in (sqlite3.SQLITE_CORRUPT, sqlite3.SQLITE_NOTADB) + else Code.IO + ) from None + except OSError: + raise VaultError(Code.IO) from None + finally: + if connection is not None: + connection.close() # Roll back any failed transaction. + + @staticmethod + def read(connection) -> bytes: + row = connection.execute( + "SELECT length(current) FROM vault WHERE id=1" + ).fetchone() + if row is None or not 0 < row[0] <= MAX_SNAPSHOT_BYTES: + raise VaultError(Code.UNREADABLE) + row = connection.execute("SELECT current FROM vault WHERE id=1").fetchone() + if not isinstance(row[0], bytes): + raise VaultError(Code.UNREADABLE) + return row[0] + + @staticmethod + def write(connection, encrypted: bytes) -> None: + if not 0 < len(encrypted) <= MAX_SNAPSHOT_BYTES: + raise VaultError(Code.INVALID) + connection.execute( + "INSERT INTO vault(id,current) VALUES(1,?) " + "ON CONFLICT(id) DO UPDATE SET previous=current, current=excluded.current", + (encrypted,), + ) diff --git a/app/credentials/streaming.py b/app/credentials/streaming.py new file mode 100644 index 000000000..b24488050 --- /dev/null +++ b/app/credentials/streaming.py @@ -0,0 +1,127 @@ +"""Validated SSE relay with bounded semantic source-echo holdback.""" + +import codecs +import json +import re +from .helpers import _response_variants, _sanitize_json_payloads +from .use_errors import CredentialUseError, UseCode + + +class StreamFilter: + def __init__(self, fields, emit): + self.fields, self.emit = fields, emit + self.patterns = _response_variants(fields) + self.tables = [] + for pattern in self.patterns.reveal(): + table, matched = [0] * len(pattern), 0 + for i in range(1, len(pattern)): + while matched and pattern[i] != pattern[matched]: + matched = table[matched - 1] + if pattern[i] == pattern[matched]: + matched += 1 + table[i] = matched + self.tables.append(table) + self.states, self.pending = {}, [] + self.pending_size = 0 + + def _strings(self, value, path=()): + if type(value) is dict: + for key, child in value.items(): + yield from self._strings(child, (*path, key)) + elif type(value) is list: + for index, child in enumerate(value): + yield from self._strings(child, (*path, index)) + elif ( + isinstance(value, str) + and path + and path[-1] + in ( + "content", + "text", + "arguments", + "partial_json", + "thinking", + "refusal", + "delta", + "signature", + ) + ): + yield path, value + + def feed(self, record): + event, data = "", [] + for line in record.splitlines(): + if line.startswith("data:"): + data.append(line[5:].lstrip(" ")) + elif line.startswith("event:") and re.fullmatch( + r"event: ?[A-Za-z0-9_.-]{1,64}", line + ): + event = line + "\n" + elif line and not line.startswith(":"): + raise CredentialUseError(UseCode.HELPER) + if not data: + return + raw = "\n".join(data) + if raw == "[DONE]": + self.flush() + self.emit("data: [DONE]\n\n") + return + cleaned = _sanitize_json_payloads([json.loads(raw)], self.fields)[0] + for path, value in self._strings(json.loads(cleaned)): + if len(self.states) > 2048: + raise CredentialUseError(UseCode.LIMIT) + states = self.states.setdefault(path, [0] * len(self.tables)) + for index, (pattern, table) in enumerate( + zip(self.patterns.reveal(), self.tables) + ): + state = states[index] + for char in value: + while state and char != pattern[state]: + state = table[state - 1] + if char == pattern[state]: + state += 1 + if state == len(pattern): + # The entire echo is still in pending frames. Never + # release even the first source fragment to the SDK. + raise CredentialUseError(UseCode.HELPER) + states[index] = state + frame = event + "data: " + cleaned + "\n\n" + self.pending.append(frame) + self.pending_size += len(frame) + if self.pending_size > 1_000_000: + raise CredentialUseError(UseCode.LIMIT) + if not any(any(states) for states in self.states.values()): + self.flush() + + def flush(self): + for frame in self.pending: + self.emit(frame) + self.pending, self.pending_size = [], 0 + + +def relay(response, fields, execution, maximum): + decoder = codecs.getincrementaldecoder("utf-8")("strict") + buffer, total = "", 0 + filtering = StreamFilter(fields, execution.emit) + while True: + if execution.cancelled.is_set(): + raise CredentialUseError(UseCode.INTERRUPTED) + chunk = response.read1(8192) + if not chunk: + buffer += decoder.decode(b"", final=True) + break + total += len(chunk) + if total > maximum: + raise CredentialUseError(UseCode.LIMIT) + buffer += decoder.decode(chunk) + # SSE line endings are normalized after decoding; a CRLF split across + # TLS reads is retained until the following chunk arrives. + buffer = buffer.replace("\r\n", "\n") + while "\n\n" in buffer: + record, buffer = buffer.split("\n\n", 1) + filtering.feed(record) + if len(buffer) > 1_000_000: + raise CredentialUseError(UseCode.LIMIT) + if buffer.strip(): + filtering.feed(buffer) + filtering.flush() diff --git a/app/credentials/twitter_helper.py b/app/credentials/twitter_helper.py new file mode 100644 index 000000000..376aad657 --- /dev/null +++ b/app/credentials/twitter_helper.py @@ -0,0 +1,83 @@ +"""Host-only OAuth 1.0a signing for sealed Twitter REST operations. + +App key/secret remain in the vault. The generated user token pair is supplied +per operation by its existing account owner; no signer or header is exposed. +""" + +import base64 +import hashlib +import hmac +import secrets +import time +from urllib.parse import parse_qsl, quote + +from .secrets import Secret +from .use_errors import CredentialUseError, UseCode + + +def authorization(url, method, query, fields, session): + if type(session) is not dict or set(session) != { + "access_token", + "access_token_secret", + }: + raise CredentialUseError(UseCode.DENIED) + private = Secret({**fields.reveal(), **session}) + if set(fields.reveal()) != {"api_key", "api_secret"} or any( + not isinstance(v, str) + or not 1 <= len(v) <= 4096 + or any(ord(c) < 33 or ord(c) > 126 for c in v) + for v in private.reveal().values() + ): + raise CredentialUseError(UseCode.DENIED) + oauth = Secret( + { + "oauth_consumer_key": private.reveal()["api_key"], + "oauth_token": private.reveal()["access_token"], + "oauth_nonce": secrets.token_hex(16), + "oauth_timestamp": str(int(time.time())), + "oauth_signature_method": "HMAC-SHA1", + "oauth_version": "1.0", + } + ) + pairs = parse_qsl(query, keep_blank_values=True) + if any(k.startswith("oauth_") for k, _ in pairs): + raise CredentialUseError(UseCode.DENIED) + normalized = Secret( + "&".join( + k + "=" + v + for k, v in sorted( + (quote(k, safe=""), quote(v, safe="")) + for k, v in [*pairs, *oauth.reveal().items()] + ) + ) + ) + base = Secret( + "&".join(quote(v, safe="") for v in (method, url, normalized.reveal())) + ) + key = Secret( + quote(private.reveal()["api_secret"], safe="") + + "&" + + quote(private.reveal()["access_token_secret"], safe="") + ) + signature = Secret( + base64.b64encode( + hmac.new( + key.reveal().encode(), base.reveal().encode(), hashlib.sha1 + ).digest() + ).decode() + ) + oauth.reveal()["oauth_signature"] = signature.reveal() + header = Secret( + "OAuth " + + ", ".join( + quote(k, safe="") + '="' + quote(v, safe="") + '"' + for k, v in sorted(oauth.reveal().items()) + ) + ) + return header, Secret( + { + **private.reveal(), + "signature": signature.reveal(), + "signing_key": key.reveal(), + } + ) diff --git a/app/credentials/use_errors.py b/app/credentials/use_errors.py new file mode 100644 index 000000000..02c9dc6e6 --- /dev/null +++ b/app/credentials/use_errors.py @@ -0,0 +1,64 @@ +"""Public helper errors discard secret-bearing traceback frames and contexts.""" + +from enum import Enum +from functools import wraps + +from .contracts import IsolationUnavailable +from .directory import IdentityError +from .errors import VaultError, VaultErrorCode + + +class UseCode(str, Enum): + DENIED = "credential_use_denied" + EXPIRED = "credential_grant_expired" + CAPABILITY = "credential_capability_invalid" + ISOLATION = "credential_isolation_unavailable" + VERIFICATION = "owner_verification_required" + LOCKED = "vault_locked" + CHANGED = "credential_changed" + UNAVAILABLE = "credential_unavailable" + HELPER = "credential_helper_failed" + LIMIT = "credential_use_limit" + INTERRUPTED = "credential_use_interrupted" + BUSY = "credential_busy" + + +class CredentialUseError(RuntimeError): + def __init__(self, code: UseCode): + self.code = code + super().__init__(code.value) + + +def use_boundary(operation): + @wraps(operation) + def sanitized(*args, **kwargs): + interrupted = None + try: + return operation(*args, **kwargs) + except CredentialUseError as error: + code = error.code + except IsolationUnavailable: + code = UseCode.ISOLATION + except IdentityError: + code = UseCode.DENIED + except VaultError as error: + code = { + VaultErrorCode.LOCKED: UseCode.LOCKED, + VaultErrorCode.CONFLICT: UseCode.CHANGED, + VaultErrorCode.VERIFICATION_REQUIRED: UseCode.VERIFICATION, + VaultErrorCode.SCOPE_DENIED: UseCode.DENIED, + }.get(error.code, UseCode.UNAVAILABLE) + except Exception: + code = UseCode.HELPER + except (KeyboardInterrupt, SystemExit, GeneratorExit) as error: + interrupted = type(error) + except BaseException: + code = UseCode.INTERRUPTED + # Outside the except block: diagnostic loggers must not see inner frames. + if interrupted is SystemExit: + raise SystemExit(1) from None + if interrupted is not None: + raise interrupted() from None + raise CredentialUseError(code) from None + + return sanitized diff --git a/app/credentials/vault.py b/app/credentials/vault.py new file mode 100644 index 000000000..01b61fbfa --- /dev/null +++ b/app/credentials/vault.py @@ -0,0 +1,540 @@ +"""Shared, host-only source-credential vault. No consumer routes. + +Keys live in an injected OS facility; snapshots contain authenticated ciphertext +only. Owner verification is mandatory, with no working default verifier. The +service must own one instance per profile; SQLite serializes other-process writes. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from dataclasses import dataclass, replace +from datetime import datetime, timezone +from enum import Enum +import hashlib +import hmac +import math +from pathlib import Path +from threading import RLock, Timer +import time +from uuid import UUID, uuid4 + +from .contracts import CredentialKind, CredentialReference, CredentialSummary +from .errors import VaultError, VaultErrorCode as Code, sanitized_errors +from .identity import VaultOwner, VaultProfile +from .keys import ( + KeyStore, + OwnerVerifier, + VerificationUnavailable, + WindowsCredentialKeyStore, +) +from .records import ( + CredentialDetails, + MAX_ITEMS, + _Record, + decode, + encode, + origins, + validate_fields, +) +from .secrets import Secret +from .storage import SnapshotStore +from .use_errors import CredentialUseError + + +class VaultState(str, Enum): + LOCKED = "locked" + READY = "ready" + UNREADABLE = "unreadable" + UNAVAILABLE = "unavailable" + + +@dataclass(frozen=True) +class VaultStatus: + state: VaultState + unlock_epoch: UUID | None + + +@dataclass(frozen=True) +class SourceImport: + credential_id: UUID + kind: CredentialKind + service: str + account_label: str + fields: Secret + allowed_origins: tuple[str, ...] + + +def _cipher(key: Secret): + try: + from cryptography.fernet import Fernet + except ImportError: + raise VaultError(Code.BACKEND_UNAVAILABLE) from None + try: + return Secret(Fernet(key.reveal())) + except (TypeError, ValueError): + raise VaultError(Code.UNREADABLE) from None + + +def _new_key() -> Secret: + try: + from cryptography.fernet import Fernet + except ImportError: + raise VaultError(Code.BACKEND_UNAVAILABLE) from None + return Secret(Fernet.generate_key()) + + +class ProfileVault: + def __init__( + self, + root: Path, + owner: VaultOwner, + profile: VaultProfile, + *, + key_store: KeyStore | None = None, + verifier: OwnerVerifier | None = None, + idle_seconds: float = 900, + clock=time.monotonic, + ) -> None: + if owner.owner_id != profile.scope.owner_id: + raise VaultError(Code.SCOPE_DENIED) + if not math.isfinite(idle_seconds) or idle_seconds <= 0: + raise VaultError(Code.INVALID) + self.owner = owner + self.profile = profile + self.store = SnapshotStore(root, profile.scope) + self._keys = key_store if key_store is not None else WindowsCredentialKeyStore() + self._verifier = verifier if verifier is not None else VerificationUnavailable() + self._idle = idle_seconds + self._clock = clock + self._lock = RLock() + self._crypto: Secret | None = None + self._key_tag: Secret | None = None + self._state = VaultState.LOCKED + self._epoch: UUID | None = None + self._deadline = 0.0 + self._timer: Timer | None = None + self._on_lock = ( + None # Protected host cancellation hook; never a consumer callback. + ) + + def _clear(self, state: VaultState = VaultState.LOCKED) -> None: + was_ready = self._state is VaultState.READY + if self._timer is not None: + self._timer.cancel() + self._timer = None + self._crypto = self._key_tag = None + self._epoch = None + self._deadline = 0.0 + self._state = state + if was_ready and self._on_lock is not None: + self._on_lock() + + def lock(self) -> None: + """Also used by host OS-lock/sleep/app-exit hooks; no session sign-out.""" + with self._lock: + self._clear() + + close = lock + + def _expire(self) -> None: + with self._lock: + if self._state is VaultState.READY: + if self._clock() >= self._deadline: + self._clear() + else: + self._schedule_lock() + + def _schedule_lock(self) -> None: + if self._timer is not None: + self._timer.cancel() + self._timer = Timer(max(0.001, self._deadline - self._clock()), self._expire) + self._timer.daemon = True + self._timer.start() + + def _activate(self, key: Secret) -> None: + self._crypto = _cipher(key) + self._key_tag = Secret(hashlib.sha256(key.reveal()).digest()) + self._epoch = uuid4() + self._state = VaultState.READY + self._deadline = self._clock() + self._idle + self._schedule_lock() + + def status(self) -> VaultStatus: + with self._lock: + if self._state is VaultState.READY and self._clock() >= self._deadline: + self._clear() + return VaultStatus(self._state, self._epoch) + + def owner_activity(self) -> None: + """Trusted owner UI events only; secret use never refreshes this timer.""" + with self._lock: + self._require_ready() + self._deadline = self._clock() + self._idle + self._schedule_lock() + + def _require_ready(self) -> None: + if self._state is VaultState.READY and self._clock() >= self._deadline: + self._clear() + if self._state is not VaultState.READY: + raise VaultError(Code.LOCKED) + + def _verify_owner(self) -> None: + try: + if self._verifier.verify(self.owner) is not None: + raise VaultError(Code.VERIFICATION_REQUIRED) + except VaultError: + raise + except Exception: + raise VaultError(Code.VERIFICATION_REQUIRED) from None + + def _load_key(self) -> Secret | None: + try: + return self._keys.load(self.profile.scope) + except VaultError: + raise + except Exception: + raise VaultError(Code.BACKEND_UNAVAILABLE) from None + + def _create_key(self, key: Secret) -> None: + try: + self._keys.create(self.profile.scope, key) + except VaultError: + raise + except Exception: + raise VaultError(Code.BACKEND_UNAVAILABLE) from None + + @contextmanager + def _opening(self): + with self._lock: + self._clear() + try: + self._verify_owner() + yield + except VaultError as exc: + if exc.code is Code.UNREADABLE: + self._clear(VaultState.UNREADABLE) + elif exc.code in (Code.IO, Code.BACKEND_UNAVAILABLE): + self._clear(VaultState.UNAVAILABLE) + else: + self._clear() + raise + except Exception: + self._clear(VaultState.UNAVAILABLE) + raise VaultError(Code.IO) from None + except BaseException: + self._clear() + raise + + @sanitized_errors + def initialize(self) -> VaultStatus: + """Explicit owner action; never called implicitly by a read/unlock.""" + with self._opening(): + # An existing OS key without a DB may be lost data: never reset it. + if not self.store.path.exists() and self._load_key() is not None: + raise VaultError(Code.UNREADABLE) + with self.store.transaction(initialize=True, write=True) as connection: + if ( + connection.execute("SELECT 1 FROM vault LIMIT 1").fetchone() + is not None + ): + raise VaultError(Code.ALREADY_EXISTS) + key = self._load_key() + if key is None: + key = _new_key() + self._create_key(key) + crypto = _cipher(key) + SnapshotStore.write( + connection, + crypto.reveal().encrypt( + encode(self.owner, self.profile, []).reveal() + ), + ) + self._activate(key) + return self.status() + + @sanitized_errors + def unlock(self) -> VaultStatus: + with self._opening(): + if not self.store.path.is_file(): + raise VaultError(Code.UNREADABLE) + key = self._load_key() + if key is None: + raise VaultError(Code.UNREADABLE) + self._crypto = _cipher(key) + with self.store.transaction() as connection: + self._read(connection) # Verify decrypt/schema/scope before READY. + self._activate(key) + return self.status() + + def _read(self, connection): + try: + plaintext = Secret( + self._crypto.reveal().decrypt(SnapshotStore.read(connection)) + ) + except VaultError: + raise + except Exception: + raise VaultError(Code.UNREADABLE) from None + profile, records = decode(plaintext, self.owner, self.profile.scope) + self.profile = profile + return records + + @contextmanager + def _operation(self, *, write=False, helper=False): + with self._lock: + self._require_ready() + try: + key = self._load_key() + if key is None or not hmac.compare_digest( + self._key_tag.reveal(), hashlib.sha256(key.reveal()).digest() + ): + raise VaultError(Code.UNREADABLE) + # Helpers retain the writer reservation to prevent another + # process changing/deleting the version before dispatch. + with self.store.transaction(write=write or helper) as connection: + records = self._read(connection) + yield connection, records + if write: + SnapshotStore.write( + connection, + self._crypto.reveal().encrypt( + encode(self.owner, self.profile, records).reveal() + ), + ) + except CredentialUseError: + # Policy/network failures do not indicate storage corruption. + raise + except VaultError as exc: + if exc.code is Code.UNREADABLE: + self._clear(VaultState.UNREADABLE) + elif exc.code in (Code.IO, Code.BACKEND_UNAVAILABLE): + self._clear(VaultState.UNAVAILABLE) + raise + except Exception: + self._clear(VaultState.UNAVAILABLE) + raise VaultError(Code.IO) from None + except BaseException: + self._clear() + raise + + @sanitized_errors + def list_items(self) -> tuple[CredentialDetails, ...]: + with self._operation() as (_, records): + return tuple( + sorted( + (record.details for record in records), + key=lambda item: ( + item.summary.service, + item.summary.account_label, + str(item.summary.reference.credential_id), + ), + ) + ) + + @sanitized_errors + def create( + self, + kind: CredentialKind, + service: str, + account_label: str, + fields: Secret, + *, + allowed_origins: tuple[str, ...] = (), + ) -> CredentialDetails: + """Secure owner-entry adapter must wrap fields before calling this API.""" + if not isinstance(fields, Secret) or not isinstance(fields.reveal(), dict): + raise VaultError(Code.INVALID) + fields = Secret(dict(fields.reveal())) + validate_fields(kind, fields) + allowed = origins(allowed_origins) + if kind is CredentialKind.LOGIN and not allowed: + raise VaultError(Code.INVALID) + try: + summary = CredentialSummary( + CredentialReference(self.profile.scope, uuid4(), 1), + kind, + service, + account_label, + ) + except ValueError: + raise VaultError(Code.INVALID) from None + now = datetime.now(timezone.utc).isoformat() + details = CredentialDetails(summary, allowed, now, now) + with self._operation(write=True) as (_, records): + if len(records) >= MAX_ITEMS: + raise VaultError(Code.INVALID) + records.append(_Record(details, fields)) + return details + + def _find(self, reference: CredentialReference, records: list[_Record]) -> _Record: + if reference.scope != self.profile.scope: + raise VaultError(Code.SCOPE_DENIED) + for record in records: + existing = record.details.summary.reference + if existing.credential_id == reference.credential_id: + if existing.version != reference.version: + raise VaultError(Code.CONFLICT) + return record + raise VaultError(Code.NOT_FOUND) + + @sanitized_errors + def import_source( + self, + credential_id: UUID, + kind: CredentialKind, + service: str, + account_label: str, + fields: Secret, + *, + allowed_origins: tuple[str, ...], + ): + """Host migration only. Stable IDs make interrupted imports retryable. + + An existing ID is accepted only for exactly the same source and policy; + migration never overwrites an edited item or silently creates duplicates. + """ + return self.import_sources( + ( + SourceImport( + credential_id, kind, service, account_label, fields, allowed_origins + ), + ) + )[0] + + @sanitized_errors + def import_sources(self, values: tuple[SourceImport, ...]): + """One encrypted transaction and read-back for a bounded migration batch.""" + if type(values) is not tuple or len(values) > MAX_ITEMS: + raise VaultError(Code.INVALID) + now = datetime.now(timezone.utc).isoformat() + incoming, results = {}, [] + for value in values: + if ( + type(value) is not SourceImport + or value.credential_id in incoming + or not isinstance(value.fields, Secret) + or type(value.fields.reveal()) is not dict + ): + raise VaultError(Code.INVALID) + fields = Secret(dict(value.fields.reveal())) + validate_fields(value.kind, fields) + allowed = origins(value.allowed_origins) + if value.kind is CredentialKind.LOGIN and not allowed: + raise VaultError(Code.INVALID) + summary = CredentialSummary( + CredentialReference(self.profile.scope, value.credential_id, 1), + value.kind, + value.service, + value.account_label, + ) + incoming[value.credential_id] = _Record( + CredentialDetails(summary, allowed, now, now), fields + ) + with self._operation(write=True) as (_, records): + existing = {r.details.summary.reference.credential_id: r for r in records} + if len(set(existing) | set(incoming)) > MAX_ITEMS: + raise VaultError(Code.INVALID) + for key, record in incoming.items(): + old = existing.get(key) + if old is not None: + if ( + old.details.summary != record.details.summary + or old.details.allowed_origins != record.details.allowed_origins + or old.fields.reveal() != record.fields.reveal() + ): + raise VaultError(Code.CONFLICT) + results.append(old.details) + else: + records.append(record) + results.append(record.details) + # Verify every imported source from the committed encrypted document. + with self._operation() as (_, records): + verified = {r.details.summary.reference.credential_id: r for r in records} + for key, record in incoming.items(): + if ( + key not in verified + or verified[key].details.summary != record.details.summary + or verified[key].fields.reveal() != record.fields.reveal() + ): + raise VaultError(Code.UNREADABLE) + return tuple(results) + + @contextmanager + def _helper_record(self, reference: CredentialReference, epoch: UUID): + """Broker internals only, under authorization and isolation checks. + + Keep same-instance updates/lock serialized until dispatch completes. + There is deliberately no public read/reveal API or caller callback. + """ + with self._operation(helper=True) as (_, records): + self._require_epoch(epoch) + yield self._find(reference, records) + + def _require_epoch(self, epoch: UUID) -> None: + self._require_ready() + if self._epoch != epoch: + raise VaultError(Code.LOCKED) + + @sanitized_errors + def update( + self, + reference: CredentialReference, + *, + service: str | None = None, + account_label: str | None = None, + fields: Secret | None = None, + allowed_origins: tuple[str, ...] | None = None, + ) -> CredentialDetails: + with self._operation(write=True) as (_, records): + record = self._find(reference, records) + merged = Secret(dict(record.fields.reveal())) + if fields is not None: + if not isinstance(fields, Secret) or not isinstance( + fields.reveal(), dict + ): + raise VaultError(Code.INVALID) + # Empty fields preserve existing values; no implicit secret removal. + merged.reveal().update( + { + key: value + for key, value in fields.reveal().items() + if value != "" + } + ) + validate_fields(record.details.summary.kind, merged) + allowed = ( + record.details.allowed_origins + if allowed_origins is None + else origins(allowed_origins) + ) + if record.details.summary.kind is CredentialKind.LOGIN and not allowed: + raise VaultError(Code.INVALID) + try: + summary = replace( + record.details.summary, + reference=replace(reference, version=reference.version + 1), + service=record.details.summary.service + if service is None + else service, + account_label=( + record.details.summary.account_label + if account_label is None + else account_label + ), + ) + except ValueError: + raise VaultError(Code.INVALID) from None + record.details = replace( + record.details, + summary=summary, + allowed_origins=allowed, + updated_at=datetime.now(timezone.utc).isoformat(), + ) + record.fields = merged + return record.details + + @sanitized_errors + def delete(self, reference: CredentialReference) -> None: + with self._operation(write=True) as (_, records): + records.remove(self._find(reference, records)) diff --git a/app/credentials/windows_host.py b/app/credentials/windows_host.py new file mode 100644 index 000000000..85fd60463 --- /dev/null +++ b/app/credentials/windows_host.py @@ -0,0 +1,475 @@ +"""Windows kernel boundary. Imported only by the installed SYSTEM worker. + +No attestation in a client message is used here. Handles, tokens and job +membership are queried by the host and retained for the lifetime of a launch. +""" + +from __future__ import annotations + +import ctypes as C +from contextlib import closing +from ctypes import wintypes as W +import os +from pathlib import Path +import stat +import subprocess +from threading import RLock +from uuid import uuid4 + +from .contracts import IsolationUnavailable + +SYSTEM = "S-1-5-18" +ADMIN = "S-1-5-32-544" +WRITE = 0x500D0116 # generic write/all, delete, write DACL/owner, file mutations + + +def api(): + if os.name != "nt": + raise IsolationUnavailable() + import win32api + import win32con + import win32job + import win32process + import win32security + import win32ts + import win32event + + return ( + win32api, + win32con, + win32job, + win32process, + win32security, + win32ts, + win32event, + ) + + +def native_sid(pointer): + dll = C.WinDLL("advapi32", use_last_error=True) + dll.GetLengthSid.argtypes = [C.c_void_p] + dll.GetLengthSid.restype = W.DWORD + security = api()[4] + return security.ConvertSidToStringSid( + security.SID(C.string_at(pointer, dll.GetLengthSid(pointer))) + ) + + +def token_value(token, information, *, pointer=False): + dll = C.WinDLL("advapi32", use_last_error=True) + dll.GetTokenInformation.argtypes = [ + W.HANDLE, + C.c_int, + C.c_void_p, + W.DWORD, + C.POINTER(W.DWORD), + ] + size = W.DWORD() + dll.GetTokenInformation(int(token), information, None, 0, C.byref(size)) + if not 4 <= size.value <= 4096: + raise IsolationUnavailable() + buffer = C.create_string_buffer(size.value) + if not dll.GetTokenInformation( + int(token), information, buffer, len(buffer), C.byref(size) + ): + raise IsolationUnavailable() + value = (C.c_void_p if pointer else W.DWORD).from_buffer(buffer) + return native_sid(value) if pointer and value.value else value.value + + +def private_acl(path: Path, sid: str | None = None, *, writable=False): + security = api()[4] + extra = f"(A;OICI;{'0x1301bf' if writable else 'GRGX'};;;{sid})" if sid else "" + descriptor = security.ConvertStringSecurityDescriptorToSecurityDescriptor( + "O:SYG:SYD:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)" + + extra + + ("S:(ML;OICI;NW;;;LW)" if writable else ""), + 1, + ) + security.SetFileSecurity( + str(path), + security.DACL_SECURITY_INFORMATION + | security.PROTECTED_DACL_SECURITY_INFORMATION + | security.OWNER_SECURITY_INFORMATION + | (0x10 if writable else 0), # LABEL_SECURITY_INFORMATION, scratch only. + descriptor, + ) + + +def check_install(root: Path, state: Path): + """SYSTEM only; reject writable code/dependencies, links and foreign owners.""" + security = api()[4] + with closing(security.OpenProcessToken(api()[0].GetCurrentProcess(), 8)) as token: + if ( + security.ConvertSidToStringSid(security.GetTokenInformation(token, 1)[0]) + != SYSTEM + ): + raise IsolationUnavailable() + for base in (root, state): + for path in (base, *base.parents): + info = path.lstat() + if ( + stat.S_ISLNK(info.st_mode) + or getattr(info, "st_file_attributes", 0) & 0x400 + ): + raise IsolationUnavailable() + # All files loaded by the worker/runtime must be protected, not only + # the executable. The installer sets these ACLs before registering SCM. + paths = [base, *base.rglob("*")] if base == root else [base] + for path in paths: + info = path.lstat() + if getattr(info, "st_file_attributes", 0) & 0x400: + raise IsolationUnavailable() + sd = security.GetFileSecurity(str(path), 1 | 4) + if security.ConvertSidToStringSid(sd.GetSecurityDescriptorOwner()) not in ( + SYSTEM, + ADMIN, + ): + raise IsolationUnavailable() + dacl = sd.GetSecurityDescriptorDacl() + if dacl is None: + raise IsolationUnavailable() + for index in range(dacl.GetAceCount()): + ace = dacl.GetAce(index) + if ace[0][0] != 0: # no ambiguous object/callback/deny ACLs + raise IsolationUnavailable() + if ( + security.ConvertSidToStringSid(ace[-1]) not in (SYSTEM, ADMIN) + and ace[1] & WRITE + ): + raise IsolationUnavailable() + + +class Peer: + def __init__(self, pid: int): + winapi, _, _, _, security, _, _ = api() + self.pid = pid + self.handle = winapi.OpenProcess(0x1000 | 0x100000, False, pid) + dll = C.WinDLL("kernel32", use_last_error=True) + dll.GetProcessTimes.argtypes = [W.HANDLE, *([C.POINTER(C.c_ulonglong)] * 4)] + created, exited, system, user = (C.c_ulonglong() for _ in range(4)) + if not dll.GetProcessTimes( + int(self.handle), + C.byref(created), + C.byref(exited), + C.byref(system), + C.byref(user), + ): + self.handle.Close() + raise IsolationUnavailable() + self.created = created.value + with closing(security.OpenProcessToken(self.handle, 8)) as token: + self.sid = security.ConvertSidToStringSid( + security.GetTokenInformation(token, 1)[0] + ) + self.session = security.GetTokenInformation(token, 12) + self.container = bool(token_value(token, 29)) + self.elevated = bool(token_value(token, 20)) + self.app_sid = ( + token_value(token, 31, pointer=True) if self.container else None + ) + + def alive(self): + return api()[6].WaitForSingleObject(self.handle, 0) == 258 + + def close(self): + self.handle.Close() + + +class STARTUPINFO(C.Structure): + _fields_ = [ + ("cb", W.DWORD), + ("reserved", W.LPWSTR), + ("desktop", W.LPWSTR), + ("title", W.LPWSTR), + ("x", W.DWORD), + ("y", W.DWORD), + ("xs", W.DWORD), + ("ys", W.DWORD), + ("xc", W.DWORD), + ("yc", W.DWORD), + ("fill", W.DWORD), + ("flags", W.DWORD), + ("show", W.WORD), + ("reserved2size", W.WORD), + ("reserved2", C.c_void_p), + ("stdin", W.HANDLE), + ("stdout", W.HANDLE), + ("stderr", W.HANDLE), + ] + + +class STARTUPINFOEX(C.Structure): + _fields_ = [("startup", STARTUPINFO), ("attributes", C.c_void_p)] + + +class PROCESSINFO(C.Structure): + _fields_ = [ + ("process", W.HANDLE), + ("thread", W.HANDLE), + ("pid", W.DWORD), + ("tid", W.DWORD), + ] + + +class CAPS(C.Structure): + _fields_ = [ + ("sid", C.c_void_p), + ("caps", C.c_void_p), + ("count", W.DWORD), + ("reserved", W.DWORD), + ] + + +class SA(C.Structure): + _fields_ = [("length", W.DWORD), ("descriptor", C.c_void_p), ("inherit", W.BOOL)] + + +class IsolatedProcess: + """Single-process Python consumer, no descendants, handles or inherited env.""" + + def __init__(self, root: Path, runtime: Path, owner_sid: str, session: int): + winapi, _, job, _, security, ts, _ = api() + self.root = root + self._closing = RLock() + self.profile_name = "CraftBot.Credential." + uuid4().hex + self.job = job.CreateJobObject(None, "") + limits = job.QueryInformationJobObject( + self.job, job.JobObjectExtendedLimitInformation + ) + limits["BasicLimitInformation"]["LimitFlags"] = 0x2000 | 0x8 | 0x100 + limits["BasicLimitInformation"]["ActiveProcessLimit"] = 1 + limits["ProcessMemoryLimit"] = 512 * 1024 * 1024 + job.SetInformationJobObject( + self.job, job.JobObjectExtendedLimitInformation, limits + ) + job.SetInformationJobObject( + self.job, job.JobObjectBasicUIRestrictions, {"UIRestrictionsClass": 0xFF} + ) + self.handle = None + self.pid = None + self.runtime = runtime + self._sid = C.c_void_p() + userenv = C.WinDLL("userenv", use_last_error=True) + userenv.CreateAppContainerProfile.argtypes = [ + W.LPCWSTR, + W.LPCWSTR, + W.LPCWSTR, + C.c_void_p, + W.DWORD, + C.POINTER(C.c_void_p), + ] + userenv.CreateAppContainerProfile.restype = C.c_long + with closing(ts.WTSQueryUserToken(session)) as token: + if ( + security.ConvertSidToStringSid( + security.GetTokenInformation(token, 1)[0] + ) + != owner_sid + ): + raise IsolationUnavailable() + if token_value(token, 20): + dll = C.WinDLL("advapi32", use_last_error=True) + dll.GetTokenInformation.argtypes = [ + W.HANDLE, + C.c_int, + C.c_void_p, + W.DWORD, + C.POINTER(W.DWORD), + ] + linked, size = W.HANDLE(), W.DWORD() + if not dll.GetTokenInformation( + int(token), 19, C.byref(linked), C.sizeof(linked), C.byref(size) + ): + raise IsolationUnavailable() + self.token = linked.value + else: + self.token = winapi.DuplicateHandle( + winapi.GetCurrentProcess(), + token, + winapi.GetCurrentProcess(), + 0, + False, + 2, + ).Detach() + if token_value(self.token, 20): + raise IsolationUnavailable() + security.ImpersonateLoggedOnUser(self.token) + try: + code = userenv.CreateAppContainerProfile( + self.profile_name, + "CraftBot isolated credential consumer", + "", + None, + 0, + C.byref(self._sid), + ) + finally: + security.RevertToSelf() + if code != 0: + raise IsolationUnavailable() + self.app_sid = native_sid(self._sid) + root.mkdir() + private_acl(root, self.app_sid) + (root / "scratch").mkdir() + private_acl(root / "scratch", self.app_sid, writable=True) + + def start(self, entry: str): + kernel = C.WinDLL("kernel32", use_last_error=True) + advapi = C.WinDLL("advapi32", use_last_error=True) + initialize = kernel.InitializeProcThreadAttributeList + initialize.argtypes = [C.c_void_p, W.DWORD, W.DWORD, C.POINTER(C.c_size_t)] + update = kernel.UpdateProcThreadAttribute + update.argtypes = [ + C.c_void_p, + W.DWORD, + C.c_size_t, + C.c_void_p, + C.c_size_t, + C.c_void_p, + C.c_void_p, + ] + kernel.DeleteProcThreadAttributeList.argtypes = [C.c_void_p] + size = C.c_size_t() + initialize(None, 2, 0, C.byref(size)) + storage = C.create_string_buffer(size.value) + if not initialize(storage, 2, 0, C.byref(size)): + raise IsolationUnavailable() + caps = CAPS(self._sid, None, 0, 0) # No network or broad file capabilities. + no_children = W.DWORD(1) + si = STARTUPINFOEX() + si.startup.cb = C.sizeof(si) + si.attributes = C.cast(storage, C.c_void_p) + pi = PROCESSINFO() + # Process/thread objects cannot be opened/injected by the desktop owner. + security = api()[4] + descriptor = security.ConvertStringSecurityDescriptorToSecurityDescriptor( + "O:SYD:P(A;;GA;;;SY)", 1 + ) + descriptor_bytes = bytes(descriptor) + descriptor_buffer = C.create_string_buffer(descriptor_bytes) + attributes = SA(C.sizeof(SA), C.cast(descriptor_buffer, C.c_void_p), False) + command = C.create_unicode_buffer( + subprocess.list2cmdline( + [str(self.runtime), "-I", "-S", str(self.root / entry)] + ) + ) + windows = api()[0].GetWindowsDirectory() + environment = C.create_unicode_buffer( + "\0".join( + [ + "PATH=" + str(self.runtime.parent), + "SYSTEMROOT=" + windows, + "TEMP=" + str(self.root / "scratch"), + "TMP=" + str(self.root / "scratch"), + "USERPROFILE=" + str(self.root / "scratch"), + ] + ) + + "\0\0" + ) + create = advapi.CreateProcessAsUserW + create.argtypes = [ + W.HANDLE, + W.LPCWSTR, + W.LPWSTR, + C.POINTER(SA), + C.POINTER(SA), + W.BOOL, + W.DWORD, + C.c_void_p, + W.LPCWSTR, + C.POINTER(STARTUPINFOEX), + C.POINTER(PROCESSINFO), + ] + try: + if not update( + storage, 0, 0x20009, C.byref(caps), C.sizeof(caps), None, None + ) or not update( + storage, + 0, + 0x2000E, + C.byref(no_children), + C.sizeof(no_children), + None, + None, + ): + raise IsolationUnavailable() + if not create( + self.token, + str(self.runtime), + command, + C.byref(attributes), + C.byref(attributes), + False, + 0x80000 | 0x400 | 4 | 0x08000000, + environment, + str(self.root / "scratch"), + C.byref(si), + C.byref(pi), + ): + raise IsolationUnavailable() + self.handle = pi.process + self.pid = pi.pid + try: + api()[2].AssignProcessToJobObject(self.job, self.handle) + api()[3].ResumeThread(pi.thread) + except BaseException: + api()[0].TerminateProcess(self.handle, 1) + raise + finally: + api()[0].CloseHandle(pi.thread) + finally: + kernel.DeleteProcThreadAttributeList(storage) + + def matches(self, peer: Peer): + return ( + self.handle is not None + and self.pid == peer.pid + and peer.alive() + and peer.container + and peer.app_sid == self.app_sid + and not peer.elevated + and api()[2].IsProcessInJob(peer.handle, self.job) + ) + + def close(self): + with self._closing: + self._close() + + def __del__(self): + try: + self.close() + except BaseException: + pass + + def _close(self): + if self.handle is not None: + try: + api()[0].TerminateProcess(self.handle, 1) + except Exception: + pass + api()[6].WaitForSingleObject(self.handle, 5000) + api()[0].CloseHandle(self.handle) + self.handle = None + if self.job is not None: + self.job.Close() # Kill-on-close, including on worker termination. + self.job = None + if getattr(self, "token", None): + try: + security = api()[4] + security.ImpersonateLoggedOnUser(self.token) + try: + delete = C.WinDLL("userenv").DeleteAppContainerProfile + delete.argtypes = [W.LPCWSTR] + delete(self.profile_name) + finally: + security.RevertToSelf() + except Exception: + pass + api()[0].CloseHandle(self.token) + self.token = None + if self._sid: + free = C.WinDLL("advapi32").FreeSid + free.argtypes = [C.c_void_p] + free(self._sid) + self._sid = None diff --git a/app/credentials/wire.py b/app/credentials/wire.py new file mode 100644 index 000000000..c5dc9a152 --- /dev/null +++ b/app/credentials/wire.py @@ -0,0 +1,112 @@ +"""Bounded data encoders, never caller-provided HTTP transport or headers.""" + +import base64 +import json +import re +import secrets +from urllib.parse import urlencode + +MAX_INPUT = 32_000_000 +MAX_OUTPUT = 96_000_000 + + +def encode(body, format): + if body is None: + return None, "application/json" + if type(body) is not dict and not (format == "json" and type(body) is list): + raise ValueError("credential_request_invalid") + if format == "json": + result = json.dumps(body, allow_nan=False).encode("utf-8") + mime = "application/json" + elif format == "form": + if any( + type(v) not in (str, int, float, bool, type(None)) for v in body.values() + ): + raise ValueError("credential_request_invalid") + result = urlencode({k: v for k, v in body.items() if v is not None}).encode() + mime = "application/x-www-form-urlencoded" + elif format == "image": + if set(body) != {"data", "content_type"} or body["content_type"] not in ( + "image/png", + "image/jpeg", + ): + raise ValueError("credential_request_invalid") + result = base64.b64decode(body["data"], validate=True) + mime = body["content_type"] + elif format == "multipart": + if ( + set(body) != {"fields", "files"} + or type(body["fields"]) is not dict + or type(body["files"]) is not list + or not 1 <= len(body["files"]) <= 8 + ): + raise ValueError("credential_request_invalid") + boundary = "CraftBot" + secrets.token_hex(24) + parts = [] + + def name(value): + if not isinstance(value, str) or not re.fullmatch( + r"[A-Za-z][A-Za-z0-9_\[\]-]{0,63}", value + ): + raise ValueError("credential_request_invalid") + return value + + for field, value in body["fields"].items(): + if type(value) not in (str, int, float, bool): + raise ValueError("credential_request_invalid") + parts.append( + ( + f'--{boundary}\r\nContent-Disposition: form-data; name="{name(field)}"\r\n\r\n' + + str(value) + + "\r\n" + ).encode() + ) + for item in body["files"]: + if ( + type(item) is not dict + or not {"field", "data", "content_type"} <= set(item) + or set(item) - {"field", "data", "content_type", "filename"} + or item["content_type"] + not in ( + "image/png", + "image/jpeg", + "image/webp", + "video/mp4", + "application/octet-stream", + "audio/wav", + "audio/x-wav", + "audio/mpeg", + "audio/mp4", + "audio/flac", + "audio/ogg", + "application/zip", + "text/plain", + "application/javascript", + "application/typescript", + ) + ): + raise ValueError("credential_request_invalid") + filename = item.get("filename", "upload.bin") + if not isinstance(filename, str) or not re.fullmatch( + r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}", filename + ): + raise ValueError("credential_request_invalid") + content = base64.b64decode(item["data"], validate=True) + if len(content) > 24_000_000: + raise ValueError("credential_use_limit") + parts.extend( + [ + ( + f'--{boundary}\r\nContent-Disposition: form-data; name="{name(item["field"])}"; filename="{filename}"\r\nContent-Type: {item["content_type"]}\r\n\r\n' + ).encode(), + content, + b"\r\n", + ] + ) + parts.append(f"--{boundary}--\r\n".encode()) + result, mime = b"".join(parts), f"multipart/form-data; boundary={boundary}" + else: + raise ValueError("credential_request_invalid") + if len(result) > MAX_INPUT: + raise ValueError("credential_use_limit") + return result, mime diff --git a/app/data/action/credential_operations.py b/app/data/action/credential_operations.py new file mode 100644 index 000000000..026fcbbc7 --- /dev/null +++ b/app/data/action/credential_operations.py @@ -0,0 +1,95 @@ +"""Agent surfaces accept identifiers and operation data, never source values.""" + +from agent_core import action + + +@action( + name="use_saved_credential", + description="Request one installed credential operation using an owner-selected source reference. The protected host asks the owner to approve the caller, account, operation and destination. Never request or provide a password/API-key value. Use Settings > Credentials for secure entry. Unknown helpers fail closed.", + mode="ALL", + execution_mode="internal", + parallelizable=False, + irreversible=True, + input_schema={ + "reference": { + "type": "string", + "description": "Owner-provided craftbot-vault:// source reference.", + }, + "helper": {"type": "string", "description": "Installed helper ID."}, + "operation": {"type": "string", "description": "Installed operation name."}, + "body": { + "type": "object", + "description": "Operation JSON data; never a URL, headers or source credentials.", + }, + }, + output_schema={"status": {"type": "string"}, "result": {"type": "object"}}, +) +async def use_saved_credential(input_data: dict) -> dict: + from app.credentials.invocation import invoke_async + + try: + response = await invoke_async( + input_data["reference"], + input_data["helper"], + input_data["operation"], + input_data.get("body"), + subject="Agent credential operation", + ) + body = response["body"] + return { + "status": "success" if 200 <= response["status"] < 300 else "error", + "result": { + "http_status": response["status"], + "body": body[:12000], + "truncated": len(body) > 12000, + }, + } + except Exception: + return { + "status": "error", + "message": "Credential use needs a supported helper, current reference and owner approval. Open Settings > Credentials to add or unlock an item.", + } + + +@action( + name="request_credential_entry", + description="Ask the owner to save a credential in the protected native dialog for an installed helper. Requires an owner-selected initialized profile ID. Returns only a reference. Saving grants no permission to use the credential. Never ask for secret values in chat or action input.", + mode="ALL", + execution_mode="internal", + parallelizable=False, + irreversible=True, + input_schema={ + "profile_id": { + "type": "string", + "description": "Owner-selected vault profile UUID.", + }, + "helper": {"type": "string", "description": "Installed helper ID."}, + "operation": {"type": "string", "description": "Installed operation name."}, + "account": { + "type": "string", + "description": "Visible account label; no username, key or password value.", + }, + }, + output_schema={"status": {"type": "string"}, "reference": {"type": "string"}}, +) +async def request_credential_entry(input_data: dict) -> dict: + from app.credentials.consumer_runtime import execute_action + + code = """def secure_entry(data): + import credential_client + result = credential_client.request_entry(data['helper'], data['operation'], data['account']) + return {'status': 'success', 'reference': result['reference']} +""" + try: + return await execute_action( + code, + {key: input_data[key] for key in ("helper", "operation", "account")}, + profile_id=input_data["profile_id"], + subject="Agent secure credential entry", + timeout=600, + ) + except Exception: + return { + "status": "error", + "message": "Secure entry requires the installed service, an initialized owner-selected profile, supported helper and native owner approval.", + } diff --git a/app/data/action/integrations/_helpers.py b/app/data/action/integrations/_helpers.py index 7856e009e..b4c26d4cb 100644 --- a/app/data/action/integrations/_helpers.py +++ b/app/data/action/integrations/_helpers.py @@ -676,46 +676,70 @@ def system_connect_token(system, integration_id: str, credentials: Dict[str, str way the connect flow's ``login()`` does, then store the credential through the integration system (``store_credential``) — never through a single-account save. Returns (success, message). """ - # Providers may carry their own verifier (the bridge-provider pattern — - # keeps each platform's connect logic in its provider package); the - # central table covers the three providers that predate it. - provider_obj = system.registry.get(integration_id) - verifier = getattr(provider_obj, "verify_token", None) or _TOKEN_VERIFIERS.get( - integration_id - ) - if verifier is None: - # Mirrors the token-connect contract for field-less - # (OAuth-only) integrations. - return ( - False, - f"Token-based login not supported for " - f"{display_name_for(system, integration_id)}", - ) - try: - ok, message, credential = verifier(credentials) - except Exception as e: - return False, f"{integration_id} token verification failed: {e}" - if not ok or not credential: - return False, message - - provider = system.registry.get(integration_id) - identity = provider.identity_of(credential) - if not identity: - # Refuse rather than store under the UNIDENTIFIED sentinel: a second - # identity-less connect would land on the same sentinel key and - # silently REPLACE the first account's credential. The sentinel - # exists only for single-account files migrating in. - return False, ( - f"Could not determine which account this " - f"{display_name_for(system, integration_id)} token belongs to — " - f"connect was aborted so an existing account can't be " - f"overwritten. Re-check the token and try again." + if "source_reference" in credentials: + if integration_id in ("lark", "lark_calendar", "lark_drive", "telegram_user"): + allowed_fields = ( + {"source_reference", "app_id"} + if integration_id.startswith("lark") + else {"source_reference", "api_id", "phone_number", "code"} + ) + if set(credentials) - allowed_fields: + return ( + False, + "Only a saved reference and login identifiers/code are accepted. Save 2FA passwords in the vault.", + ) + try: + from app.credentials.references import parse + + parse(credentials["source_reference"]) + provider = system._require_provider(integration_id) + data = {k: v for k, v in credentials.items() if k != "source_reference"} + data[ + "app_secret" + if integration_id.startswith("lark") + else "api_hash_reference" + ] = credentials["source_reference"] + ok, message, credential = provider.verify_token(data) + if ok and credential is not None: + identity = provider.identity_of(credential) + system.store_credential(integration_id, identity, credential) + return ok, message + except Exception: + return ( + False, + "Saved login requires the protected host and owner approval.", + ) + allowed = ( + {"source_reference", "phone_number_id"} + if integration_id == "whatsapp_business" + else {"source_reference", "access_token", "access_token_secret"} + if integration_id == "twitter" + else {"source_reference"} ) - system.store_credential(integration_id, identity, credential) - # Slack has a listener; reconcile so a fresh token starts listening - # immediately (no-op when no manager is attached / no listener exists). - system.reconcile_listeners() - return True, message + if set(credentials) - allowed or not isinstance( + credentials["source_reference"], str + ): + return ( + False, + "A saved source connection accepts only a credential reference.", + ) + try: + metadata = {k: v for k, v in credentials.items() if k != "source_reference"} + system.connect_source( + integration_id, + credentials["source_reference"], + **({"public_metadata": metadata} if metadata else {}), + ) + return True, "Saved credential connected through the protected host." + except Exception: + return ( + False, + "Saved credential connection needs a supported helper and owner approval. Existing token connections remain available.", + ) + return ( + False, + "Save source credentials in Settings > Credentials and connect using their reference. Generated OAuth callbacks keep their current flow.", + ) # Strong references to scheduled teardown tasks: a bare create_task result diff --git a/app/data/action/understand_video.py b/app/data/action/understand_video.py index e9cd60c6a..14f995577 100644 --- a/app/data/action/understand_video.py +++ b/app/data/action/understand_video.py @@ -119,33 +119,37 @@ def understand_video(input_data: dict) -> dict: if api_key: try: - from google import genai - - client = genai.Client(api_key=api_key) import time from datetime import datetime from app.config import AGENT_WORKSPACE_ROOT - video_file = client.files.upload(file=video_path) - - while video_file.state.name == "PROCESSING": - time.sleep(2) - video_file = client.files.get(name=video_file.name) - vlm_model = get_vlm_model() or "gemini-1.5-pro" prompt = ( query if query else "Understand and describe the contents of this video." ) - response = client.models.generate_content( - model=vlm_model, - contents=[video_file, prompt], - ) - - client.files.delete(name=video_file.name) - - full_text = response.text + from app.credentials.references import is_reference + + if is_reference(api_key): + from app.credentials.media_clients import analyse_video + + full_text = analyse_video(api_key, video_path, vlm_model, prompt) + else: + from google import genai + + client = genai.Client(api_key=api_key) + video_file = client.files.upload(file=video_path) + try: + while video_file.state.name == "PROCESSING": + time.sleep(2) + video_file = client.files.get(name=video_file.name) + response = client.models.generate_content( + model=vlm_model, contents=[video_file, prompt] + ) + full_text = response.text + finally: + client.files.delete(name=video_file.name) ts = datetime.utcnow().strftime("%Y%m%d_%H%M%S") out_path = os.path.join(AGENT_WORKSPACE_ROOT, f"video_summary_{ts}.txt") with open(out_path, "w", encoding="utf-8") as f: diff --git a/app/data/action/web_search.py b/app/data/action/web_search.py index 4a230212d..9f42bdc54 100644 --- a/app/data/action/web_search.py +++ b/app/data/action/web_search.py @@ -160,8 +160,25 @@ def google_cse_search(q, n=5): if not api_key or not cse_id: raise Exception("No Google API credentials") - service = build("customsearch", "v1", developerKey=api_key) - res = service.cse().list(q=q, cx=cse_id, num=min(n + 5, 10)).execute() + from app.credentials.references import is_reference + + if is_reference(api_key): + import json + from app.credentials.invocation import invoke + + response = invoke( + api_key, + "model_google", + "custom_search", + {"q": q, "cx": cse_id, "num": min(n + 5, 10)}, + subject="Google Custom Search", + ) + if response["status"] != 200: + raise RuntimeError("credential_operation_failed") + res = json.loads(response["body"]) + else: + service = build("customsearch", "v1", developerKey=api_key) + res = service.cse().list(q=q, cx=cse_id, num=min(n + 5, 10)).execute() items = res.get("items", []) return [ diff --git a/app/integrations.py b/app/integrations.py index 55d1dc062..cd9a25beb 100644 --- a/app/integrations.py +++ b/app/integrations.py @@ -166,12 +166,24 @@ def set_event_callback(on_message: "MessageCallback") -> None: def get_system() -> IntegrationSystem: global _system + from app.credentials.oauth_transport import request as source_oauth_request + from app.credentials.model_transport import openai_client + from app.credentials.provider_auth import lark_request, telegram_auth + from craftos_integrations.config import ConfigStore + + ConfigStore.extras["source_oauth_request"] = source_oauth_request + ConfigStore.extras["openai_client_factory"] = openai_client + ConfigStore.extras["source_lark_request"] = lark_request + ConfigStore.extras["source_telegram_auth"] = telegram_auth if _system is None: from craftos_integrations.providers import default_providers + from app.credentials.integrations import SavedSourceOperations + providers = default_providers() _system = IntegrationSystem( store=FileCredentialStore(), - providers=default_providers(), + providers=providers, + source_credentials=SavedSourceOperations(providers), ) return _system diff --git a/app/mini_browser/actions_api.py b/app/mini_browser/actions_api.py index f35629d0c..45fed0fa9 100644 --- a/app/mini_browser/actions_api.py +++ b/app/mini_browser/actions_api.py @@ -417,6 +417,9 @@ def _v_login(data: Dict[str, Any], _context: Optional[UrlContext]) -> Dict[str, return { "username": _text(data, "username", max_chars=MAX_USERNAME_CHARS), "submit": _flag(data, "submit", True), + "credential_reference": _text(data, "credential_reference", max_chars=256), + "helper": _text(data, "helper", max_chars=256), + "operation": _text(data, "operation", max_chars=256) or "login", } diff --git a/app/mini_browser/core.py b/app/mini_browser/core.py index 0b263a942..910321742 100644 --- a/app/mini_browser/core.py +++ b/app/mini_browser/core.py @@ -2714,10 +2714,10 @@ def _agent_notice( return event def vault(self) -> Any: - """The credential vault (``vault.get_vault()``).""" - from app.mini_browser.vault import get_vault + """The ordinary browser process has no source-vault access.""" + from app.mini_browser.errors import MiniBrowserError - return get_vault() + raise MiniBrowserError("MINI_BROWSER_VAULT_UNREADABLE") def ui_origins(self) -> frozenset: """CraftBot's own UI origins (``host:port``), never opened in the browser.""" diff --git a/app/mini_browser/ops.py b/app/mini_browser/ops.py index dd4fb3035..ceeda8bc5 100644 --- a/app/mini_browser/ops.py +++ b/app/mini_browser/ops.py @@ -2809,12 +2809,27 @@ async def screenshot(core: Any, tab: Any, *, full_page: bool = False) -> Dict[st async def login( - core: Any, tab: Any, *, username: Optional[str] = None, submit: bool = True + core: Any, + tab: Any, + *, + username: Optional[str] = None, + submit: bool = True, + credential_reference: Optional[str] = None, + helper: Optional[str] = None, + operation: str = "login", ) -> Dict[str, Any]: """Sign in with a saved login from the password vault.""" from app.mini_browser.login import autofill - return await autofill(core, tab, username=username, submit=submit) + return await autofill( + core, + tab, + username=username, + submit=submit, + credential_reference=credential_reference, + helper=helper, + operation=operation, + ) _CLOSED_CODES = frozenset( diff --git a/app/mini_browser/vault.py b/app/mini_browser/vault.py index d5983d51f..bf2264eea 100644 --- a/app/mini_browser/vault.py +++ b/app/mini_browser/vault.py @@ -85,6 +85,7 @@ from urllib.parse import urlsplit from app.logger import logger +from app.credentials.secrets import Secret from app.mini_browser.errors import MiniBrowserError __all__ = [ @@ -216,39 +217,6 @@ def detail(self) -> str: return str(self.fields.get("detail", "")) -class Secret: - """A value (a password, a key) that never shows in a repr, str() or log. - - Wrap a password the moment it is read, e.g. from a WebSocket message, and - pass the wrapper around. :meth:`reveal` returns the real value. The vault - accepts either a ``Secret`` or a plain ``str`` wherever it takes a password. - """ - - __slots__ = ("_value",) - - def __init__(self, value: Any) -> None: - self._value = value - - @classmethod - def wrap(cls, value: Any) -> "Secret": - return value if isinstance(value, Secret) else cls(value) - - def reveal(self) -> Any: - return self._value - - def __bool__(self) -> bool: - return bool(self._value) - - def __repr__(self) -> str: - return "Secret('[redacted]')" - - def __str__(self) -> str: - return "[redacted]" - - def __reduce__(self): - raise TypeError("Secret values cannot be pickled") - - class _Entry(dict): """A vault entry. Its repr never shows the password (see "Secret hygiene").""" diff --git a/app/mini_browser/ws.py b/app/mini_browser/ws.py index 9d0767732..44a85286a 100644 --- a/app/mini_browser/ws.py +++ b/app/mini_browser/ws.py @@ -1127,6 +1127,18 @@ async def _publish_watched_states(self) -> None: async def handle(self, ws: Any, msg_type: str, data: Dict[str, Any]) -> None: """Handle one ``mini_browser_*`` message. Never raises (except on cancellation); every failure is answered to ``ws`` only.""" + if msg_type.startswith("mini_browser_vault_"): + # Retired transport. Never accept raw entry over the old browser API. + self._send( + ws, + "mini_browser_vault_result", + { + "op": msg_type[len("mini_browser_vault_") :], + "ok": False, + "error": "Use Settings > Credentials and the protected entry dialog.", + }, + ) + return name = _HANDLERS.get(msg_type) if name is None: logger.debug( diff --git a/app/onboarding/interfaces/steps.py b/app/onboarding/interfaces/steps.py index 16c25388b..39aeaec5d 100644 --- a/app/onboarding/interfaces/steps.py +++ b/app/onboarding/interfaces/steps.py @@ -275,9 +275,7 @@ def validate(self, value: Any) -> tuple[bool, Optional[str]]: # Proxied providers submit {api_key, via, or_model?} dict if self.provider in self.OPENROUTER_PROXIED and isinstance(value, dict): api_key = value.get("api_key", "") - if not api_key or len(str(api_key).strip()) < 10: - return False, tui("onboarding_validate_api_key_required") - return True, None + return self._validate_reference(api_key) # A connected subscription (ChatGPT Plus/Pro, SuperGrok) authorizes the # provider via an OAuth bearer, so the API key is optional. Accept an @@ -289,10 +287,20 @@ def validate(self, value: Any) -> tuple[bool, Optional[str]]: if not value or not isinstance(value, str): return False, tui("onboarding_validate_api_key_required") - if len(value.strip()) < 10: - return False, tui("onboarding_validate_api_key_too_short") + return self._validate_reference(value) - return True, None + @staticmethod + def _validate_reference(value): + from app.credentials.references import parse + + try: + parse(value) + return True, None + except (ValueError, TypeError): + return ( + False, + "Choose a saved reference from Credentials. Enter source values only in the protected dialog.", + ) def get_default(self) -> str: if self.provider == "remote": @@ -300,7 +308,10 @@ def get_default(self) -> str: # Check settings.json for existing key from app.config import get_api_key - return get_api_key(self.provider) + from app.credentials.references import is_reference + + value = get_api_key(self.provider) + return value if is_reference(value) else "" def get_env_var_name(self) -> Optional[str]: """Get the environment variable name for the current provider.""" diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index ab357d7f1..a476c4362 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -1689,6 +1689,11 @@ async def _handle_ws_message(self, data: Dict[str, Any], ws=None) -> None: # Chat browser panels: live view, browser controls and the password # vault (app/mini_browser/ws.py answers every failure itself). + elif msg_type == "credentials_request": + from app.credentials.settings import handle as handle_credentials + + await handle_credentials(self, ws, data) + elif isinstance(msg_type, str) and msg_type.startswith("mini_browser_"): mini_browser = getattr(self, "_mini_browser_ws", None) if mini_browser is not None: diff --git a/app/ui_layer/adapters/ws_lanes.py b/app/ui_layer/adapters/ws_lanes.py index 23fb6c1e1..c88bd13bf 100644 --- a/app/ui_layer/adapters/ws_lanes.py +++ b/app/ui_layer/adapters/ws_lanes.py @@ -63,6 +63,7 @@ # Message-type prefix → lane; first match wins. _PREFIX_LANES = ( + ("credentials_", "credentials"), ("agent_app_", "agent_app"), ("file_", "workspace"), ("memory_", "memory"), diff --git a/app/ui_layer/browser/frontend/src/components/MiniBrowser/PasswordsModal.tsx b/app/ui_layer/browser/frontend/src/components/MiniBrowser/PasswordsModal.tsx index decca473b..461464f98 100644 --- a/app/ui_layer/browser/frontend/src/components/MiniBrowser/PasswordsModal.tsx +++ b/app/ui_layer/browser/frontend/src/components/MiniBrowser/PasswordsModal.tsx @@ -1,615 +1,9 @@ -import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { - AlertCircle, - AlertTriangle, - Eye, - EyeOff, - KeyRound, - Loader2, - Pencil, - Search, - ShieldCheck, - Trash2, -} from 'lucide-react' -import { Button, ConfirmModal, IconButton, Modal, ModalBody, ModalFooter } from '../../components/ui' -import { useToast } from '../../contexts/ToastContext' -import { formatDate } from '../../i18n/format' -import { useAppSelector } from '../../store/hooks' -import { RESOURCES, resourceSync, useResource } from '../../store/resources' -import { - selectMiniBrowserVaultEntries, - selectMiniBrowserVaultError, - selectMiniBrowserVaultLoaded, - selectMiniBrowserVaultResult, - selectMiniBrowserVaultStatus, -} from '../../store/selectors/miniBrowser' -import type { MiniBrowserError, MiniBrowserVaultEntry, MiniBrowserVaultOp } from '../../types' -import { useErrorText } from './useErrorText' -import { isSocketConnected, sendLive } from './useMiniBrowserSocket' -import { LABEL_MAX, PASSWORD_MAX, USERNAME_MAX, siteHost, validateSite } from './vaultForm' -import styles from './PasswordsModal.module.css' - -// A write normally answers within a second; past this the dialog stops -// waiting and says so (the request may still land — the list refreshes). -const RESULT_TIMEOUT_MS = 15_000 -// Show a filter box once the list gets long. -const FILTER_THRESHOLD = 8 - -// Keep password managers (1Password, LastPass, Bitwarden, Chrome's own) away -// from this form: these are third-party credentials, not CraftBot's. -const NO_AUTOFILL = { - autoComplete: 'off', - 'data-1p-ignore': '', - 'data-lpignore': 'true', - 'data-bwignore': '', - 'data-form-type': 'other', -} as const - -type FormMode = { kind: 'add' } | { kind: 'edit'; entry: MiniBrowserVaultEntry } - -interface Pending { - op: MiniBrowserVaultOp - /** The entry being deleted. */ - entryId?: string -} - -interface PasswordsModalProps { - isOpen: boolean - onClose(): void -} - -/** Accepts epoch seconds/ms or an ISO string; null when unusable. */ -function toDate(value: string | number | null): Date | null { - if (value === null) return null - const date = typeof value === 'number' ? new Date(value < 1e12 ? value * 1000 : value) : new Date(value) - return Number.isNaN(date.getTime()) ? null : date -} - -/** - * Saved logins the agents can use to sign in. The UI never receives a - * password: the list carries site, username and label only, and a typed - * password lives in this component just until the backend confirms the save. - * Unmounting (closing) drops every field. - */ -export function PasswordsModal({ isOpen, onClose }: PasswordsModalProps) { - const { t } = useTranslation(['minibrowser', 'common']) - const { showToast } = useToast() - const errorText = useErrorText() - const idPrefix = useId() - - useResource(isOpen ? RESOURCES.miniBrowserVault : null) - const loaded = useAppSelector(selectMiniBrowserVaultLoaded) - const entries = useAppSelector(selectMiniBrowserVaultEntries) - const status = useAppSelector(selectMiniBrowserVaultStatus) - const listError = useAppSelector(selectMiniBrowserVaultError) - const result = useAppSelector(selectMiniBrowserVaultResult) - - // Every opening shows the vault as it is now (it may have recovered, or - // an agent's sign-in changed "last used"). Coalesces with useResource's - // own first request. - useEffect(() => { - if (isOpen) resourceSync.refresh(RESOURCES.miniBrowserVault) - }, [isOpen]) - - // "Try again" on a vault that couldn't be loaded: busy until the reply. - const [retrying, setRetrying] = useState(false) - useEffect(() => { - setRetrying(false) - }, [status, listError]) - useEffect(() => { - if (!retrying) return - const timer = window.setTimeout(() => setRetrying(false), RESULT_TIMEOUT_MS) - return () => window.clearTimeout(timer) - }, [retrying]) - const retryList = () => { - setRetrying(true) - resourceSync.refresh(RESOURCES.miniBrowserVault) - } - - const [mode, setMode] = useState({ kind: 'add' }) - const [site, setSite] = useState('') - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') - const [label, setLabel] = useState('') - const [showPassword, setShowPassword] = useState(false) - const [submitted, setSubmitted] = useState(false) - const [formError, setFormError] = useState(null) - const [pending, setPending] = useState(null) - const [confirmDelete, setConfirmDelete] = useState(null) - const [confirmReset, setConfirmReset] = useState(false) - const [filter, setFilter] = useState('') - - const siteRef = useRef(null) - // Results that predate this dialog are not ours. - const seenResultSeqRef = useRef(result?.seq ?? 0) - - const resetForm = useCallback(() => { - setMode({ kind: 'add' }) - setSite('') - setUsername('') - setPassword('') - setLabel('') - setShowPassword(false) - setSubmitted(false) - setFormError(null) - }, []) - - // Closing clears every field, the password above all. - useEffect(() => { - if (isOpen) return - resetForm() - setPending(null) - setConfirmDelete(null) - setConfirmReset(false) - setFilter('') - }, [isOpen, resetForm]) - - // Start in the first field; give focus back to whatever opened the dialog. - useEffect(() => { - if (!isOpen) return - const opener = document.activeElement as HTMLElement | null - siteRef.current?.focus() - return () => opener?.focus?.({ preventScroll: true }) - }, [isOpen]) - - // ── Results of our writes ───────────────────────────────────────────── - useEffect(() => { - if (!result || result.seq === seenResultSeqRef.current) return - seenResultSeqRef.current = result.seq - if (!pending) return - const { op, entryId } = pending - setPending(null) - if (!result.ok) { - const text = result.error ? errorText(result.error) : null - setFormError(text ? [text.message || text.title, text.detail].filter(Boolean).join(' — ') : t('minibrowser:passwords.error')) - return - } - // The list is fetched again so it reflects the change right away. - resourceSync.refresh(RESOURCES.miniBrowserVault) - if (op === 'add' || op === 'update') { - resetForm() - showToast('success', op === 'add' ? t('minibrowser:passwords.saved') : t('minibrowser:passwords.updated')) - } else if (op === 'delete') { - // Deleted the login being edited: the form has nothing to edit any more. - if (mode.kind === 'edit' && mode.entry.id === entryId) resetForm() - showToast('success', t('minibrowser:passwords.deleted')) - } else { - showToast('success', t('minibrowser:passwords.resetDone')) - } - }, [result, pending, mode, errorText, resetForm, showToast, t]) - - useEffect(() => { - if (!pending) return - const timer = window.setTimeout(() => { - setPending(null) - setFormError(t('minibrowser:passwords.timeout')) - resourceSync.refresh(RESOURCES.miniBrowserVault) - }, RESULT_TIMEOUT_MS) - return () => window.clearTimeout(timer) - }, [pending, t]) - - /** Sends only while connected: a queued write would keep the password in - * the outbox and could replay much later. */ - const sendVault = useCallback((type: string, data: Record, pendingOp: Pending): void => { - if (!isSocketConnected() || !sendLive(type, data)) { - setFormError(t('minibrowser:passwords.notConnected')) - return - } - setFormError(null) - setPending(pendingOp) - }, [t]) - - // ── Validation ──────────────────────────────────────────────────────── - const editing = mode.kind === 'edit' - const siteProblem = validateSite(site) - const usernameProblem = !username.trim() - ? t('minibrowser:passwords.required') - : username.length > USERNAME_MAX - ? t('minibrowser:passwords.tooLong', { max: USERNAME_MAX }) - : null - const passwordProblem = !editing && !password - ? t('minibrowser:passwords.required') - : password.length > PASSWORD_MAX - ? t('minibrowser:passwords.tooLong', { max: PASSWORD_MAX }) - : null - const labelProblem = label.length > LABEL_MAX ? t('minibrowser:passwords.tooLong', { max: LABEL_MAX }) : null - const siteMessage = siteProblem === 'required' - ? t('minibrowser:passwords.required') - : siteProblem === 'invalid' ? t('minibrowser:passwords.invalidSite') : null - const invalid = !!(siteMessage || usernameProblem || passwordProblem || labelProblem) - - // Saving a login for an existing site + username replaces its password. - const replaces = useMemo(() => { - if (editing || !site.trim() || !username.trim()) return null - const host = siteHost(site) - const user = username.trim().toLowerCase() - return entries.find(entry => siteHost(entry.site) === host && entry.username.toLowerCase() === user) ?? null - }, [editing, entries, site, username]) - - // Three ways the vault can be other than "readable": unreadable (needs a - // reset), unavailable for now (a busy file, a missing dependency: retry), - // or the list request itself failed. None of them is an empty vault, and - // none can take a save. - const unreadable = !!status?.unreadable - const unavailable = loaded && !unreadable && (!!listError || (status !== null && !status.ok)) - const locked = unreadable || unavailable - const busy = pending !== null - const listErrorText = (error: MiniBrowserError): string => { - const text = errorText(error) - return [text.message || text.title, text.detail].filter(Boolean).join(' — ') - } - - const submit = (e: React.FormEvent) => { - e.preventDefault() - setSubmitted(true) - if (invalid || busy || locked) return - if (mode.kind === 'edit') { - const data: Record = { - id: mode.entry.id, - site: site.trim(), - username: username.trim(), - label: label.trim(), - } - if (password) data.password = password - sendVault('mini_browser_vault_update', data, { op: 'update' }) - } else { - sendVault( - 'mini_browser_vault_add', - { site: site.trim(), username: username.trim(), password, label: label.trim() }, - { op: 'add' }, - ) - } - } - - const startEdit = (entry: MiniBrowserVaultEntry) => { - setMode({ kind: 'edit', entry }) - setSite(entry.site) - setUsername(entry.username) - setLabel(entry.label) - setPassword('') - setShowPassword(false) - setSubmitted(false) - setFormError(null) - siteRef.current?.focus() - } - - const doDelete = () => { - const entry = confirmDelete - setConfirmDelete(null) - if (!entry) return - sendVault('mini_browser_vault_delete', { id: entry.id }, { op: 'delete', entryId: entry.id }) - } - - const doReset = () => { - setConfirmReset(false) - sendVault('mini_browser_vault_reset', {}, { op: 'reset' }) - } - - const dirty = !!(site || username || password || label) - const needle = filter.trim().toLowerCase() - const visible = needle - ? entries.filter(entry => - [entry.site, entry.username, entry.label].some(value => value.toLowerCase().includes(needle))) - : entries - - const fieldId = (name: string) => `${idPrefix}-${name}` - const showErrors = submitted - const lastUsed = (entry: MiniBrowserVaultEntry): string => { - const date = toDate(entry.lastUsedAt) - return date - ? t('minibrowser:passwords.lastUsed', { date: formatDate(date, { year: 'numeric', month: 'short', day: 'numeric' }) }) - : t('minibrowser:passwords.neverUsed') - } - - return ( - <> - {t('minibrowser:passwords.title')}} - closeOnEsc={!confirmDelete && !confirmReset} - closeOnOverlayClick={!dirty && !busy} - contentClassName={styles.dialog} - > - -

{t('minibrowser:passwords.intro')}

- {/* How the vault is protected is only known for a vault that loaded. */} - {status?.ok && !locked && ( -

- - {status.protection === 'dpapi' - ? t('minibrowser:passwords.protectionDpapi') - : t('minibrowser:passwords.protectionFile')} -

- )} - - {unreadable && ( -
- -
- {t('minibrowser:passwords.unreadableTitle')} -

{t('minibrowser:passwords.unreadableBody')}

- -
-
- )} - - {unavailable && ( -
- -
- {t('minibrowser:passwords.unavailableTitle')} -

{t('minibrowser:passwords.unavailableBody')}

- {listError &&

{listErrorText(listError)}

} - -
-
- )} - -
-
-

- {t('minibrowser:passwords.listLabel')} - {loaded && entries.length > 0 && ( - {t('minibrowser:passwords.count', { count: entries.length })} - )} -

- {entries.length > FILTER_THRESHOLD && ( - - )} -
- - {!loaded ? ( -
- - {t('minibrowser:passwords.loading')} -
- ) : entries.length === 0 ? ( -

- {locked ? t('minibrowser:passwords.emptyUnreadable') : t('minibrowser:passwords.empty')} -

- ) : ( -
    - {visible.map(entry => { - const beingEdited = mode.kind === 'edit' && mode.entry.id === entry.id - return ( -
  • -
    - {entry.site} - {entry.username} - - {entry.label && {entry.label}} - {lastUsed(entry)} - -
    - -
    - } - onClick={() => startEdit(entry)} - disabled={busy || locked} - aria-label={t('minibrowser:passwords.editNamed', { site: entry.site, username: entry.username })} - tooltip={t('minibrowser:passwords.edit')} - /> - } - className={styles.deleteButton} - onClick={() => setConfirmDelete(entry)} - disabled={busy || locked} - aria-label={t('minibrowser:passwords.deleteNamed', { site: entry.site, username: entry.username })} - tooltip={t('minibrowser:passwords.delete')} - /> -
    -
  • - ) - })} - {visible.length === 0 &&
  • {t('minibrowser:passwords.noMatches')}
  • } -
- )} -
- -
-

- {editing ? t('minibrowser:passwords.editTitle') : t('minibrowser:passwords.addTitle')} -

- -
- - setSite(e.target.value)} - placeholder={t('minibrowser:passwords.sitePlaceholder')} - inputMode="url" - autoCapitalize="off" - autoCorrect="off" - spellCheck={false} - disabled={busy || locked} - aria-invalid={showErrors && !!siteMessage} - aria-describedby={fieldId(showErrors && siteMessage ? 'site-error' : 'site-hint')} - {...NO_AUTOFILL} - /> - {showErrors && siteMessage ? ( - {siteMessage} - ) : ( - {t('minibrowser:passwords.siteHint')} - )} -
- -
- - setUsername(e.target.value)} - autoCapitalize="off" - autoCorrect="off" - spellCheck={false} - maxLength={USERNAME_MAX + 1} - disabled={busy || locked} - aria-invalid={showErrors && !!usernameProblem} - aria-describedby={showErrors && usernameProblem ? fieldId('username-error') : undefined} - {...NO_AUTOFILL} - /> - {showErrors && usernameProblem && ( - {usernameProblem} - )} -
- -
- -
- setPassword(e.target.value)} - placeholder={editing ? t('minibrowser:passwords.passwordKeep') : undefined} - autoCapitalize="off" - autoCorrect="off" - spellCheck={false} - maxLength={PASSWORD_MAX + 1} - disabled={busy || locked} - aria-invalid={showErrors && !!passwordProblem} - aria-describedby={showErrors && passwordProblem ? fieldId('password-error') : undefined} - {...NO_AUTOFILL} - /> - : } - onClick={() => setShowPassword(shown => !shown)} - disabled={busy || locked} - aria-pressed={showPassword} - aria-controls={fieldId('password')} - aria-label={showPassword ? t('minibrowser:passwords.hidePassword') : t('minibrowser:passwords.showPassword')} - tooltip={showPassword ? t('minibrowser:passwords.hidePassword') : t('minibrowser:passwords.showPassword')} - /> -
- {showErrors && passwordProblem && ( - {passwordProblem} - )} -
- -
- - setLabel(e.target.value)} - placeholder={t('minibrowser:passwords.labelPlaceholder')} - maxLength={LABEL_MAX + 1} - disabled={busy || locked} - aria-invalid={showErrors && !!labelProblem} - aria-describedby={showErrors && labelProblem ? fieldId('label-error') : undefined} - {...NO_AUTOFILL} - /> - {showErrors && labelProblem && ( - {labelProblem} - )} -
- - {replaces && ( -

- {t('minibrowser:passwords.willReplace', { site: replaces.site, username: replaces.username })} -

- )} - - {formError && ( -
- - {formError} -
- )} - -
- {editing && ( - - )} - -
-
-
- - - -
- - setConfirmDelete(null)} - /> - setConfirmReset(false)} - /> - - ) +import { Modal, ModalBody } from '../../components/ui' +import { CredentialsSettings } from '../Settings/CredentialsSettings' + +/** Compatibility entry point; credential management belongs to shared Settings. */ +export function PasswordsModal({ isOpen, onClose }: { isOpen: boolean; onClose(): void }) { + return + + } diff --git a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx index 75af727a8..9fe46cb80 100644 --- a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.tsx @@ -12,7 +12,8 @@ import { WifiOff, RefreshCw, } from 'lucide-react' -import { Button } from '../../components/ui' +import { Button, Modal, ModalBody } from '../../components/ui' +import { CredentialsSettings } from '../Settings/CredentialsSettings' import { useTranslation, Trans } from 'react-i18next' import { useWebSocket } from '../../contexts/WebSocketContext' import { useAppDispatch, useAppSelector } from '../../store/hooks' @@ -504,6 +505,7 @@ export function OnboardingPage() { // Local form state const [selectedValue, setSelectedValue] = useState('') const [textValue, setTextValue] = useState('') + const [credentialsOpen, setCredentialsOpen] = useState(false) const [orModel, setOrModel] = useState('') // For proxied providers: 'direct' tries the native API, 'openrouter' routes via OR. const [proxiedVia, setProxiedVia] = useState<'direct' | 'openrouter'>('direct') @@ -776,15 +778,16 @@ export function OnboardingPage() { return (
setTextValue(e.target.value)} - placeholder={isViaOR ? t('onboarding:proxied.orKey') : t('onboarding:proxied.directKey', { provider: providerDisplay })} + placeholder="Saved credential reference from Credentials" autoFocus onKeyDown={e => { if (e.key === 'Enter' && canSubmit) handleSubmit() }} /> -
{t('onboarding:apiKey.storedLocally')}
+
Enter source values in the protected dialog, then select their reference here.
+ {isViaOR && (
)}
+ {credentialsOpen && setCredentialsOpen(false)} title="Credentials" size="lg">} ) } diff --git a/app/ui_layer/browser/frontend/src/pages/Settings/CredentialsSettings.module.css b/app/ui_layer/browser/frontend/src/pages/Settings/CredentialsSettings.module.css new file mode 100644 index 000000000..79d680f4e --- /dev/null +++ b/app/ui_layer/browser/frontend/src/pages/Settings/CredentialsSettings.module.css @@ -0,0 +1,12 @@ +.panel { max-width: 900px; color: var(--text-primary); padding: 24px; } +.panel p { line-height: 1.5; } +.row { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin: 16px 0; } +.form { display: grid; gap: 16px; margin-top: 24px; } +.form label { display: grid; gap: 6px; } +.panel input, .panel select, .panel textarea, .panel button { font: inherit; color: inherit; background: var(--bg-secondary, transparent); border: 1px solid var(--border-color, #777); border-radius: 6px; padding: 8px 12px; } +.panel button { cursor: pointer; } +.panel button:disabled { cursor: default; opacity: .5; } +.panel textarea { min-height: 72px; } +.list { padding: 0; list-style: none; } +.list li { display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--border-color, #777); padding: 12px 0; } +.list li > span { flex: 1; overflow-wrap: anywhere; } diff --git a/app/ui_layer/browser/frontend/src/pages/Settings/CredentialsSettings.tsx b/app/ui_layer/browser/frontend/src/pages/Settings/CredentialsSettings.tsx new file mode 100644 index 000000000..7ddbbba75 --- /dev/null +++ b/app/ui_layer/browser/frontend/src/pages/Settings/CredentialsSettings.tsx @@ -0,0 +1,162 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useSettingsWebSocket } from './useSettingsWebSocket' +import styles from './CredentialsSettings.module.css' + +type Profile = { id: string; label: string; state: string } +type Item = { credential_id: string; version: number; kind: string; service: string; account: string; origins: string[] } +type Activity = { action: string; outcome: string; grant_id: string | null } +type ItemPage = { items: Item[]; view_id: string; next_offset: number | null; total: number } +type Reply = { request_id: string | null; operation: string; ok: boolean; value?: unknown; error?: string } +const errors: Record = { + credential_host_unavailable: 'The credential service is unavailable. Finish installation or restart the host.', + vault_locked: 'Unlock this profile to continue.', + owner_verification_required: 'Owner verification was cancelled or unavailable. Use your Windows password at the owner prompt.', + credential_changed: 'This item changed. Refresh its reference.', + credential_use_denied: 'This request was not approved.', + credential_busy: 'A credential request is dispatching. Try again shortly.', +} + +// Metadata stays in this mounted view. Source fields are entered only in the +// protected native host dialog, never in React, Redux or WebSocket payloads. +export function CredentialsSettings() { + const { send, onMessage, isConnected } = useSettingsWebSocket() + const [profiles, setProfiles] = useState([]) + const [profile, setProfile] = useState('') + const [actionProfile, setActionProfile] = useState(null) + const [items, setItems] = useState([]) + const [activity, setActivity] = useState([]) + const [busy, setBusy] = useState(false) + const [notice, setNotice] = useState('') + const [search, setSearch] = useState('') + const [label, setLabel] = useState('') + const [service, setService] = useState('') + const [account, setAccount] = useState('') + const [origins, setOrigins] = useState('') + const [kind, setKind] = useState('login') + const [fields, setFields] = useState('') + const [editing, setEditing] = useState(null) + const [replace, setReplace] = useState(false) + const timer = useRef>() + const pending = useRef(null) + const selected = profiles.find(p => p.id === profile) + const sendRequest = useCallback((operation: string, parameters: Record) => { + const requestId = crypto.randomUUID() + pending.current = requestId + setBusy(true) + clearTimeout(timer.current) + send('credentials_request', { operation, parameters, request_id: requestId }) + timer.current = setTimeout(() => { + setBusy(false); setNotice('The request timed out. Refresh before retrying; it may have completed.') + setItems([]); pending.current = null + }, 330_000) + }, [send]) + + useEffect(() => onMessage('credentials_result', (raw) => { + const reply = raw as Reply + if (!reply.request_id || reply.request_id !== pending.current) return + pending.current = null + clearTimeout(timer.current) + setBusy(false) + if (!reply.ok) { setNotice(errors[reply.error || ''] || 'The credential operation could not complete. Check the selected reference and installed helper.'); return } + setNotice('') + if (reply.operation === 'profiles') { + const next = reply.value as Profile[] + setProfiles(next) + setProfile(current => next.some(p => p.id === current) ? current : next[0]?.id || '') + if (next.find(p => p.id === profile)?.state !== 'ready') { setItems([]); setActivity([]) } + sendRequest('action_profile', {}) + } else if (reply.operation === 'action_profile' || reply.operation === 'action_profile_set') { + setActionProfile((reply.value as { profile_id: string | null }).profile_id) + } else if (reply.operation === 'items' || reply.operation === 'items_page') { + const page = reply.value as ItemPage + setItems(previous => reply.operation === 'items' ? page.items : [...previous, ...page.items]) + if (page.next_offset !== null) { + setBusy(true) + sendRequest('items_page', { profile_id: profile, view_id: page.view_id, offset: page.next_offset }) + } + } + else if (reply.operation === 'activity') setActivity(reply.value as Activity[]) + else { + setItems([]); setActivity([]); setEditing(null) + setNotice(reply.operation === 'lock' ? 'All profiles are locked and credential workers have stopped.' : 'Saved. Refresh the list to see the latest state.') + sendRequest('profiles', {}) + } + }), [onMessage, sendRequest, profile]) + + useEffect(() => { + if (isConnected) sendRequest('profiles', {}) + return () => { clearTimeout(timer.current); pending.current = null } + }, [isConnected, sendRequest]) + + useEffect(() => { + const interval = setInterval(() => { + if (isConnected && pending.current === null) sendRequest('profiles', {}) + }, 30_000) + return () => clearInterval(interval) + }, [isConnected, sendRequest]) + + function request(operation: string, parameters: Record) { + if (busy || !isConnected) return + setBusy(true); setNotice('Complete the Windows owner prompt when it appears.') + sendRequest(operation, parameters) + } + + function edit(item: Item) { + setEditing(item); setKind(item.kind); setService(item.service) + setAccount(item.account); setOrigins(item.origins.join('\n')); setReplace(false) + } + + return
+

Credentials

+

Save logins, API keys and service secrets in a protected profile. Labels and origins are visible metadata. Enter secret values only in the protected dialog.

+
+ + {selected?.state || 'unavailable'} + + + + +
+
+ Profile for isolated agent actions: {profiles.find(p => p.id === actionProfile)?.label || 'disabled'} + + +
+
{ e.preventDefault(); request('profile_add', { label }) }}> + + +
+

{notice}

+
+ + + +
+
    {items.filter(i => `${i.service} ${i.account} ${i.kind}`.toLowerCase().includes(search.toLowerCase())).map(item =>
  • + {item.service} · {item.account} · {item.kind}
    {item.origins.join(', ')}
    + e.target.select()} /> +
    + + + +
  • )}
+
{ + e.preventDefault() + const metadata = { profile_id: profile, service, account, origins: origins.split(/[\n,]/).map(s => s.trim()).filter(Boolean) } + if (editing) request('edit', { ...metadata, credential_id: editing.credential_id, version: editing.version, replace_fields: replace }) + else request('entry', { ...metadata, kind, fields: kind === 'service_secret' ? fields.split(',').map(s => s.trim()).filter(Boolean) : [] }) + }}> +

{editing ? 'Edit saved item' : 'Add credential'}

+ + + +