Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -56,5 +56,7 @@ SOURCERYKIT_ORG_ID=00000000-0000-0000-0000-000000000000
# SOURCERYKIT_TOKEN_STORE=/path/to/app/sourcerykit.env

# SOURCERYKIT_PROVABLY_APP_URL=https://app.provably.ai
# Consent page the browser sign-in opens (e.g. a local web app in dev).
# SOURCERYKIT_CONSENT_URL=https://switchboard.provably.ai/consent
# SOURCERYKIT_PROVABLY_API_URL=https://api.provably.ai
# SOURCERYKIT_PROVABLY_MCP_URL=https://mcp.provably.ai
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Breaking changes
- **User API key removed in favor of OAuth tokens** — authentication now uses `PROVABLY_ACCESS_TOKEN` (auto-refreshed via `PROVABLY_REFRESH_TOKEN`); the verify path uses the collection integration key. Existing users must re-run `sourcerykit init`. See the [migration guide](docs/migrations/unreleased/unreleased.md).
- **OAuth browser-only login** — `--register`, `--email`, and `--password` are removed; login is browser OAuth (PKCE) only. New accounts are created on the Provably web app during login.
- **Provably API client moved to [`provably-sdk`](https://pypi.org/project/provably-sdk/)** — `sourcerykit.provably` is gone; import from `provably` instead (e.g. `from provably import ProvablyError`, `from provably.service import service`). `ProvablyError` no longer subclasses `SourceryKitError`; catch it explicitly. sourcerykit configures the SDK on import.

### Features
- **Idempotent integration bootstrap** — re-running `init`/`doctor --fix` reuses the existing integration (exact collection match) instead of minting a duplicate key and shadow user.
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ dependencies = [
"requests >=2.34",
"jsonschema>=4.0",
"psycopg[binary]>=3.1",
"provably-sdk>=0.3.1,<1",
"pydantic>=2.6",
"python-dotenv>=1.2",
"sqlalchemy>=2.0",
Expand Down
1 change: 1 addition & 0 deletions src/sourcerykit/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from sourcerykit import _provably # noqa: F401 (configures the Provably SDK first)
from sourcerykit.bootstrap import bootstrap_system
from sourcerykit.errors import (
SourceryKitBootstrapError,
Expand Down
100 changes: 100 additions & 0 deletions src/sourcerykit/_provably.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Glue between sourcerykit and the Provably SDK (``provably-sdk``).

The SDK reads nothing on its own; this module tells it where sourcerykit keeps
its settings and session, and exempts the SDK's traffic from the interceptor.
It configures the SDK on import, which ``sourcerykit/__init__`` does first.
"""

import json
import os

import provably
from dotenv import set_key
from provably import OAuthTokens, ProvablyConfig

from sourcerykit.config import (
CONFIG_FILE,
get_bootstrap_app_url,
get_bootstrap_settings,
get_settings,
load_app_dir_config,
load_local_env,
save_app_dir_config,
)
from sourcerykit.errors import SourceryKitConfigError
from sourcerykit.intercept._self_egress import provably_self_egress

# The OAuth client sourcerykit is registered as, and the loopback port it listens on.
OAUTH_CLIENT_ID = "sourcerykit-cli"
OAUTH_LOOPBACK_PORT = 8910
# The web app's consent page
DEFAULT_CONSENT_URL = "https://switchboard.provably.ai/consent"


def _token_store() -> str | None:
"""Return the app's token-store path (SOURCERYKIT_TOKEN_STORE) or None for global JSON.

Resolved per-call so the app can set it at startup regardless of import order.
"""
return os.getenv("SOURCERYKIT_TOKEN_STORE") or None


class SourceryKitTokenStore:
"""The session in sourcerykit's global JSON, or in an embedding app's ``.env``."""

def load(self) -> OAuthTokens | None:
# Raises SourceryKitConfigError when sourcerykit is not set up, as before the SDK split.
settings = get_settings()
# sourcerykit only ever signs in as its own client, so its tokens are that client's.
return OAuthTokens(
access_token=settings.access_token,
refresh_token=settings.refresh_token or None,
client_id=OAUTH_CLIENT_ID,
)

def save(self, tokens: OAuthTokens) -> None:
store = _token_store()
if store:
set_key(store, "PROVABLY_ACCESS_TOKEN", tokens.access_token)
set_key(store, "PROVABLY_REFRESH_TOKEN", tokens.refresh_token or "")
os.environ["PROVABLY_ACCESS_TOKEN"] = tokens.access_token
if tokens.refresh_token:
os.environ["PROVABLY_REFRESH_TOKEN"] = tokens.refresh_token
load_local_env.cache_clear()
else:
save_app_dir_config(token=tokens.access_token, refresh_token=tokens.refresh_token)
get_settings.cache_clear()

def clear_refresh_token(self) -> None:
store = _token_store()
if store:
set_key(store, "PROVABLY_REFRESH_TOKEN", "")
os.environ.pop("PROVABLY_REFRESH_TOKEN", None)
load_local_env.cache_clear()
else:
payload = load_app_dir_config()
payload.pop("refresh_token", None)
CONFIG_FILE.write_text(json.dumps(payload))
load_app_dir_config.cache_clear()
get_settings.cache_clear()


def sdk_config() -> ProvablyConfig:
"""sourcerykit's settings as the SDK's; before setup, the API and app URLs alone."""
try:
settings = get_settings()
except SourceryKitConfigError:
return ProvablyConfig(api_url=get_bootstrap_settings(), app_url=get_bootstrap_app_url())
return ProvablyConfig(api_url=settings.provably_api, app_url=settings.provably_app, org_id=settings.org_id)


def consent_page_url() -> str:
"""URL of the web app consent page: ``SOURCERYKIT_CONSENT_URL``, else production.

A setting of its own, not ``SOURCERYKIT_PROVABLY_APP_URL``: that one is the
base of the app's query-record links, which a consent page URL would break.
"""
return (os.environ.get("SOURCERYKIT_CONSENT_URL") or "").strip() or DEFAULT_CONSENT_URL


provably.configure(config=sdk_config, tokens=SourceryKitTokenStore(), egress=provably_self_egress)
17 changes: 11 additions & 6 deletions src/sourcerykit/bootstrap/_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
from dataclasses import dataclass, field
from uuid import UUID

from provably import ConnectionInfo, ProvablyError
from provably.service import service

from sourcerykit.config import Settings
from sourcerykit.db._engine import ConnectionInfo, get_connection_info
from sourcerykit.db._engine import get_connection_info
from sourcerykit.db._schema import INTERCEPTS_TABLE
from sourcerykit.errors import SourceryKitBootstrapError, SourceryKitError
from sourcerykit.logger import get_logger
from sourcerykit.provably._errors import ProvablyError
from sourcerykit.provably.service import service

_log = get_logger(__name__)

Expand Down Expand Up @@ -45,14 +47,17 @@ async def run_handshake(self, project_name: str) -> None:
connection_info = get_connection_info()
self.database_id = await self._resolve_database(connection_info)

ids = await service.get_database_schema_id_and_table_id(self.middleware_id, connection_info)
ids = await service.get_database_schema_id_and_table_id(
self.middleware_id, connection_info, table_name=INTERCEPTS_TABLE
)
self.schema_id = ids["schema_id"]
self.table_id = ids["table_id"]

self.collection_name = project_name
self.collection_id = await self._resolve_collection(project_name)
self.integration_key = await self._resolve_integration_key()
except SourceryKitError:
# ProvablyError comes from provably-sdk, so it is not a SourceryKitError.
except (SourceryKitError, ProvablyError):
raise
except Exception as e:
_log.error("handshake_failed_unexpected", error=str(e))
Expand Down Expand Up @@ -110,7 +115,7 @@ async def _resolve_collection(self, project_name: str) -> UUID:
async def _resolve_integration_key(self) -> str:
if self.collection_id is None:
raise SourceryKitBootstrapError("collection_id is not set; _resolve_collection() must succeed first")
_, key = await service.ensure_integration(self.collection_id)
_, key = await service.ensure_integration(self.collection_id, name=INTERCEPTS_TABLE)
return key


Expand Down
3 changes: 2 additions & 1 deletion src/sourcerykit/bootstrap/bootstrap.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from provably.service import service as provably_service

from sourcerykit.bootstrap._cache import _BOOTSTRAP_INSTANCE, ProvablyBootstrapCache
from sourcerykit.config import get_settings, save_local_env
from sourcerykit.db._engine import get_engine
Expand All @@ -8,7 +10,6 @@
)
from sourcerykit.intercept.interceptor import init_interceptor
from sourcerykit.logger import get_logger
from sourcerykit.provably.service import service as provably_service

_log = get_logger(__name__)

Expand Down
12 changes: 8 additions & 4 deletions src/sourcerykit/cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
import dataclasses
from collections.abc import Callable

from provably import ProvablyConnectionError, ProvablyUnauthorizedError
from provably.auth_service import auth_service
from provably.service import service

from sourcerykit.cli.init import run_full_bootstrap
from sourcerykit.cli.utils import console, mask_postgres_url, run_connectivity_check
from sourcerykit.config import Settings, get_settings
from sourcerykit.db._engine import get_connection_info
from sourcerykit.provably._errors import ProvablyConnectionError, ProvablyUnauthorizedError
from sourcerykit.provably.auth_service import auth_service
from sourcerykit.provably.service import service
from sourcerykit.db._schema import INTERCEPTS_TABLE


def _check_token_and_org(settings: Settings) -> tuple[bool, str]:
Expand Down Expand Up @@ -95,7 +97,9 @@ async def _deep_check_collection_and_ids(settings: Settings) -> tuple[bool, str]
if remote_db != settings.database_id:
return False, f"Database mismatch (local={settings.database_id}, remote={remote_db})"

ids = await service.get_database_schema_id_and_table_id(settings.middleware_id, connection_info)
ids = await service.get_database_schema_id_and_table_id(
settings.middleware_id, connection_info, table_name=INTERCEPTS_TABLE
)
if ids["schema_id"] != settings.schema_id:
return False, f"Schema mismatch (local={settings.schema_id}, remote={ids['schema_id']})"
if ids["table_id"] != settings.table_id:
Expand Down
2 changes: 1 addition & 1 deletion src/sourcerykit/cli/feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@

import questionary
import typer
from provably.service import ProvablyService

from sourcerykit.cli.utils import console, require_settings
from sourcerykit.provably.service import ProvablyService

service = ProvablyService()

Expand Down
24 changes: 13 additions & 11 deletions src/sourcerykit/cli/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,19 @@

import questionary
import typer
from provably import (
Organization,
OrganizationType,
ProvablyConnectionError,
ProvablyUnauthorizedError,
browser_login,
)
from provably._api import get_api as get_main_api
from provably._http import get_http
from provably.auth_service import ProvablyAuthService
from provably.service import service as provably_service

from sourcerykit._provably import OAUTH_CLIENT_ID, OAUTH_LOOPBACK_PORT, consent_page_url
from sourcerykit.bootstrap._cache import _BOOTSTRAP_INSTANCE
from sourcerykit.cli import logo
from sourcerykit.cli.utils import (
Expand All @@ -20,16 +32,6 @@
from sourcerykit.config import load_app_dir_config, save_app_dir_config, save_local_env
from sourcerykit.db._engine import get_engine
from sourcerykit.db._schema import ensure_schema
from sourcerykit.provably._api import get_api as get_main_api
from sourcerykit.provably._auth_api import Organization, OrganizationType
from sourcerykit.provably._errors import (
ProvablyConnectionError,
ProvablyUnauthorizedError,
)
from sourcerykit.provably._http import get_http
from sourcerykit.provably.auth_service import ProvablyAuthService
from sourcerykit.provably.oauth_login import browser_login
from sourcerykit.provably.service import service as provably_service

service = ProvablyAuthService()

Expand All @@ -48,7 +50,7 @@ def _run_oauth_browser(
try:
console.print("\n[bold]🔐 Browser authentication[/bold]")
console.print("Opening browser for authentication...")
tokens = asyncio.run(browser_login())
tokens = asyncio.run(browser_login(consent_page_url(), client_id=OAUTH_CLIENT_ID, port=OAUTH_LOOPBACK_PORT))
email = asyncio.run(service.get_user_email(tokens.access_token))
except ProvablyConnectionError as e:
console.print(f"[red]❌ Network error: {e}[/red]")
Expand Down
2 changes: 1 addition & 1 deletion src/sourcerykit/cli/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
import questionary
import typer
from dotenv import unset_key
from provably.service import service

from sourcerykit.cli.utils import console, mask_postgres_url, require_settings
from sourcerykit.config import LOCAL_ENV_FILE, save_local_env
from sourcerykit.provably.service import service

sandbox = typer.Typer(no_args_is_help=True)

Expand Down
4 changes: 2 additions & 2 deletions src/sourcerykit/cli/trace.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from uuid import UUID

import typer
from provably import QueryAnswer
from provably.service import service
from rich.markup import escape as rich_escape
from rich.panel import Panel
from rich.table import Table
Expand All @@ -19,8 +21,6 @@
select_trace_intercepts_by_trace_id,
select_traces_with_intercept_count,
)
from sourcerykit.provably._answer_model import QueryAnswer
from sourcerykit.provably.service import service
from sourcerykit.utils import extract_actual

trace = typer.Typer(no_args_is_help=True)
Expand Down
4 changes: 2 additions & 2 deletions src/sourcerykit/cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@
import psycopg
import questionary
import typer
from provably._api import get_api as get_main_api
from provably._http import get_http
from rich.console import Console

from sourcerykit.config import CONFIG_FILE, Settings, get_settings, load_app_dir_config
from sourcerykit.provably._api import get_api as get_main_api
from sourcerykit.provably._http import get_http

console = Console()

Expand Down
8 changes: 8 additions & 0 deletions src/sourcerykit/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,3 +218,11 @@ def get_bootstrap_settings() -> str:
if not raw:
raw = load_app_dir_config().get("provably_api", "").strip().rstrip("/")
return raw or DEFAULT_PROVABLY_API_URL


def get_bootstrap_app_url() -> str:
"""Return the Provably app URL without requiring full settings validation."""
raw = (os.getenv("SOURCERYKIT_PROVABLY_APP_URL") or "").strip().rstrip("/")
if not raw:
raw = str(load_app_dir_config().get("provably_app", "")).strip().rstrip("/")
return raw or DEFAULT_PROVABLY_APP_URL
43 changes: 1 addition & 42 deletions src/sourcerykit/db/_engine.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
"""SQLAlchemy engine"""

from dataclasses import dataclass
from typing import Any
from urllib.parse import unquote, urlparse

from provably import ConnectionInfo
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine

from sourcerykit.config import get_settings
Expand All @@ -13,44 +10,6 @@
_log = get_logger(__name__)


@dataclass
class ConnectionInfo:
name: str
username: str
password: str
provider: str
uri: str

@classmethod
def from_url(cls, url: str) -> "ConnectionInfo":
"""Parse a PostgreSQL URL into a ConnectionInfo."""
parsed = urlparse(url)
provider = parsed.scheme.split("+", 1)[0]
host = parsed.hostname or ""
port = parsed.port
uri = f"{host}:{port}" if port else host
return cls(
name=parsed.path.lstrip("/"),
username=unquote(parsed.username or ""),
password=unquote(parsed.password or ""),
provider=provider,
uri=uri,
)

def same_server(self, other: "ConnectionInfo") -> bool:
"""True if both point to the same database server and name (ignores credentials and query params)."""
return (self.provider, self.uri, self.name) == (other.provider, other.uri, other.name)

def to_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"username": self.username,
"password": self.password,
"provider": self.provider,
"uri": self.uri,
}


# Internal singleton to ensure we only ever create one engine per process
_ENGINE: AsyncEngine | None = None

Expand Down
Loading
Loading