diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3deddd6d1..03fdf1949 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2877,6 +2877,55 @@ jobs: echo "=== API Server Logs ===" cat /tmp/api-server.log || echo "No API server log found" + test-supabase-org-authz-integration: + needs: [detect-changes] + if: >- + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.control-plane == 'true' || + needs.detect-changes.outputs.integration-tests == 'true' || + needs.detect-changes.outputs.ci == 'true' + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || '' }} + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + prune-cache: false + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version-file: ".python-version" + + - name: Install Supabase CLI + uses: supabase/setup-cli@v2 + with: + version: 2.84.2 + + - name: Verify Supabase CLI + run: supabase --version + + - name: Verify Docker + run: docker version + + - name: Install integration test dependencies + working-directory: ./hindsight-integration-tests + run: uv sync --frozen + + - name: Install API dependencies + working-directory: ./hindsight-api-slim + run: uv sync --frozen --all-extras --index-strategy unsafe-best-match + + - name: Run Supabase org authz integration test + working-directory: ./hindsight-integration-tests + run: uv run pytest tests/test_supabase_org_authz.py -v + test-integration: needs: [detect-changes] if: >- @@ -4701,6 +4750,7 @@ jobs: - test-rust-client - test-go-client - test-openclaw-integration + - test-supabase-org-authz-integration - test-integration - test-ag2-integration - test-autogen-integration diff --git a/.gitignore b/.gitignore index 2adde0e7c..a1e53dce0 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,5 @@ hindsight-integrations/_drafts/ blog-post* .worktrees/ +hindsight-control-plane/supabase/.branches/ +hindsight-control-plane/supabase/.temp/ diff --git a/hindsight-api-slim/hindsight_api/api/http.py b/hindsight-api-slim/hindsight_api/api/http.py index 705f50aff..1230fff0b 100644 --- a/hindsight-api-slim/hindsight_api/api/http.py +++ b/hindsight-api-slim/hindsight_api/api/http.py @@ -2742,6 +2742,12 @@ class AsyncOperationSubmitResponse(BaseModel): class FeaturesInfo(BaseModel): """Feature flags indicating which capabilities are enabled.""" + authz_profile: str = Field(default="disabled", description="Configured authorization deployment profile") + tenant_extension: str | None = Field(default=None, description="Loaded tenant extension class name") + operation_validator_extension: str | None = Field( + default=None, description="Loaded operation validator extension class name" + ) + supabase_org_ready: bool = Field(default=False, description="Whether the supabase_org profile is fully configured") observations: bool = Field(description="Whether observations (auto-consolidation) are enabled") mcp: bool = Field(description="Whether MCP (Model Context Protocol) server is enabled") worker: bool = Field(description="Whether the background worker is enabled") @@ -3040,6 +3046,20 @@ async def lifespan(app: FastAPI): logging.error(f"Failed to initialize tracing: {e}") logging.warning("Continuing without tracing") + # Call tenant and validator startup hooks before memory.initialize(). + # Startup migrations call tenant_extension.list_tenants(), so external + # tenant resolvers (for example Supabase-backed org discovery) must be + # connected before initialization starts. + tenant_extension = memory.tenant_extension + if tenant_extension: + await tenant_extension.on_startup() + logging.info("Tenant extension started") + + operation_validator = getattr(memory, "_operation_validator", None) + if operation_validator: + await operation_validator.on_startup() + logging.info("Operation validator extension started") + # Startup: Initialize database and memory system (migrations run inside initialize if enabled) if initialize_memory: await memory.initialize() @@ -3078,12 +3098,6 @@ async def lifespan(app: FastAPI): "Tasks (mental model refresh, consolidation) will run synchronously." ) - # Call tenant extension startup hook (e.g. JWKS fetch for Supabase) - tenant_extension = memory.tenant_extension - if tenant_extension: - await tenant_extension.on_startup() - logging.info("Tenant extension started") - # Call HTTP extension startup hook if http_extension: await http_extension.on_startup() @@ -3107,6 +3121,10 @@ async def lifespan(app: FastAPI): await tenant_extension.on_shutdown() logging.info("Tenant extension stopped") + if operation_validator: + await operation_validator.on_shutdown() + logging.info("Operation validator extension stopped") + # Call HTTP extension shutdown hook if http_extension: await http_extension.on_shutdown() @@ -3292,7 +3310,10 @@ def _register_routes(app: FastAPI): # Create audit decorator bound to this app's audit logger audited = _make_audited_http(lambda: getattr(app.state, "audit_logger", None)) - def get_request_context(authorization: str | None = Header(default=None)) -> RequestContext: + def get_request_context( + request: Request, + authorization: str | None = Header(default=None), + ) -> RequestContext: """ Extract request context from Authorization header. @@ -3308,7 +3329,10 @@ def get_request_context(authorization: str | None = Header(default=None)) -> Req api_key = authorization[7:].strip() else: api_key = authorization.strip() - return RequestContext(api_key=api_key) + return RequestContext( + api_key=api_key, + selected_org_id=request.headers.get("x-hindsight-org-id"), + ) def precheck_for(operation: str): """ @@ -3421,11 +3445,20 @@ async def version_endpoint() -> VersionResponse: """ from hindsight_api import __version__ from hindsight_api.config import _get_raw_config + from hindsight_api.extensions.authz_profile import get_authz_profile_info config = _get_raw_config() + authz_info = get_authz_profile_info( + app.state.memory.tenant_extension, + getattr(app.state.memory, "_operation_validator", None), + ) return VersionResponse( api_version=__version__, features=FeaturesInfo( + authz_profile=authz_info.authz_profile, + tenant_extension=authz_info.tenant_extension, + operation_validator_extension=authz_info.operation_validator_extension, + supabase_org_ready=authz_info.supabase_org_ready, observations=config.enable_observations, mcp=config.mcp_enabled, worker=config.worker_enabled, diff --git a/hindsight-api-slim/hindsight_api/engine/memory_engine.py b/hindsight-api-slim/hindsight_api/engine/memory_engine.py index f94da3de8..78bab7d81 100644 --- a/hindsight-api-slim/hindsight_api/engine/memory_engine.py +++ b/hindsight-api-slim/hindsight_api/engine/memory_engine.py @@ -1143,6 +1143,9 @@ def __init__( webhook_manager=None, current_schema=None, ) + self._tenant_extension.set_context(self._ext_ctx) + if self._operation_validator is not None: + self._operation_validator.set_context(self._ext_ctx) loaded = load_extension("MEMORY_DEFENSE", MemoryDefenseExtension, context=self._ext_ctx) if loaded is not None: diff --git a/hindsight-api-slim/hindsight_api/extensions/__init__.py b/hindsight-api-slim/hindsight_api/extensions/__init__.py index 08db9f800..f0c6192ca 100644 --- a/hindsight-api-slim/hindsight_api/extensions/__init__.py +++ b/hindsight-api-slim/hindsight_api/extensions/__init__.py @@ -19,6 +19,8 @@ from hindsight_api.extensions.builtin import ( ApiKeyTenantExtension, MemoryDefenseRegexExtension, + SupabaseAuthorizationExtension, + SupabaseOrgTenantExtension, SupabaseTenantExtension, ) from hindsight_api.extensions.context import DefaultExtensionContext, ExtensionContext @@ -111,6 +113,8 @@ "MentalModelRefreshResult", # Tenant/Auth "ApiKeyTenantExtension", + "SupabaseAuthorizationExtension", + "SupabaseOrgTenantExtension", "SupabaseTenantExtension", "AuthenticationError", "RequestContext", diff --git a/hindsight-api-slim/hindsight_api/extensions/authz_profile.py b/hindsight-api-slim/hindsight_api/extensions/authz_profile.py new file mode 100644 index 000000000..51f1a6d22 --- /dev/null +++ b/hindsight-api-slim/hindsight_api/extensions/authz_profile.py @@ -0,0 +1,72 @@ +"""Deployment profile validation for grouped authn/authz integrations.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Any + +ENV_AUTHZ_PROFILE = "HINDSIGHT_API_AUTHZ_PROFILE" +SUPABASE_ORG_PROFILE = "supabase_org" +SUPABASE_ORG_TENANT_CLASS = "SupabaseOrgTenantExtension" +SUPABASE_ORG_VALIDATOR_CLASS = "SupabaseAuthorizationExtension" + + +@dataclass(frozen=True) +class AuthzProfileInfo: + """Authz deployment state exposed to control-plane health/version checks.""" + + authz_profile: str + tenant_extension: str | None + operation_validator_extension: str | None + supabase_org_ready: bool + + +def _class_name(instance: Any | None) -> str | None: + return instance.__class__.__name__ if instance is not None else None + + +def get_authz_profile_info(tenant_extension: Any | None, operation_validator: Any | None) -> AuthzProfileInfo: + """Return normalized authz profile information for diagnostics and feature gates.""" + + profile = os.getenv(ENV_AUTHZ_PROFILE, "disabled").strip() or "disabled" + tenant_name = _class_name(tenant_extension) + validator_name = _class_name(operation_validator) + return AuthzProfileInfo( + authz_profile=profile, + tenant_extension=tenant_name, + operation_validator_extension=validator_name, + supabase_org_ready=( + profile == SUPABASE_ORG_PROFILE + and tenant_name == SUPABASE_ORG_TENANT_CLASS + and validator_name == SUPABASE_ORG_VALIDATOR_CLASS + ), + ) + + +def validate_authz_profile(tenant_extension: Any | None, operation_validator: Any | None) -> None: + """Fail fast when a deployment profile is only partially configured.""" + + info = get_authz_profile_info(tenant_extension, operation_validator) + + has_supabase_org_piece = ( + info.tenant_extension == SUPABASE_ORG_TENANT_CLASS + or info.operation_validator_extension == SUPABASE_ORG_VALIDATOR_CLASS + ) + if info.authz_profile == SUPABASE_ORG_PROFILE: + if not info.supabase_org_ready: + raise RuntimeError( + "HINDSIGHT_API_AUTHZ_PROFILE=supabase_org requires " + "HINDSIGHT_API_TENANT_EXTENSION=...:SupabaseOrgTenantExtension and " + "HINDSIGHT_API_OPERATION_VALIDATOR_EXTENSION=...:SupabaseAuthorizationExtension. " + f"Got tenant_extension={info.tenant_extension!r}, " + f"operation_validator_extension={info.operation_validator_extension!r}." + ) + return + + if has_supabase_org_piece: + raise RuntimeError( + "Supabase org authz extensions are a grouped deployment profile. " + "Set HINDSIGHT_API_AUTHZ_PROFILE=supabase_org and configure both " + "SupabaseOrgTenantExtension and SupabaseAuthorizationExtension." + ) diff --git a/hindsight-api-slim/hindsight_api/extensions/builtin/__init__.py b/hindsight-api-slim/hindsight_api/extensions/builtin/__init__.py index 206120393..ac3e4549f 100644 --- a/hindsight-api-slim/hindsight_api/extensions/builtin/__init__.py +++ b/hindsight-api-slim/hindsight_api/extensions/builtin/__init__.py @@ -7,6 +7,8 @@ Available built-in extensions: - ApiKeyTenantExtension: Simple API key validation with public schema - SupabaseTenantExtension: Supabase JWT validation with per-user schema isolation + - SupabaseOrgTenantExtension: Supabase Auth with organization schema isolation + - SupabaseAuthorizationExtension: Organization role/API-key bank-scope authorization Example usage: HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension @@ -14,11 +16,14 @@ """ from hindsight_api.extensions.builtin.memory_defense_regex import MemoryDefenseRegexExtension +from hindsight_api.extensions.builtin.supabase_org import SupabaseAuthorizationExtension, SupabaseOrgTenantExtension from hindsight_api.extensions.builtin.supabase_tenant import SupabaseTenantExtension from hindsight_api.extensions.builtin.tenant import ApiKeyTenantExtension __all__ = [ "ApiKeyTenantExtension", "MemoryDefenseRegexExtension", + "SupabaseAuthorizationExtension", + "SupabaseOrgTenantExtension", "SupabaseTenantExtension", ] diff --git a/hindsight-api-slim/hindsight_api/extensions/builtin/supabase_org.py b/hindsight-api-slim/hindsight_api/extensions/builtin/supabase_org.py new file mode 100644 index 000000000..fbfab9df6 --- /dev/null +++ b/hindsight-api-slim/hindsight_api/extensions/builtin/supabase_org.py @@ -0,0 +1,522 @@ +"""Supabase organization-based authn/authz extensions for Cloud-like deployments.""" + +from __future__ import annotations + +import asyncio +import hashlib +import logging +import re +import time +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any, Literal + +import httpx +import jwt as pyjwt +from jwt import PyJWK +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, field_validator + +from hindsight_api.extensions.operation_validator import ( + BankListContext, + BankListResult, + BankReadContext, + BankWriteContext, + OperationValidatorExtension, + RecallContext, + ReflectContext, + RetainContext, + ValidationResult, +) +from hindsight_api.extensions.tenant import AuthenticationError, Tenant, TenantContext, TenantExtension +from hindsight_api.models import RequestContext + +logger = logging.getLogger(__name__) + +REQUEST_TIMEOUT_SECONDS = 10.0 +JWKS_CACHE_TTL_SECONDS = 600 +JWKS_MIN_REFRESH_INTERVAL_SECONDS = 30 +SUPPORTED_ALGORITHMS = ["RS256", "ES256"] +MIN_TOKEN_LENGTH = 20 + +_UUID_OR_SLUG_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$") +_JWT_SHAPE_RE = re.compile(r"^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$") +_SCHEMA_PREFIX_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") + + +class OrganizationRow(BaseModel): + """Organization row returned by Supabase PostgREST.""" + + model_config = ConfigDict(extra="ignore") + + id: str + name: str | None = None + config: dict[str, Any] = Field(default_factory=dict) + + +class MemberRow(BaseModel): + """Organization membership row returned by Supabase PostgREST.""" + + model_config = ConfigDict(extra="ignore") + + org_id: str + user_id: str + role: Literal["owner", "admin", "member"] + + +class ApiKeyRow(BaseModel): + """Hindsight scoped API key row returned by Supabase PostgREST.""" + + model_config = ConfigDict(extra="ignore") + + id: str + org_id: str + role: Literal["owner", "admin", "member"] = "member" + allowed_operations: list[str] | None = None + revoked_at: str | None = None + expires_at: str | None = None + + @field_validator("allowed_operations", mode="before") + @classmethod + def coerce_allowed_operations(cls, value: Any) -> list[str] | None: + if value is None: + return None + if isinstance(value, list): + return [str(item) for item in value] + return None + + +class ApiKeyBankScopeRow(BaseModel): + """Bank scope row for a Hindsight scoped API key.""" + + model_config = ConfigDict(extra="ignore") + + bank_id: str + + +@dataclass(frozen=True) +class CallerPolicy: + """Resolved caller policy shared by tenant and operation validator extensions.""" + + org_id: str + schema_name: str + user_id: str | None + api_key_id: str | None + role: Literal["owner", "admin", "member"] + allowed_bank_ids: frozenset[str] | None + allowed_operations: frozenset[str] | None + tenant_config: dict[str, Any] = field(default_factory=dict) + + @property + def is_admin(self) -> bool: + return self.role in {"owner", "admin"} + + +@dataclass(frozen=True) +class _CachedPolicy: + policy: CallerPolicy + expires_at_monotonic: float + + +class SupabasePolicyResolver: + """Resolve Supabase JWTs and Hindsight scoped API keys into caller policies.""" + + def __init__(self, config: dict[str, str]): + self.supabase_url = (config.get("supabase_url") or "").rstrip("/") + self.supabase_service_key = config.get("supabase_service_key") + self.schema_prefix = config.get("schema_prefix", "org") + self.cache_ttl_seconds = int(config.get("policy_cache_ttl_seconds", "5")) + self._http_client: httpx.AsyncClient | None = None + self._jwks_keys: dict[str, PyJWK] = {} + self._jwks_last_fetched = 0.0 + self._use_jwks = False + self._cache: dict[str, _CachedPolicy] = {} + + if not self.supabase_url: + raise ValueError("HINDSIGHT_AUTH_SUPABASE_URL or HINDSIGHT_API_*_SUPABASE_URL is required") + if not self.supabase_service_key: + raise ValueError("HINDSIGHT_AUTH_SUPABASE_SERVICE_KEY or HINDSIGHT_API_*_SUPABASE_SERVICE_KEY is required") + if not _SCHEMA_PREFIX_RE.match(self.schema_prefix): + raise ValueError("schema_prefix must be a valid PostgreSQL identifier component") + + async def on_startup(self) -> None: + await self._ensure_http_client() + await self._try_init_jwks() + + async def on_shutdown(self) -> None: + if self._http_client is not None: + await self._http_client.aclose() + self._http_client = None + + async def resolve(self, context: RequestContext) -> CallerPolicy: + token = context.api_key + if not token: + raise AuthenticationError("Missing Authorization header. Expected: Bearer ") + if len(token) < MIN_TOKEN_LENGTH: + raise AuthenticationError("Invalid credential format") + + cache_key = f"{token}:{context.selected_org_id or ''}" + cached = self._cache.get(cache_key) + now = time.monotonic() + if cached and cached.expires_at_monotonic > now: + return cached.policy + + if _JWT_SHAPE_RE.match(token): + policy = await self._resolve_jwt(token, context.selected_org_id) + else: + policy = await self._resolve_api_key(token) + + self._cache[cache_key] = _CachedPolicy(policy=policy, expires_at_monotonic=now + self.cache_ttl_seconds) + return policy + + async def list_tenants(self) -> list[Tenant]: + rows = await self._rest_get("organizations", select="id") + organizations = TypeAdapter(list[OrganizationRow]).validate_python(rows) + return [Tenant(schema=self._schema_for_org(row.id), tenant_id=row.id) for row in organizations] + + async def _resolve_jwt(self, token: str, selected_org_id: str | None) -> CallerPolicy: + if not selected_org_id: + raise AuthenticationError("Missing X-Hindsight-Org-Id header for Supabase JWT requests") + self._validate_org_id(selected_org_id) + user_id = await self._verify_token(token) + member = await self._get_member(selected_org_id, user_id) + if member is None: + raise AuthenticationError("User is not a member of the selected organization") + organization = await self._get_organization(selected_org_id) + if organization is None: + raise AuthenticationError("Selected organization does not exist") + return CallerPolicy( + org_id=selected_org_id, + schema_name=self._schema_for_org(selected_org_id), + user_id=user_id, + api_key_id=None, + role=member.role, + allowed_bank_ids=None, + allowed_operations=None, + tenant_config=organization.config, + ) + + async def _resolve_api_key(self, api_key: str) -> CallerPolicy: + key_hash = hashlib.sha256(api_key.encode("utf-8")).hexdigest() + rows = await self._rest_get( + "hindsight_api_keys", + select="id,org_id,role,allowed_operations,revoked_at,expires_at", + key_hash=f"eq.{key_hash}", + revoked_at="is.null", + limit="1", + ) + keys = TypeAdapter(list[ApiKeyRow]).validate_python(rows) + if not keys: + raise AuthenticationError("Invalid Hindsight API key") + key = keys[0] + if key.expires_at and _is_past_timestamp(key.expires_at): + raise AuthenticationError("Hindsight API key has expired") + scope_rows = await self._rest_get( + "hindsight_api_key_bank_scopes", + select="bank_id", + api_key_id=f"eq.{key.id}", + ) + scopes = TypeAdapter(list[ApiKeyBankScopeRow]).validate_python(scope_rows) + organization = await self._get_organization(key.org_id) + if organization is None: + raise AuthenticationError("API key organization does not exist") + return CallerPolicy( + org_id=key.org_id, + schema_name=self._schema_for_org(key.org_id), + user_id=None, + api_key_id=key.id, + role=key.role, + allowed_bank_ids=frozenset(scope.bank_id for scope in scopes) if scopes else None, + allowed_operations=frozenset(key.allowed_operations) if key.allowed_operations is not None else None, + tenant_config=organization.config, + ) + + async def _get_member(self, org_id: str, user_id: str) -> MemberRow | None: + rows = await self._rest_get( + "organization_members", + select="org_id,user_id,role", + org_id=f"eq.{org_id}", + user_id=f"eq.{user_id}", + limit="1", + ) + members = TypeAdapter(list[MemberRow]).validate_python(rows) + return members[0] if members else None + + async def _get_organization(self, org_id: str) -> OrganizationRow | None: + rows = await self._rest_get("organizations", select="id,name,config", id=f"eq.{org_id}", limit="1") + organizations = TypeAdapter(list[OrganizationRow]).validate_python(rows) + return organizations[0] if organizations else None + + async def _rest_get(self, table: str, **params: str) -> Any: + await self._ensure_http_client() + assert self._http_client is not None + response = await self._http_client.get( + f"{self.supabase_url}/rest/v1/{table}", + params=params, + headers={ + "apikey": self.supabase_service_key or "", + "Authorization": f"Bearer {self.supabase_service_key}", + }, + ) + if response.status_code >= 400: + raise AuthenticationError(f"Supabase policy lookup failed: {response.status_code}") + return response.json() + + async def _try_init_jwks(self) -> None: + try: + await self._fetch_jwks() + if self._jwks_keys: + self._use_jwks = True + return + except Exception as exc: + logger.warning("Could not fetch Supabase JWKS; falling back to /auth/v1/user: %s", exc) + self._use_jwks = False + + async def _fetch_jwks(self) -> None: + await self._ensure_http_client() + assert self._http_client is not None + response = await self._http_client.get(f"{self.supabase_url}/auth/v1/.well-known/jwks.json") + response.raise_for_status() + keys: dict[str, PyJWK] = {} + for key_data in response.json().get("keys", []): + kid = key_data.get("kid") + if kid: + keys[kid] = PyJWK(key_data) + self._jwks_keys = keys + self._jwks_last_fetched = time.monotonic() + + async def _verify_token(self, token: str) -> str: + return await self._verify_token_jwks(token) if self._use_jwks else await self._verify_token_legacy(token) + + async def _verify_token_jwks(self, token: str) -> str: + try: + signing_key = await self._get_signing_key(token) + payload = pyjwt.decode( + token, + signing_key.key, + algorithms=SUPPORTED_ALGORITHMS, + audience="authenticated", + issuer=f"{self.supabase_url}/auth/v1", + ) + except pyjwt.ExpiredSignatureError: + raise AuthenticationError("Token has expired") + except pyjwt.InvalidTokenError as exc: + raise AuthenticationError(f"Invalid token: {exc!s}") + user_id = payload.get("sub") + if not user_id: + raise AuthenticationError("Token valid but missing subject (sub) claim") + return str(user_id) + + async def _get_signing_key(self, token: str) -> PyJWK: + header = pyjwt.get_unverified_header(token) + kid = header.get("kid") + if not kid: + raise AuthenticationError("Token missing key ID (kid) header") + now = time.monotonic() + if now - self._jwks_last_fetched > JWKS_CACHE_TTL_SECONDS: + await self._fetch_jwks() + if kid in self._jwks_keys: + return self._jwks_keys[kid] + if now - self._jwks_last_fetched > JWKS_MIN_REFRESH_INTERVAL_SECONDS: + await self._fetch_jwks() + if kid in self._jwks_keys: + return self._jwks_keys[kid] + raise AuthenticationError("Unable to find signing key for token") + + async def _verify_token_legacy(self, token: str) -> str: + await self._ensure_http_client() + assert self._http_client is not None + response = await self._http_client.get( + f"{self.supabase_url}/auth/v1/user", + headers={"Authorization": f"Bearer {token}", "apikey": self.supabase_service_key or ""}, + ) + if response.status_code == 401: + raise AuthenticationError("Invalid or expired token") + if response.status_code != 200: + raise AuthenticationError(f"Authentication failed: {response.status_code}") + user_id = response.json().get("id") + if not user_id: + raise AuthenticationError("Token valid but no user ID found") + return str(user_id) + + async def _ensure_http_client(self) -> None: + if self._http_client is None: + self._http_client = httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS) + + def _schema_for_org(self, org_id: str) -> str: + self._validate_org_id(org_id) + return f"{self.schema_prefix}_{org_id.replace('-', '_')}" + + @staticmethod + def _validate_org_id(org_id: str) -> None: + if not _UUID_OR_SLUG_RE.match(org_id): + raise AuthenticationError("Invalid organization ID") + + +def _resolver_config(config: dict[str, str]) -> dict[str, str]: + """Accept both grouped auth env names and current extension-prefixed env names.""" + + import os + + resolved = dict(config) + if "supabase_url" not in resolved and os.getenv("HINDSIGHT_AUTH_SUPABASE_URL"): + resolved["supabase_url"] = os.environ["HINDSIGHT_AUTH_SUPABASE_URL"] + if "supabase_service_key" not in resolved and os.getenv("HINDSIGHT_AUTH_SUPABASE_SERVICE_KEY"): + resolved["supabase_service_key"] = os.environ["HINDSIGHT_AUTH_SUPABASE_SERVICE_KEY"] + if "schema_prefix" not in resolved and os.getenv("HINDSIGHT_AUTH_SCHEMA_PREFIX"): + resolved["schema_prefix"] = os.environ["HINDSIGHT_AUTH_SCHEMA_PREFIX"] + return resolved + + +def _is_past_timestamp(value: str) -> bool: + normalized = value.replace("Z", "+00:00") + try: + expires_at = datetime.fromisoformat(normalized) + except ValueError as exc: + raise AuthenticationError("Hindsight API key has invalid expiration timestamp") from exc + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=UTC) + return expires_at <= datetime.now(UTC) + + +class SupabaseOrgTenantExtension(TenantExtension): + """Tenant extension that maps Supabase users or Hindsight API keys to organization schemas.""" + + def __init__(self, config: dict[str, str]): + super().__init__(config) + self.resolver = SupabasePolicyResolver(_resolver_config(config)) + self._initialized_schemas: set[str] = set() + self._schema_locks: dict[str, asyncio.Lock] = {} + + async def on_startup(self) -> None: + await self.resolver.on_startup() + + async def on_shutdown(self) -> None: + await self.resolver.on_shutdown() + + async def authenticate(self, context: RequestContext) -> TenantContext: + policy = await self.resolver.resolve(context) + await self._ensure_schema_ready(policy.schema_name) + context.tenant_id = policy.org_id + context.selected_org_id = policy.org_id + context.user_id = policy.user_id + context.api_key_id = policy.api_key_id + context.role = policy.role + context.allowed_bank_ids = sorted(policy.allowed_bank_ids) if policy.allowed_bank_ids is not None else None + context.allowed_operations = ( + sorted(policy.allowed_operations) if policy.allowed_operations is not None else None + ) + context.auth_policy = policy + return TenantContext(schema_name=policy.schema_name) + + async def list_tenants(self) -> list[Tenant]: + tenants = await self.resolver.list_tenants() + self._initialized_schemas.update(tenant.schema for tenant in tenants) + return tenants + + async def _ensure_schema_ready(self, schema_name: str) -> None: + if schema_name in self._initialized_schemas: + return + lock = self._schema_locks.setdefault(schema_name, asyncio.Lock()) + async with lock: + if schema_name in self._initialized_schemas: + return + logger.info("Initializing organization schema: %s", schema_name) + try: + await self.context.run_migration(schema_name) + except Exception as exc: + logger.error("Organization schema initialization failed for %s: %s", schema_name, exc) + raise AuthenticationError(f"Failed to initialize organization schema: {exc!s}") from exc + self._initialized_schemas.add(schema_name) + logger.info("Organization schema ready: %s", schema_name) + + async def get_tenant_config(self, context: RequestContext) -> dict[str, Any]: + policy = await self.resolver.resolve(context) + return policy.tenant_config + + +class SupabaseAuthorizationExtension(OperationValidatorExtension): + """Operation validator enforcing Cloud-like organization roles and bank-scoped API keys.""" + + def __init__(self, config: dict[str, str]): + super().__init__(config) + self.resolver = SupabasePolicyResolver(_resolver_config(config)) + + async def on_startup(self) -> None: + await self.resolver.on_startup() + + async def on_shutdown(self) -> None: + await self.resolver.on_shutdown() + + async def validate_retain(self, ctx: RetainContext) -> ValidationResult: + return await self._validate_bank_operation(ctx.request_context, ctx.bank_id, "retain", write=True) + + async def validate_recall(self, ctx: RecallContext) -> ValidationResult: + return await self._validate_bank_operation(ctx.request_context, ctx.bank_id, "recall", write=False) + + async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult: + return await self._validate_bank_operation(ctx.request_context, ctx.bank_id, "reflect", write=False) + + async def validate_bank_read(self, ctx: BankReadContext) -> ValidationResult: + return await self._validate_bank_operation(ctx.request_context, ctx.bank_id, ctx.operation, write=False) + + async def validate_bank_write(self, ctx: BankWriteContext) -> ValidationResult: + return await self._validate_bank_operation(ctx.request_context, ctx.bank_id, ctx.operation, write=True) + + async def filter_bank_list(self, ctx: BankListContext) -> BankListResult: + policy = await self._resolve_policy(ctx.request_context) + if policy.is_admin or policy.allowed_bank_ids is None: + return BankListResult(banks=ctx.banks) + filtered = [bank for bank in ctx.banks if str(bank.get("id") or bank.get("bank_id")) in policy.allowed_bank_ids] + return BankListResult(banks=filtered) + + async def filter_mcp_tools( + self, + bank_id: str, + request_context: RequestContext, + tools: frozenset[str], + ) -> frozenset[str]: + policy = await self._resolve_policy(request_context) + if not self._can_access_bank(policy, bank_id): + return frozenset() + if policy.allowed_operations is None: + return tools + return frozenset(tool for tool in tools if self._tool_allowed(tool, policy.allowed_operations)) + + async def _validate_bank_operation( + self, + request_context: RequestContext, + bank_id: str, + operation: str, + *, + write: bool, + ) -> ValidationResult: + policy = await self._resolve_policy(request_context) + if not self._can_access_bank(policy, bank_id): + return ValidationResult.reject("Caller is not allowed to access this bank", status_code=403) + if write and policy.role == "member": + return ValidationResult.reject("Members cannot manage banks or write memories", status_code=403) + if policy.allowed_operations is not None and operation not in policy.allowed_operations: + return ValidationResult.reject("API key is not allowed to perform this operation", status_code=403) + return ValidationResult.accept() + + async def _resolve_policy(self, request_context: RequestContext) -> CallerPolicy: + cached = request_context.auth_policy + if isinstance(cached, CallerPolicy): + return cached + policy = await self.resolver.resolve(request_context) + request_context.auth_policy = policy + return policy + + @staticmethod + def _can_access_bank(policy: CallerPolicy, bank_id: str) -> bool: + return policy.is_admin or policy.allowed_bank_ids is None or bank_id in policy.allowed_bank_ids + + @staticmethod + def _tool_allowed(tool_name: str, allowed_operations: frozenset[str]) -> bool: + if tool_name.startswith("retain") or tool_name in {"create_bank", "delete_bank"}: + return "retain" in allowed_operations + if tool_name.startswith("reflect"): + return "reflect" in allowed_operations + if tool_name.startswith("recall") or tool_name in {"list_banks", "get_bank"}: + return "recall" in allowed_operations + return True diff --git a/hindsight-api-slim/hindsight_api/main.py b/hindsight-api-slim/hindsight_api/main.py index 35dd89341..601d186ae 100644 --- a/hindsight-api-slim/hindsight_api/main.py +++ b/hindsight-api-slim/hindsight_api/main.py @@ -258,6 +258,10 @@ def main(): logging.info(f"Loaded tenant extension: {tenant_extension.__class__.__name__}") + from hindsight_api.extensions.authz_profile import validate_authz_profile + + validate_authz_profile(tenant_extension, operation_validator) + # Create MemoryEngine (reads configuration from environment) _memory = MemoryEngine( operation_validator=operation_validator, diff --git a/hindsight-api-slim/hindsight_api/models.py b/hindsight-api-slim/hindsight_api/models.py index 94b4d6af7..e603e31ea 100644 --- a/hindsight-api-slim/hindsight_api/models.py +++ b/hindsight-api-slim/hindsight_api/models.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from datetime import datetime -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from uuid import UUID as PyUUID if TYPE_CHECKING: @@ -24,6 +24,11 @@ class RequestContext: api_key: str | None = None api_key_id: str | None = None # UUID of the API key used for authentication tenant_id: str | None = None # Tenant identifier (set by extension after auth) + selected_org_id: str | None = None # Organization selected by the caller (for org-based auth) + user_id: str | None = None # Authenticated user id when available from the auth provider + role: str | None = None # Caller role resolved by an authorization extension + allowed_operations: list[str] | None = None # None = unrestricted operations + auth_policy: Any | None = None # Provider-specific resolved authz policy cached for this request internal: bool = False # True for background/internal operations (skips extension auth) mcp_authenticated: bool = False # True when MCP transport auth already validated (skips tenant re-auth) user_initiated: bool = False # True for async operations that originated from a user request diff --git a/hindsight-api-slim/hindsight_api/server.py b/hindsight-api-slim/hindsight_api/server.py index 10c6649ef..bb4bb6678 100644 --- a/hindsight-api-slim/hindsight_api/server.py +++ b/hindsight-api-slim/hindsight_api/server.py @@ -24,6 +24,7 @@ TenantExtension, load_extension, ) +from hindsight_api.extensions.authz_profile import validate_authz_profile # Disable tokenizers parallelism to avoid warnings os.environ["TOKENIZERS_PARALLELISM"] = "false" @@ -42,6 +43,8 @@ if tenant_extension: logging.info(f"Loaded tenant extension: {tenant_extension.__class__.__name__}") +validate_authz_profile(tenant_extension, operation_validator) + # Create app at module level (required for uvicorn import string) # MemoryEngine reads configuration from environment variables automatically # Note: run_migrations=True by default, but migrations are idempotent so safe with workers diff --git a/hindsight-api-slim/hindsight_api/worker/main.py b/hindsight-api-slim/hindsight_api/worker/main.py index 46343dee0..90bee8b41 100644 --- a/hindsight-api-slim/hindsight_api/worker/main.py +++ b/hindsight-api-slim/hindsight_api/worker/main.py @@ -202,6 +202,7 @@ async def run(): import uvicorn from ..extensions import OperationValidatorExtension, TenantExtension, load_extension + from ..extensions.authz_profile import validate_authz_profile # Load tenant extension BEFORE creating MemoryEngine so it can # set correct schema context during task execution. Without this, @@ -215,6 +216,8 @@ async def run(): if operation_validator: logger.info(f"Loaded operation validator: {operation_validator.__class__.__name__}") + validate_authz_profile(tenant_extension, operation_validator) + # Initialize MemoryEngine # Workers use WorkerTaskBackend: submit_task is a no-op because the # row already exists in async_operations. Child tasks (e.g. consolidation @@ -229,6 +232,11 @@ async def run(): await memory.initialize() + if tenant_extension: + await tenant_extension.on_startup() + if operation_validator: + await operation_validator.on_startup() + print(f"Database connected: {config.database_url}") if tenant_extension: @@ -341,6 +349,11 @@ def signal_handler(): except asyncio.CancelledError: pass + if operation_validator: + await operation_validator.on_shutdown() + if tenant_extension: + await tenant_extension.on_shutdown() + # Close memory engine await memory.close() print("Worker shutdown complete") diff --git a/hindsight-api-slim/tests/test_supabase_org_authz.py b/hindsight-api-slim/tests/test_supabase_org_authz.py new file mode 100644 index 000000000..81210bc35 --- /dev/null +++ b/hindsight-api-slim/tests/test_supabase_org_authz.py @@ -0,0 +1,341 @@ +"""Tests for Supabase organization authz extensions.""" + +from __future__ import annotations + +import hashlib +from typing import Literal +from unittest.mock import AsyncMock + +import pytest + +from hindsight_api.extensions.authz_profile import validate_authz_profile +from hindsight_api.extensions.builtin.supabase_org import ( + CallerPolicy, + SupabaseAuthorizationExtension, + SupabaseOrgTenantExtension, + SupabasePolicyResolver, +) +from hindsight_api.extensions.operation_validator import ( + BankListContext, + BankWriteContext, + RecallContext, + ReflectContext, + RetainContext, +) +from hindsight_api.extensions.tenant import AuthenticationError, Tenant +from hindsight_api.models import RequestContext + + +class _MigrationContext: + def __init__(self) -> None: + self.run_migration = AsyncMock() + + +def _resolver_config() -> dict[str, str]: + return { + "supabase_url": "https://test.supabase.co", + "supabase_service_key": "service-key", + "policy_cache_ttl_seconds": "0", + } + + +def _jwt_context() -> RequestContext: + return RequestContext(api_key="header.payload.signature", selected_org_id="org_123") + + +def _policy( + *, + role: Literal["owner", "admin", "member"] = "owner", + allowed_operations: frozenset[str] | None = None, + allowed_bank_ids: frozenset[str] | None = None, +) -> CallerPolicy: + return CallerPolicy( + org_id="org_123", + schema_name="org_org_123", + user_id="user_123", + api_key_id=None, + role=role, + allowed_bank_ids=allowed_bank_ids, + allowed_operations=allowed_operations, + tenant_config={}, + ) + + +def _api_key_policy() -> CallerPolicy: + return CallerPolicy( + org_id="org_123", + schema_name="org_org_123", + user_id=None, + api_key_id="key_123", + role="member", + allowed_bank_ids=frozenset({"bank_a"}), + allowed_operations=frozenset({"recall"}), + tenant_config={}, + ) + + +def _admin_api_key_policy() -> CallerPolicy: + return CallerPolicy( + org_id="org_123", + schema_name="org_org_123", + user_id=None, + api_key_id="key_123", + role="admin", + allowed_bank_ids=frozenset({"bank_a"}), + allowed_operations=frozenset({"retain", "recall", "reflect"}), + tenant_config={}, + ) + + +@pytest.mark.asyncio +async def test_resolver_maps_supabase_jwt_to_org_schema() -> None: + resolver = SupabasePolicyResolver(_resolver_config()) + resolver._verify_token = AsyncMock(return_value="user_123") # type: ignore[method-assign] + resolver._rest_get = AsyncMock( # type: ignore[method-assign] + side_effect=[ + [{"org_id": "org_123", "user_id": "user_123", "role": "admin"}], + [{"id": "org_123", "name": "Org", "config": {"llm_model": "gpt-4"}}], + ] + ) + + policy = await resolver.resolve(_jwt_context()) + + assert policy.org_id == "org_123" + assert policy.schema_name == "org_org_123" + assert policy.user_id == "user_123" + assert policy.role == "admin" + assert policy.allowed_bank_ids is None + assert policy.tenant_config == {"llm_model": "gpt-4"} + + +@pytest.mark.asyncio +async def test_resolver_rejects_jwt_without_selected_org() -> None: + resolver = SupabasePolicyResolver(_resolver_config()) + + with pytest.raises(AuthenticationError, match="Missing X-Hindsight-Org-Id"): + await resolver.resolve(RequestContext(api_key="header.payload.signature")) + + +@pytest.mark.asyncio +async def test_resolver_rejects_non_member_jwt() -> None: + resolver = SupabasePolicyResolver(_resolver_config()) + resolver._verify_token = AsyncMock(return_value="user_123") # type: ignore[method-assign] + resolver._rest_get = AsyncMock(return_value=[]) # type: ignore[method-assign] + + with pytest.raises(AuthenticationError, match="not a member"): + await resolver.resolve(_jwt_context()) + + +@pytest.mark.asyncio +async def test_resolver_maps_hindsight_api_key_to_bank_scoped_policy() -> None: + resolver = SupabasePolicyResolver(_resolver_config()) + api_key = "hs_test_secret_with_enough_entropy" + key_hash = hashlib.sha256(api_key.encode("utf-8")).hexdigest() + resolver._rest_get = AsyncMock( # type: ignore[method-assign] + side_effect=[ + [ + { + "id": "key_123", + "org_id": "org_123", + "role": "member", + "allowed_operations": ["recall", "reflect"], + } + ], + [{"bank_id": "bank_a"}], + [{"id": "org_123", "name": "Org", "config": {}}], + ] + ) + + policy = await resolver.resolve(RequestContext(api_key=api_key)) + + assert policy.org_id == "org_123" + assert policy.api_key_id == "key_123" + assert policy.allowed_bank_ids == frozenset({"bank_a"}) + assert policy.allowed_operations == frozenset({"recall", "reflect"}) + resolver._rest_get.assert_any_call( # type: ignore[attr-defined] + "hindsight_api_keys", + select="id,org_id,role,allowed_operations,revoked_at,expires_at", + key_hash=f"eq.{key_hash}", + revoked_at="is.null", + limit="1", + ) + + +@pytest.mark.asyncio +async def test_resolver_rejects_expired_hindsight_api_key() -> None: + resolver = SupabasePolicyResolver(_resolver_config()) + resolver._rest_get = AsyncMock( # type: ignore[method-assign] + return_value=[ + { + "id": "key_123", + "org_id": "org_123", + "role": "member", + "allowed_operations": ["recall"], + "expires_at": "2000-01-01T00:00:00Z", + } + ] + ) + + with pytest.raises(AuthenticationError, match="expired"): + await resolver.resolve(RequestContext(api_key="hs_test_secret_with_enough_entropy")) + + +@pytest.mark.asyncio +async def test_resolver_preserves_empty_api_key_operation_scope() -> None: + resolver = SupabasePolicyResolver(_resolver_config()) + resolver._rest_get = AsyncMock( # type: ignore[method-assign] + side_effect=[ + [ + { + "id": "key_123", + "org_id": "org_123", + "role": "member", + "allowed_operations": [], + "expires_at": "2099-01-01T00:00:00Z", + } + ], + [], + [{"id": "org_123", "name": "Org", "config": {}}], + ] + ) + + policy = await resolver.resolve(RequestContext(api_key="hs_test_secret_with_enough_entropy")) + + assert policy.allowed_operations == frozenset() + + +@pytest.mark.asyncio +async def test_tenant_extension_populates_request_context() -> None: + extension = SupabaseOrgTenantExtension(_resolver_config()) + context_api = _MigrationContext() + extension.set_context(context_api) # type: ignore[arg-type] + extension.resolver.resolve = AsyncMock(return_value=_policy()) # type: ignore[method-assign] + + context = _jwt_context() + tenant = await extension.authenticate(context) + + assert tenant.schema_name == "org_org_123" + assert context.tenant_id == "org_123" + assert context.user_id == "user_123" + assert context.role == "owner" + assert context.auth_policy is not None + context_api.run_migration.assert_awaited_once_with("org_org_123") + + +@pytest.mark.asyncio +async def test_tenant_extension_runs_migration_on_first_schema_access() -> None: + extension = SupabaseOrgTenantExtension(_resolver_config()) + context_api = _MigrationContext() + extension.set_context(context_api) # type: ignore[arg-type] + extension.resolver.resolve = AsyncMock(return_value=_policy()) # type: ignore[method-assign] + + await extension.authenticate(_jwt_context()) + await extension.authenticate(_jwt_context()) + + context_api.run_migration.assert_awaited_once_with("org_org_123") + + +@pytest.mark.asyncio +async def test_list_tenants_marks_startup_migrated_schemas_ready() -> None: + extension = SupabaseOrgTenantExtension(_resolver_config()) + context_api = _MigrationContext() + extension.set_context(context_api) # type: ignore[arg-type] + extension.resolver.list_tenants = AsyncMock(return_value=[Tenant(schema="org_org_123", tenant_id="org_123")]) # type: ignore[method-assign] + extension.resolver.resolve = AsyncMock(return_value=_policy()) # type: ignore[method-assign] + + await extension.list_tenants() + await extension.authenticate(_jwt_context()) + + context_api.run_migration.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_authorization_allows_admin_bank_write() -> None: + validator = SupabaseAuthorizationExtension(_resolver_config()) + validator.resolver.resolve = AsyncMock(return_value=_policy()) # type: ignore[method-assign] + + result = await validator.validate_bank_write( + BankWriteContext(bank_id="bank_a", operation="delete_bank", request_context=_jwt_context()) + ) + + assert result.allowed is True + + +@pytest.mark.asyncio +async def test_authorization_allows_admin_api_key_retain_with_scope() -> None: + validator = SupabaseAuthorizationExtension(_resolver_config()) + validator.resolver.resolve = AsyncMock(return_value=_admin_api_key_policy()) # type: ignore[method-assign] + + result = await validator.validate_retain( + RetainContext(bank_id="bank_a", contents=[], request_context=_jwt_context()) + ) + + assert result.allowed is True + + +@pytest.mark.asyncio +async def test_authorization_reuses_policy_from_request_context() -> None: + validator = SupabaseAuthorizationExtension(_resolver_config()) + validator.resolver.resolve = AsyncMock(side_effect=AssertionError("resolver should not be called")) # type: ignore[method-assign] + context = _jwt_context() + context.auth_policy = _admin_api_key_policy() + + result = await validator.validate_retain(RetainContext(bank_id="bank_a", contents=[], request_context=context)) + + assert result.allowed is True + validator.resolver.resolve.assert_not_called() # type: ignore[attr-defined] + + +@pytest.mark.asyncio +async def test_authorization_denies_member_write() -> None: + validator = SupabaseAuthorizationExtension(_resolver_config()) + validator.resolver.resolve = AsyncMock(return_value=_policy(role="member")) # type: ignore[method-assign] + + result = await validator.validate_retain( + RetainContext(bank_id="bank_a", contents=[], request_context=_jwt_context()) + ) + + assert result.allowed is False + assert result.status_code == 403 + + +@pytest.mark.asyncio +async def test_authorization_enforces_api_key_bank_scope_and_operation_scope() -> None: + validator = SupabaseAuthorizationExtension(_resolver_config()) + validator.resolver.resolve = AsyncMock(return_value=_api_key_policy()) # type: ignore[method-assign] + + allowed = await validator.validate_recall( + RecallContext(bank_id="bank_a", query="q", request_context=_jwt_context()) + ) + wrong_operation = await validator.validate_reflect( + ReflectContext(bank_id="bank_a", query="q", request_context=_jwt_context()) + ) + wrong_bank = await validator.validate_recall( + RecallContext(bank_id="bank_b", query="q", request_context=_jwt_context()) + ) + + assert allowed.allowed is True + assert wrong_operation.allowed is False + assert wrong_bank.allowed is False + + +@pytest.mark.asyncio +async def test_filter_bank_list_limits_member_api_key_scope() -> None: + validator = SupabaseAuthorizationExtension(_resolver_config()) + validator.resolver.resolve = AsyncMock(return_value=_api_key_policy()) # type: ignore[method-assign] + + result = await validator.filter_bank_list( + BankListContext( + banks=[{"id": "bank_a"}, {"id": "bank_b"}], + request_context=_jwt_context(), + ) + ) + + assert result.banks == [{"id": "bank_a"}] + + +def test_validate_authz_profile_requires_both_extensions(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HINDSIGHT_API_AUTHZ_PROFILE", "supabase_org") + + with pytest.raises(RuntimeError, match="requires"): + validate_authz_profile(SupabaseOrgTenantExtension(_resolver_config()), None) diff --git a/hindsight-clients/go/api/openapi.yaml b/hindsight-clients/go/api/openapi.yaml index be05c7002..cf1777850 100644 --- a/hindsight-clients/go/api/openapi.yaml +++ b/hindsight-clients/go/api/openapi.yaml @@ -5776,6 +5776,22 @@ components: FeaturesInfo: description: Feature flags indicating which capabilities are enabled. properties: + authz_profile: + default: disabled + description: Configured authorization deployment profile + title: Authz Profile + type: string + tenant_extension: + nullable: true + type: string + operation_validator_extension: + nullable: true + type: string + supabase_org_ready: + default: false + description: Whether the supabase_org profile is fully configured + title: Supabase Org Ready + type: boolean observations: description: Whether observations (auto-consolidation) are enabled title: Observations diff --git a/hindsight-clients/go/model_features_info.go b/hindsight-clients/go/model_features_info.go index 6eff1dd19..9f32c07d6 100644 --- a/hindsight-clients/go/model_features_info.go +++ b/hindsight-clients/go/model_features_info.go @@ -21,6 +21,12 @@ var _ MappedNullable = &FeaturesInfo{} // FeaturesInfo Feature flags indicating which capabilities are enabled. type FeaturesInfo struct { + // Configured authorization deployment profile + AuthzProfile *string `json:"authz_profile,omitempty"` + TenantExtension NullableString `json:"tenant_extension,omitempty"` + OperationValidatorExtension NullableString `json:"operation_validator_extension,omitempty"` + // Whether the supabase_org profile is fully configured + SupabaseOrgReady *bool `json:"supabase_org_ready,omitempty"` // Whether observations (auto-consolidation) are enabled Observations bool `json:"observations"` // Whether MCP (Model Context Protocol) server is enabled @@ -53,6 +59,10 @@ type _FeaturesInfo FeaturesInfo // will change when the set of required properties is changed func NewFeaturesInfo(observations bool, mcp bool, worker bool, bankConfigApi bool, bankLlmHealth bool, fileUploadApi bool, documentExportApi bool, documentImportApi bool, auditLog bool, llmTrace bool, storeDocumentText bool) *FeaturesInfo { this := FeaturesInfo{} + var authzProfile string = "disabled" + this.AuthzProfile = &authzProfile + var supabaseOrgReady bool = false + this.SupabaseOrgReady = &supabaseOrgReady this.Observations = observations this.Mcp = mcp this.Worker = worker @@ -72,9 +82,161 @@ func NewFeaturesInfo(observations bool, mcp bool, worker bool, bankConfigApi boo // but it doesn't guarantee that properties required by API are set func NewFeaturesInfoWithDefaults() *FeaturesInfo { this := FeaturesInfo{} + var authzProfile string = "disabled" + this.AuthzProfile = &authzProfile + var supabaseOrgReady bool = false + this.SupabaseOrgReady = &supabaseOrgReady return &this } +// GetAuthzProfile returns the AuthzProfile field value if set, zero value otherwise. +func (o *FeaturesInfo) GetAuthzProfile() string { + if o == nil || IsNil(o.AuthzProfile) { + var ret string + return ret + } + return *o.AuthzProfile +} + +// GetAuthzProfileOk returns a tuple with the AuthzProfile field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *FeaturesInfo) GetAuthzProfileOk() (*string, bool) { + if o == nil || IsNil(o.AuthzProfile) { + return nil, false + } + return o.AuthzProfile, true +} + +// HasAuthzProfile returns a boolean if a field has been set. +func (o *FeaturesInfo) HasAuthzProfile() bool { + if o != nil && !IsNil(o.AuthzProfile) { + return true + } + + return false +} + +// SetAuthzProfile gets a reference to the given string and assigns it to the AuthzProfile field. +func (o *FeaturesInfo) SetAuthzProfile(v string) { + o.AuthzProfile = &v +} + +// GetTenantExtension returns the TenantExtension field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *FeaturesInfo) GetTenantExtension() string { + if o == nil || IsNil(o.TenantExtension.Get()) { + var ret string + return ret + } + return *o.TenantExtension.Get() +} + +// GetTenantExtensionOk returns a tuple with the TenantExtension field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *FeaturesInfo) GetTenantExtensionOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.TenantExtension.Get(), o.TenantExtension.IsSet() +} + +// HasTenantExtension returns a boolean if a field has been set. +func (o *FeaturesInfo) HasTenantExtension() bool { + if o != nil && o.TenantExtension.IsSet() { + return true + } + + return false +} + +// SetTenantExtension gets a reference to the given NullableString and assigns it to the TenantExtension field. +func (o *FeaturesInfo) SetTenantExtension(v string) { + o.TenantExtension.Set(&v) +} +// SetTenantExtensionNil sets the value for TenantExtension to be an explicit nil +func (o *FeaturesInfo) SetTenantExtensionNil() { + o.TenantExtension.Set(nil) +} + +// UnsetTenantExtension ensures that no value is present for TenantExtension, not even an explicit nil +func (o *FeaturesInfo) UnsetTenantExtension() { + o.TenantExtension.Unset() +} + +// GetOperationValidatorExtension returns the OperationValidatorExtension field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *FeaturesInfo) GetOperationValidatorExtension() string { + if o == nil || IsNil(o.OperationValidatorExtension.Get()) { + var ret string + return ret + } + return *o.OperationValidatorExtension.Get() +} + +// GetOperationValidatorExtensionOk returns a tuple with the OperationValidatorExtension field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *FeaturesInfo) GetOperationValidatorExtensionOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.OperationValidatorExtension.Get(), o.OperationValidatorExtension.IsSet() +} + +// HasOperationValidatorExtension returns a boolean if a field has been set. +func (o *FeaturesInfo) HasOperationValidatorExtension() bool { + if o != nil && o.OperationValidatorExtension.IsSet() { + return true + } + + return false +} + +// SetOperationValidatorExtension gets a reference to the given NullableString and assigns it to the OperationValidatorExtension field. +func (o *FeaturesInfo) SetOperationValidatorExtension(v string) { + o.OperationValidatorExtension.Set(&v) +} +// SetOperationValidatorExtensionNil sets the value for OperationValidatorExtension to be an explicit nil +func (o *FeaturesInfo) SetOperationValidatorExtensionNil() { + o.OperationValidatorExtension.Set(nil) +} + +// UnsetOperationValidatorExtension ensures that no value is present for OperationValidatorExtension, not even an explicit nil +func (o *FeaturesInfo) UnsetOperationValidatorExtension() { + o.OperationValidatorExtension.Unset() +} + +// GetSupabaseOrgReady returns the SupabaseOrgReady field value if set, zero value otherwise. +func (o *FeaturesInfo) GetSupabaseOrgReady() bool { + if o == nil || IsNil(o.SupabaseOrgReady) { + var ret bool + return ret + } + return *o.SupabaseOrgReady +} + +// GetSupabaseOrgReadyOk returns a tuple with the SupabaseOrgReady field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *FeaturesInfo) GetSupabaseOrgReadyOk() (*bool, bool) { + if o == nil || IsNil(o.SupabaseOrgReady) { + return nil, false + } + return o.SupabaseOrgReady, true +} + +// HasSupabaseOrgReady returns a boolean if a field has been set. +func (o *FeaturesInfo) HasSupabaseOrgReady() bool { + if o != nil && !IsNil(o.SupabaseOrgReady) { + return true + } + + return false +} + +// SetSupabaseOrgReady gets a reference to the given bool and assigns it to the SupabaseOrgReady field. +func (o *FeaturesInfo) SetSupabaseOrgReady(v bool) { + o.SupabaseOrgReady = &v +} + // GetObservations returns the Observations field value func (o *FeaturesInfo) GetObservations() bool { if o == nil { @@ -349,6 +511,18 @@ func (o FeaturesInfo) MarshalJSON() ([]byte, error) { func (o FeaturesInfo) ToMap() (map[string]interface{}, error) { toSerialize := map[string]interface{}{} + if !IsNil(o.AuthzProfile) { + toSerialize["authz_profile"] = o.AuthzProfile + } + if o.TenantExtension.IsSet() { + toSerialize["tenant_extension"] = o.TenantExtension.Get() + } + if o.OperationValidatorExtension.IsSet() { + toSerialize["operation_validator_extension"] = o.OperationValidatorExtension.Get() + } + if !IsNil(o.SupabaseOrgReady) { + toSerialize["supabase_org_ready"] = o.SupabaseOrgReady + } toSerialize["observations"] = o.Observations toSerialize["mcp"] = o.Mcp toSerialize["worker"] = o.Worker diff --git a/hindsight-clients/python/hindsight_client_api/models/features_info.py b/hindsight-clients/python/hindsight_client_api/models/features_info.py index 89fb7e69d..995e546ee 100644 --- a/hindsight-clients/python/hindsight_client_api/models/features_info.py +++ b/hindsight-clients/python/hindsight_client_api/models/features_info.py @@ -17,8 +17,8 @@ import re # noqa: F401 import json -from pydantic import BaseModel, ConfigDict, Field, StrictBool -from typing import Any, ClassVar, Dict, List +from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictStr +from typing import Any, ClassVar, Dict, List, Optional from typing import Optional, Set from typing_extensions import Self @@ -26,6 +26,10 @@ class FeaturesInfo(BaseModel): """ Feature flags indicating which capabilities are enabled. """ # noqa: E501 + authz_profile: Optional[StrictStr] = Field(default='disabled', description="Configured authorization deployment profile") + tenant_extension: Optional[StrictStr] = None + operation_validator_extension: Optional[StrictStr] = None + supabase_org_ready: Optional[StrictBool] = Field(default=False, description="Whether the supabase_org profile is fully configured") observations: StrictBool = Field(description="Whether observations (auto-consolidation) are enabled") mcp: StrictBool = Field(description="Whether MCP (Model Context Protocol) server is enabled") worker: StrictBool = Field(description="Whether the background worker is enabled") @@ -37,7 +41,7 @@ class FeaturesInfo(BaseModel): audit_log: StrictBool = Field(description="Whether audit logging is enabled") llm_trace: StrictBool = Field(description="Whether per-bank LLM request tracing is enabled") store_document_text: StrictBool = Field(description="Whether raw source text is persisted. When false, document/chunk source text is not stored.") - __properties: ClassVar[List[str]] = ["observations", "mcp", "worker", "bank_config_api", "bank_llm_health", "file_upload_api", "document_export_api", "document_import_api", "audit_log", "llm_trace", "store_document_text"] + __properties: ClassVar[List[str]] = ["authz_profile", "tenant_extension", "operation_validator_extension", "supabase_org_ready", "observations", "mcp", "worker", "bank_config_api", "bank_llm_health", "file_upload_api", "document_export_api", "document_import_api", "audit_log", "llm_trace", "store_document_text"] model_config = ConfigDict( populate_by_name=True, @@ -78,6 +82,16 @@ def to_dict(self) -> Dict[str, Any]: exclude=excluded_fields, exclude_none=True, ) + # set to None if tenant_extension (nullable) is None + # and model_fields_set contains the field + if self.tenant_extension is None and "tenant_extension" in self.model_fields_set: + _dict['tenant_extension'] = None + + # set to None if operation_validator_extension (nullable) is None + # and model_fields_set contains the field + if self.operation_validator_extension is None and "operation_validator_extension" in self.model_fields_set: + _dict['operation_validator_extension'] = None + return _dict @classmethod @@ -90,6 +104,10 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: return cls.model_validate(obj) _obj = cls.model_validate({ + "authz_profile": obj.get("authz_profile") if obj.get("authz_profile") is not None else 'disabled', + "tenant_extension": obj.get("tenant_extension"), + "operation_validator_extension": obj.get("operation_validator_extension"), + "supabase_org_ready": obj.get("supabase_org_ready") if obj.get("supabase_org_ready") is not None else False, "observations": obj.get("observations"), "mcp": obj.get("mcp"), "worker": obj.get("worker"), diff --git a/hindsight-clients/typescript/generated/types.gen.ts b/hindsight-clients/typescript/generated/types.gen.ts index 4f20fb290..dad40fc6e 100644 --- a/hindsight-clients/typescript/generated/types.gen.ts +++ b/hindsight-clients/typescript/generated/types.gen.ts @@ -1810,6 +1810,30 @@ export type FactsIncludeOptions = { * Feature flags indicating which capabilities are enabled. */ export type FeaturesInfo = { + /** + * Authz Profile + * + * Configured authorization deployment profile + */ + authz_profile?: string; + /** + * Tenant Extension + * + * Loaded tenant extension class name + */ + tenant_extension?: string | null; + /** + * Operation Validator Extension + * + * Loaded operation validator extension class name + */ + operation_validator_extension?: string | null; + /** + * Supabase Org Ready + * + * Whether the supabase_org profile is fully configured + */ + supabase_org_ready?: boolean; /** * Observations * diff --git a/hindsight-control-plane/src/app/[locale]/login/page.tsx b/hindsight-control-plane/src/app/[locale]/login/page.tsx index a1114a22c..c1713e1ab 100644 --- a/hindsight-control-plane/src/app/[locale]/login/page.tsx +++ b/hindsight-control-plane/src/app/[locale]/login/page.tsx @@ -12,7 +12,14 @@ import Image from "next/image"; function LoginForm() { const t = useTranslations("login"); + const [authProvider, setAuthProvider] = useState< + "access_key" | "supabase_org" | "disabled" | null + >(null); const [key, setKey] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [organizationName, setOrganizationName] = useState(""); + const [mode, setMode] = useState<"login" | "signup">("login"); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const router = useRouter(); @@ -20,23 +27,46 @@ function LoginForm() { // Validate returnTo to prevent open-redirect via crafted login links. const returnTo = sanitizeReturnTo(searchParams.get("returnTo")); + const inviteToken = searchParams.get("invite") || undefined; useEffect(() => { - // Focus the input on mount - const input = document.getElementById("access-key"); - input?.focus(); + const loadAuthProvider = async () => { + try { + const response = await fetch(withBasePath("/api/version")); + const data = await response.json(); + setAuthProvider(data?.features?.auth_provider || "access_key"); + } catch { + setAuthProvider("access_key"); + } + }; + loadAuthProvider(); }, []); + useEffect(() => { + const input = document.getElementById(authProvider === "supabase_org" ? "email" : "access-key"); + input?.focus(); + }, [authProvider]); + async function handleSubmit(e: FormEvent) { e.preventDefault(); setError(""); setLoading(true); try { + const body = + authProvider === "supabase_org" + ? { + mode, + email, + password, + organization_name: organizationName || undefined, + invite_token: inviteToken, + } + : { key }; const res = await fetch(withBasePath("/api/auth/login"), { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ key }), + body: JSON.stringify(body), }); if (res.ok) { @@ -72,8 +102,55 @@ function LoginForm() { {t("description")} + {authProvider === "supabase_org" && ( +
+ + +
+ )}
-
+ {authProvider === "supabase_org" ? ( + <> + setEmail(e.target.value)} + autoComplete="email" + /> + setPassword(e.target.value)} + autoComplete={mode === "signup" ? "new-password" : "current-password"} + /> + {mode === "signup" && !inviteToken && ( + setOrganizationName(e.target.value)} + autoComplete="organization" + /> + )} + + ) : ( setKey(e.target.value)} autoComplete="off" /> -
+ )} {error &&

{error}

} -
diff --git a/hindsight-control-plane/src/app/[locale]/settings/page.tsx b/hindsight-control-plane/src/app/[locale]/settings/page.tsx new file mode 100644 index 000000000..9a9f224e2 --- /dev/null +++ b/hindsight-control-plane/src/app/[locale]/settings/page.tsx @@ -0,0 +1,476 @@ +"use client"; + +import { FormEvent, useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { ArrowLeft, Copy, KeyRound, Plus, RefreshCw, Save, Trash2, UserPlus } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { withBasePath } from "@/lib/base-path"; + +type Role = "owner" | "admin" | "member"; + +interface Organization { + id: string; + name: string; + role: Role; +} + +interface Member { + org_id: string; + user_id: string; + email?: string; + role: Role; +} + +interface Invite { + id: string; + email: string; + role: Role; + expires_at: string; + accepted_at?: string | null; + revoked_at?: string | null; +} + +interface ApiKeySummary { + id: string; + name: string; + allowed_operations?: string[] | null; + revoked_at?: string | null; + created_at: string; +} + +interface VersionInfo { + features?: { + auth_provider?: "disabled" | "access_key" | "supabase_org"; + profile_match?: boolean; + }; +} + +export default function SettingsPage() { + const t = useTranslations("settings"); + const router = useRouter(); + const [organizations, setOrganizations] = useState([]); + const [selectedOrgId, setSelectedOrgId] = useState(""); + const [members, setMembers] = useState([]); + const [invites, setInvites] = useState([]); + const [apiKeys, setApiKeys] = useState([]); + const [orgName, setOrgName] = useState(""); + const [newOrgName, setNewOrgName] = useState(""); + const [inviteEmail, setInviteEmail] = useState(""); + const [inviteRole, setInviteRole] = useState("member"); + const [apiKeyName, setApiKeyName] = useState(""); + const [apiKeyBanks, setApiKeyBanks] = useState(""); + const [newInviteLink, setNewInviteLink] = useState(null); + const [newApiKey, setNewApiKey] = useState(null); + const [loading, setLoading] = useState(true); + + const currentOrg = useMemo( + () => organizations.find((organization) => organization.id === selectedOrgId), + [organizations, selectedOrgId] + ); + const canAdmin = currentOrg?.role === "owner" || currentOrg?.role === "admin"; + const canOwner = currentOrg?.role === "owner"; + + async function loadAll() { + setLoading(true); + try { + const version = await fetchJson("/api/version"); + if ( + version.features?.auth_provider !== "supabase_org" || + version.features?.profile_match === false + ) { + router.replace("/dashboard"); + return; + } + + const me = await fetchJson<{ + organizations: Organization[]; + current: { org_id: string; role: Role } | null; + }>("/api/me"); + setOrganizations(me.organizations); + const nextOrgId = me.current?.org_id || me.organizations[0]?.id || ""; + setSelectedOrgId(nextOrgId); + setOrgName( + me.organizations.find((organization) => organization.id === nextOrgId)?.name || "" + ); + const [team, inviteList, keyList] = await Promise.all([ + fetchJson<{ members: Member[] }>("/api/team"), + fetchJson<{ invites: Invite[] }>("/api/team/invites"), + fetchJson<{ api_keys: ApiKeySummary[] }>("/api/api-keys"), + ]); + setMembers(team.members); + setInvites(inviteList.invites); + setApiKeys(keyList.api_keys); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to load settings"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + loadAll(); + }, []); + + async function createOrg(event: FormEvent) { + event.preventDefault(); + const response = await fetchJson<{ organization: Organization }>("/api/organizations", { + method: "POST", + body: JSON.stringify({ name: newOrgName }), + }); + setNewOrgName(""); + setOrganizations((items) => [...items, { ...response.organization, role: "owner" }]); + await selectOrganization(response.organization.id); + toast.success("Organization created"); + } + + async function selectOrganization(orgId: string) { + await fetchJson("/api/auth/select-org", { + method: "POST", + body: JSON.stringify({ org_id: orgId }), + }); + setSelectedOrgId(orgId); + setOrgName(organizations.find((organization) => organization.id === orgId)?.name || ""); + await loadAll(); + } + + async function renameOrganization(event: FormEvent) { + event.preventDefault(); + if (!selectedOrgId) return; + const response = await fetchJson<{ organization: Organization }>( + `/api/organizations/${encodeURIComponent(selectedOrgId)}`, + { + method: "PATCH", + body: JSON.stringify({ name: orgName }), + } + ); + setOrganizations((items) => + items.map((item) => + item.id === response.organization.id ? { ...item, name: response.organization.name } : item + ) + ); + setOrgName(response.organization.name); + toast.success("Organization updated"); + } + + async function inviteMember(event: FormEvent) { + event.preventDefault(); + const response = await fetchJson<{ invite: { invite_url: string } }>("/api/team/invites", { + method: "POST", + body: JSON.stringify({ email: inviteEmail, role: inviteRole }), + }); + setInviteEmail(""); + setNewInviteLink(response.invite.invite_url); + try { + await navigator.clipboard.writeText(response.invite.invite_url); + toast.success("Invite link copied"); + } catch { + toast.success("Invite link created"); + } + await loadAll(); + } + + async function updateMember(userId: string, role: Role) { + await fetchJson(`/api/team/members/${encodeURIComponent(userId)}`, { + method: "PATCH", + body: JSON.stringify({ role }), + }); + await loadAll(); + } + + async function removeMember(userId: string) { + await fetchJson(`/api/team/members/${encodeURIComponent(userId)}`, { method: "DELETE" }); + await loadAll(); + } + + async function createApiKey(event: FormEvent) { + event.preventDefault(); + const bankIds = apiKeyBanks + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + const response = await fetchJson<{ api_key: { key: string } }>("/api/api-keys", { + method: "POST", + body: JSON.stringify({ name: apiKeyName, bank_ids: bankIds.length > 0 ? bankIds : null }), + }); + setApiKeyName(""); + setApiKeyBanks(""); + setNewApiKey(response.api_key.key); + await loadAll(); + } + + async function revokeApiKey(id: string) { + await fetchJson(`/api/api-keys/${encodeURIComponent(id)}`, { method: "DELETE" }); + await loadAll(); + } + + return ( +
+
+
+
+

{t("organizationSettings")}

+

+ {currentOrg?.name || t("noOrganizationSelected")} +

+
+ +
+ +
+ + +

Organizations

+
+ + +
+ setOrgName(event.target.value)} + placeholder={t("organizationName")} + disabled={!canOwner} + /> + +
+
+ setNewOrgName(event.target.value)} + placeholder={t("newOrganization")} + /> + +
+ +
+
+ +
+ + +

Team

+
+ + {canAdmin && ( +
+ setInviteEmail(event.target.value)} + placeholder="Email" + /> + + +
+ )} + + {newInviteLink && ( +
+
{t("inviteLinkCreated")}
+

{t("inviteLinkOneTime")}

+
+ {newInviteLink} + +
+
+ )} + +
+ {members.map((member) => ( +
+
+
{member.email || member.user_id}
+
{member.user_id}
+
+ + +
+ ))} +
+ + {invites.length > 0 && ( +
+

Invites

+ {invites.map((invite) => ( +
+ + {invite.email} ({invite.role}) + + + {invite.revoked_at + ? "revoked" + : invite.accepted_at + ? "accepted" + : "pending"} + +
+ ))} +
+ )} +
+
+ + + +

{t("apiKeys")}

+
+ + {canAdmin && ( +
+ setApiKeyName(event.target.value)} + placeholder={t("keyName")} + /> + setApiKeyBanks(event.target.value)} + placeholder="Bank ids, comma separated" + /> + +
+ )} + {newApiKey && ( +
+ {newApiKey} + +
+ )} +
+ {apiKeys.map((apiKey) => ( +
+
+
{apiKey.name}
+
+ {apiKey.allowed_operations?.join(", ") || "all operations"} +
+
+ + {apiKey.revoked_at ? "revoked" : "active"} + + +
+ ))} +
+
+
+
+
+
+
+ ); +} + +async function fetchJson(path: string, init: RequestInit = {}): Promise { + const response = await fetch(withBasePath(path), { + ...init, + headers: { "Content-Type": "application/json", ...init.headers }, + }); + const data = await response.json().catch(() => null); + if (!response.ok) throw new Error(data?.error || `Request failed: ${response.status}`); + return data as T; +} diff --git a/hindsight-control-plane/src/app/api/api-keys/[id]/route.ts b/hindsight-control-plane/src/app/api/api-keys/[id]/route.ts new file mode 100644 index 000000000..051c7f789 --- /dev/null +++ b/hindsight-control-plane/src/app/api/api-keys/[id]/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; + +import { getCurrentOrgContext, jsonError, revokeApiKey } from "@/lib/supabase-org/store"; + +export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const context = await getCurrentOrgContext(request); + await revokeApiKey(context, id); + return NextResponse.json({ success: true }, { status: 200 }); + } catch (error) { + return jsonError(error instanceof Error ? error.message : "Failed to revoke API key", 400); + } +} diff --git a/hindsight-control-plane/src/app/api/api-keys/route.ts b/hindsight-control-plane/src/app/api/api-keys/route.ts new file mode 100644 index 000000000..156373ef4 --- /dev/null +++ b/hindsight-control-plane/src/app/api/api-keys/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; + +import { + createApiKey, + getCurrentOrgContext, + jsonError, + listApiKeys, +} from "@/lib/supabase-org/store"; + +export async function GET(request: Request) { + try { + const context = await getCurrentOrgContext(request); + return NextResponse.json( + { api_keys: await listApiKeys(context.selectedOrgId) }, + { status: 200 } + ); + } catch (error) { + return jsonError(error instanceof Error ? error.message : "Failed to list API keys", 400); + } +} + +export async function POST(request: Request) { + try { + const body = (await request.json()) as { + name?: string; + bank_ids?: string[] | null; + allowed_operations?: string[] | null; + }; + if (!body.name) return jsonError("name is required", 400); + const context = await getCurrentOrgContext(request); + const apiKey = await createApiKey( + context, + body.name, + body.bank_ids ?? null, + body.allowed_operations ?? ["retain", "recall", "reflect"] + ); + return NextResponse.json({ api_key: apiKey }, { status: 201 }); + } catch (error) { + return jsonError(error instanceof Error ? error.message : "Failed to create API key", 400); + } +} diff --git a/hindsight-control-plane/src/app/api/auth/invites/[token]/accept/route.ts b/hindsight-control-plane/src/app/api/auth/invites/[token]/accept/route.ts new file mode 100644 index 000000000..75049fe64 --- /dev/null +++ b/hindsight-control-plane/src/app/api/auth/invites/[token]/accept/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { SUPABASE_ORG_SELECTED_ORG_COOKIE } from "@/lib/auth/provider"; +import { sessionCookieOptions } from "@/lib/auth/session"; +import { acceptInvite, jsonError } from "@/lib/supabase-org/store"; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ token: string }> } +) { + try { + const { token } = await params; + const accepted = await acceptInvite(request, token); + const response = NextResponse.json( + { success: true, selected_org_id: accepted.org_id }, + { status: 200 } + ); + response.cookies.set({ + name: SUPABASE_ORG_SELECTED_ORG_COOKIE, + value: accepted.org_id, + ...sessionCookieOptions(request), + maxAge: 30 * 24 * 60 * 60, + }); + return response; + } catch (error) { + return jsonError(error instanceof Error ? error.message : "Failed to accept invite", 400); + } +} diff --git a/hindsight-control-plane/src/app/api/auth/login/route.ts b/hindsight-control-plane/src/app/api/auth/login/route.ts index 0251aedfa..b153bdd38 100644 --- a/hindsight-control-plane/src/app/api/auth/login/route.ts +++ b/hindsight-control-plane/src/app/api/auth/login/route.ts @@ -7,8 +7,22 @@ import { createSessionToken, sessionCookieOptions, } from "@/lib/auth/session"; +import { getControlPlaneAuthProvider } from "@/lib/auth/provider"; +import { + acceptInviteForUser, + ensureInitialOrganizationForSignup, + listOrganizationsForUser, + setSupabaseOrgSessionCookies, + signInWithPassword, + signUpWithPassword, +} from "@/lib/supabase-org/store"; export async function POST(request: NextRequest) { + const provider = getControlPlaneAuthProvider(); + if (provider === "supabase_org") { + return loginWithSupabaseOrg(request); + } + const accessKey = process.env.HINDSIGHT_CP_ACCESS_KEY; // If no access key is configured, return 503 @@ -62,6 +76,93 @@ export async function POST(request: NextRequest) { return response; } +async function loginWithSupabaseOrg(request: NextRequest): Promise { + let body: { + mode?: "login" | "signup"; + email?: string; + password?: string; + organization_name?: string; + selected_org_id?: string; + invite_token?: string; + }; + try { + body = await request.json(); + } catch { + return NextResponse.json( + localizeApiErrorPayload(request, { + error: "Invalid request body", + errorKey: "api.errors.auth.invalidRequestBody", + }), + { status: 400 } + ); + } + + if (!body.email || !body.password) { + return NextResponse.json({ error: "email and password are required" }, { status: 400 }); + } + + try { + const mode = body.mode || "login"; + const session = + mode === "signup" + ? await signUpWithPassword(body.email, body.password) + : await signInWithPassword(body.email, body.password); + + if (!session) { + return NextResponse.json( + { + success: false, + pending_email_confirmation: true, + error: "Email confirmation is required before login.", + }, + { status: 202 } + ); + } + + const acceptedInvite = body.invite_token + ? await acceptInviteForUser(session.user, body.invite_token) + : null; + const organizations = + mode === "signup" + ? [await ensureInitialOrganizationForSignup(session.user, body.organization_name)] + : await listOrganizationsForUser(session.user.id); + if (organizations.length === 0) { + return NextResponse.json( + { + error: + "No organization is available for this user. Accept an invite or sign up with organization creation enabled.", + }, + { status: 403 } + ); + } + + const selectedOrgId = acceptedInvite?.org_id || body.selected_org_id; + const selectedOrg = selectedOrgId + ? organizations.find((organization) => organization.id === selectedOrgId) + : organizations[0]; + if (!selectedOrg) { + return NextResponse.json( + { error: "User is not a member of the selected organization" }, + { status: 403 } + ); + } + + const response = NextResponse.json({ + success: true, + user: session.user, + organizations, + selected_org_id: selectedOrg.id, + }); + setSupabaseOrgSessionCookies(response, request, session, selectedOrg.id); + return response; + } catch (error) { + return NextResponse.json( + { error: error instanceof Error ? error.message : "Supabase login failed" }, + { status: 401 } + ); + } +} + /** * Constant-time string comparison to prevent timing attacks. */ diff --git a/hindsight-control-plane/src/app/api/auth/logout/route.ts b/hindsight-control-plane/src/app/api/auth/logout/route.ts index 7e74c17d0..f9d27585d 100644 --- a/hindsight-control-plane/src/app/api/auth/logout/route.ts +++ b/hindsight-control-plane/src/app/api/auth/logout/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { ACCESS_KEY_COOKIE, sessionCookieOptions } from "@/lib/auth/session"; +import { clearSupabaseOrgSessionCookies } from "@/lib/supabase-org/store"; export async function POST(request: NextRequest) { const response = NextResponse.json({ success: true }); @@ -11,6 +12,7 @@ export async function POST(request: NextRequest) { ...sessionCookieOptions(request), maxAge: 0, }); + clearSupabaseOrgSessionCookies(response, request); return response; } diff --git a/hindsight-control-plane/src/app/api/auth/select-org/route.ts b/hindsight-control-plane/src/app/api/auth/select-org/route.ts new file mode 100644 index 000000000..08960c354 --- /dev/null +++ b/hindsight-control-plane/src/app/api/auth/select-org/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { SUPABASE_ORG_SELECTED_ORG_COOKIE } from "@/lib/auth/provider"; +import { sessionCookieOptions } from "@/lib/auth/session"; +import { + getAuthenticatedUser, + jsonError, + listOrganizationsForUser, +} from "@/lib/supabase-org/store"; + +export async function POST(request: NextRequest) { + try { + const body = (await request.json()) as { org_id?: string }; + if (!body.org_id) return jsonError("org_id is required", 400); + const user = await getAuthenticatedUser(request); + const organizations = await listOrganizationsForUser(user.id); + if (!organizations.some((organization) => organization.id === body.org_id)) { + return jsonError("User is not a member of the selected organization", 403); + } + const response = NextResponse.json( + { success: true, selected_org_id: body.org_id }, + { status: 200 } + ); + response.cookies.set({ + name: SUPABASE_ORG_SELECTED_ORG_COOKIE, + value: body.org_id, + ...sessionCookieOptions(request), + maxAge: 30 * 24 * 60 * 60, + }); + return response; + } catch (error) { + return jsonError(error instanceof Error ? error.message : "Failed to select organization", 400); + } +} diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/audit-logs/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/audit-logs/route.ts index bb9d20d01..c8d0cec52 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/audit-logs/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/audit-logs/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { dataplaneBankUrl, getDataplaneHeadersForRequest } from "@/lib/hindsight-client"; export async function GET(request: Request, { params }: { params: Promise<{ bankId: string }> }) { try { @@ -23,7 +23,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ bank const url = dataplaneBankUrl(bankId, `/audit-logs${query ? `?${query}` : ""}`); const response = await fetch(url, { method: "GET", - headers: getDataplaneHeaders(), + headers: getDataplaneHeadersForRequest(request), }); const data = await response.json(); diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/audit-logs/stats/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/audit-logs/stats/route.ts index eafbd2aa1..57a92125f 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/audit-logs/stats/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/audit-logs/stats/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { dataplaneBankUrl, getDataplaneHeadersForRequest } from "@/lib/hindsight-client"; export async function GET(request: Request, { params }: { params: Promise<{ bankId: string }> }) { try { @@ -21,7 +21,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ bank const url = dataplaneBankUrl(bankId, `/audit-logs/stats${query ? `?${query}` : ""}`); const response = await fetch(url, { method: "GET", - headers: getDataplaneHeaders(), + headers: getDataplaneHeadersForRequest(request), }); const data = await response.json(); diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/config/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/config/route.ts index 2f0ec6eaf..570c84865 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/config/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/config/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { lowLevelClient, sdk } from "@/lib/hindsight-client"; +import { createDataplaneClientForRequest, sdk } from "@/lib/hindsight-client"; import { respondWithSdk } from "@/lib/sdk-response"; export async function GET( @@ -9,7 +9,7 @@ export async function GET( ) { const { bankId } = await params; const response = await sdk.getBankConfig({ - client: lowLevelClient, + client: createDataplaneClientForRequest(request), path: { bank_id: bankId }, }); return respondWithSdk(response, "Failed to fetch bank config", { request }); @@ -35,7 +35,7 @@ export async function PATCH( const { updates } = body; const response = await sdk.updateBankConfig({ - client: lowLevelClient, + client: createDataplaneClientForRequest(request), path: { bank_id: bankId }, body: { updates }, }); @@ -48,7 +48,7 @@ export async function DELETE( ) { const { bankId } = await params; const response = await sdk.resetBankConfig({ - client: lowLevelClient, + client: createDataplaneClientForRequest(request), path: { bank_id: bankId }, }); return respondWithSdk(response, "Failed to reset bank config", { request }); diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/consolidate/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/consolidate/route.ts index 7e5c806b5..230af2c7b 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/consolidate/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/consolidate/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { sdk, createDataplaneClientForRequest } from "@/lib/hindsight-client"; import { respondWithSdk } from "@/lib/sdk-response"; export async function POST(request: Request, { params }: { params: Promise<{ bankId: string }> }) { @@ -17,7 +17,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ ban } const response = await sdk.triggerConsolidation({ - client: lowLevelClient, + client: createDataplaneClientForRequest(request), path: { bank_id: bankId }, }); return respondWithSdk(response, "Failed to trigger consolidation", { request }); diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/consolidation-recover/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/consolidation-recover/route.ts index 3f967653e..528844634 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/consolidation-recover/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/consolidation-recover/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { sdk, createDataplaneClientForRequest } from "@/lib/hindsight-client"; import { respondWithSdk } from "@/lib/sdk-response"; export async function POST(request: Request, { params }: { params: Promise<{ bankId: string }> }) { @@ -17,7 +17,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ ban } const response = await sdk.recoverConsolidation({ - client: lowLevelClient, + client: createDataplaneClientForRequest(request), path: { bank_id: bankId }, }); return respondWithSdk(response, "Failed to recover consolidation", { request }); diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/directives/[directiveId]/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/directives/[directiveId]/route.ts index 3121dbb1e..df62bd7ed 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/directives/[directiveId]/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/directives/[directiveId]/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { dataplaneBankUrl, getDataplaneHeadersForRequest } from "@/lib/hindsight-client"; export async function GET( request: Request, @@ -21,7 +21,7 @@ export async function GET( const response = await fetch( dataplaneBankUrl(bankId, `/directives/${encodeURIComponent(directiveId)}`), - { method: "GET", headers: getDataplaneHeaders() } + { method: "GET", headers: getDataplaneHeadersForRequest(request) } ); if (!response.ok) { @@ -73,7 +73,7 @@ export async function PATCH( dataplaneBankUrl(bankId, `/directives/${encodeURIComponent(directiveId)}`), { method: "PATCH", - headers: getDataplaneHeaders({ "Content-Type": "application/json" }), + headers: getDataplaneHeadersForRequest(request, { "Content-Type": "application/json" }), body: JSON.stringify(body), } ); @@ -123,7 +123,7 @@ export async function DELETE( const response = await fetch( dataplaneBankUrl(bankId, `/directives/${encodeURIComponent(directiveId)}`), - { method: "DELETE", headers: getDataplaneHeaders() } + { method: "DELETE", headers: getDataplaneHeadersForRequest(request) } ); if (!response.ok) { diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/directives/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/directives/route.ts index 01b2675df..32ca1035a 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/directives/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/directives/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { dataplaneBankUrl, getDataplaneHeadersForRequest } from "@/lib/hindsight-client"; export async function GET(request: Request, { params }: { params: Promise<{ bankId: string }> }) { try { @@ -31,7 +31,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ bank bankId, `/directives${queryParams.toString() ? `?${queryParams}` : ""}` ); - const response = await fetch(url, { method: "GET", headers: getDataplaneHeaders() }); + const response = await fetch(url, { + method: "GET", + headers: getDataplaneHeadersForRequest(request), + }); if (!response.ok) { const errorText = await response.text(); @@ -77,7 +80,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ ban const response = await fetch(dataplaneBankUrl(bankId, "/directives"), { method: "POST", - headers: getDataplaneHeaders({ "Content-Type": "application/json" }), + headers: getDataplaneHeadersForRequest(request, { "Content-Type": "application/json" }), body: JSON.stringify(body), }); diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/export/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/export/route.ts index c7e162c6f..7454fc455 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/export/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/export/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { dataplaneBankUrl, getDataplaneHeadersForRequest } from "@/lib/hindsight-client"; export async function GET( request: NextRequest, @@ -11,7 +11,7 @@ export async function GET( const url = dataplaneBankUrl(bankId, "/export"); const response = await fetch(url, { - headers: getDataplaneHeaders(), + headers: getDataplaneHeadersForRequest(request), }); const data = await response.json(); diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/health/llm/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/health/llm/route.ts index a0bdff111..aacf7c1cb 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/health/llm/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/health/llm/route.ts @@ -1,5 +1,5 @@ import { NextRequest } from "next/server"; -import { lowLevelClient, sdk } from "@/lib/hindsight-client"; +import { createDataplaneClientForRequest, sdk } from "@/lib/hindsight-client"; import { respondWithSdk } from "@/lib/sdk-response"; export async function POST( @@ -8,7 +8,7 @@ export async function POST( ) { const { bankId } = await params; const response = await sdk.testBankLlm({ - client: lowLevelClient, + client: createDataplaneClientForRequest(request), path: { bank_id: bankId }, }); return respondWithSdk(response, "Failed to test bank LLM connectivity", { request }); diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/import/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/import/route.ts index 57dd1c444..ecc4f9b3c 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/import/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/import/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { dataplaneBankUrl, getDataplaneHeadersForRequest } from "@/lib/hindsight-client"; export async function POST( request: NextRequest, @@ -15,7 +15,7 @@ export async function POST( const url = dataplaneBankUrl(bankId, `/import${dryRun ? "?dry_run=true" : ""}`); const response = await fetch(url, { method: "POST", - headers: getDataplaneHeaders({ "Content-Type": "application/json" }), + headers: getDataplaneHeadersForRequest(request, { "Content-Type": "application/json" }), body: JSON.stringify(body), }); diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/llm-requests/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/llm-requests/route.ts index 4869647bf..1ac2d9680 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/llm-requests/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/llm-requests/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { dataplaneBankUrl, getDataplaneHeadersForRequest } from "@/lib/hindsight-client"; export async function GET(request: Request, { params }: { params: Promise<{ bankId: string }> }) { try { @@ -23,7 +23,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ bank const url = dataplaneBankUrl(bankId, `/llm-requests${query ? `?${query}` : ""}`); const response = await fetch(url, { method: "GET", - headers: getDataplaneHeaders(), + headers: getDataplaneHeadersForRequest(request), }); const data = await response.json(); diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/llm-requests/stats/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/llm-requests/stats/route.ts index 62555a35e..c75363a84 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/llm-requests/stats/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/llm-requests/stats/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { dataplaneBankUrl, getDataplaneHeadersForRequest } from "@/lib/hindsight-client"; export async function GET(request: Request, { params }: { params: Promise<{ bankId: string }> }) { try { @@ -21,7 +21,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ bank const url = dataplaneBankUrl(bankId, `/llm-requests/stats${query ? `?${query}` : ""}`); const response = await fetch(url, { method: "GET", - headers: getDataplaneHeaders(), + headers: getDataplaneHeadersForRequest(request), }); const data = await response.json(); diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[mentalModelId]/clear/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[mentalModelId]/clear/route.ts index bf62ae4c3..25ddf1a4e 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[mentalModelId]/clear/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[mentalModelId]/clear/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { dataplaneBankUrl, getDataplaneHeadersForRequest } from "@/lib/hindsight-client"; export async function POST( request: Request, @@ -21,7 +21,7 @@ export async function POST( const response = await fetch( dataplaneBankUrl(bankId, `/mental-models/${encodeURIComponent(mentalModelId)}/clear`), - { method: "POST", headers: getDataplaneHeaders() } + { method: "POST", headers: getDataplaneHeadersForRequest(request) } ); if (!response.ok) { diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[mentalModelId]/history/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[mentalModelId]/history/route.ts index 24b9ffefa..6588a117c 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[mentalModelId]/history/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[mentalModelId]/history/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { dataplaneBankUrl, getDataplaneHeadersForRequest } from "@/lib/hindsight-client"; export async function GET( request: Request, @@ -21,7 +21,7 @@ export async function GET( const response = await fetch( dataplaneBankUrl(bankId, `/mental-models/${encodeURIComponent(mentalModelId)}/history`), - { method: "GET", headers: getDataplaneHeaders() } + { method: "GET", headers: getDataplaneHeadersForRequest(request) } ); if (!response.ok) { diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[mentalModelId]/refresh/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[mentalModelId]/refresh/route.ts index f934761c2..a75e3afee 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[mentalModelId]/refresh/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[mentalModelId]/refresh/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { dataplaneBankUrl, getDataplaneHeadersForRequest } from "@/lib/hindsight-client"; export async function POST( request: Request, @@ -21,7 +21,7 @@ export async function POST( const response = await fetch( dataplaneBankUrl(bankId, `/mental-models/${encodeURIComponent(mentalModelId)}/refresh`), - { method: "POST", headers: getDataplaneHeaders() } + { method: "POST", headers: getDataplaneHeadersForRequest(request) } ); if (!response.ok) { diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[mentalModelId]/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[mentalModelId]/route.ts index 73eab0874..33c974a39 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[mentalModelId]/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/[mentalModelId]/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { dataplaneBankUrl, getDataplaneHeadersForRequest } from "@/lib/hindsight-client"; export async function GET( request: Request, @@ -21,7 +21,7 @@ export async function GET( const response = await fetch( dataplaneBankUrl(bankId, `/mental-models/${encodeURIComponent(mentalModelId)}`), - { method: "GET", headers: getDataplaneHeaders() } + { method: "GET", headers: getDataplaneHeadersForRequest(request) } ); if (!response.ok) { @@ -73,7 +73,7 @@ export async function PATCH( dataplaneBankUrl(bankId, `/mental-models/${encodeURIComponent(mentalModelId)}`), { method: "PATCH", - headers: getDataplaneHeaders({ "Content-Type": "application/json" }), + headers: getDataplaneHeadersForRequest(request, { "Content-Type": "application/json" }), body: JSON.stringify(body), } ); @@ -123,7 +123,7 @@ export async function DELETE( const response = await fetch( dataplaneBankUrl(bankId, `/mental-models/${encodeURIComponent(mentalModelId)}`), - { method: "DELETE", headers: getDataplaneHeaders() } + { method: "DELETE", headers: getDataplaneHeadersForRequest(request) } ); if (!response.ok) { diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/route.ts index b892f322a..8452232ed 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/mental-models/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { dataplaneBankUrl, getDataplaneHeadersForRequest } from "@/lib/hindsight-client"; export async function GET(request: Request, { params }: { params: Promise<{ bankId: string }> }) { try { @@ -31,7 +31,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ bank bankId, `/mental-models${queryParams.toString() ? `?${queryParams}` : ""}` ); - const response = await fetch(url, { method: "GET", headers: getDataplaneHeaders() }); + const response = await fetch(url, { + method: "GET", + headers: getDataplaneHeadersForRequest(request), + }); if (!response.ok) { const errorText = await response.text(); @@ -77,7 +80,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ ban const response = await fetch(dataplaneBankUrl(bankId, "/mental-models"), { method: "POST", - headers: getDataplaneHeaders({ "Content-Type": "application/json" }), + headers: getDataplaneHeadersForRequest(request, { "Content-Type": "application/json" }), body: JSON.stringify(body), }); diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/observations/[modelId]/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/observations/[modelId]/route.ts index e2c7597ec..765e6816d 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/observations/[modelId]/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/observations/[modelId]/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { dataplaneBankUrl, getDataplaneHeadersForRequest } from "@/lib/hindsight-client"; export async function GET( request: Request, @@ -21,7 +21,7 @@ export async function GET( const response = await fetch( dataplaneBankUrl(bankId, `/memories/${encodeURIComponent(modelId)}`), - { method: "GET", headers: getDataplaneHeaders() } + { method: "GET", headers: getDataplaneHeadersForRequest(request) } ); if (!response.ok) { diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/observations/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/observations/route.ts index 3b7c08c51..a6a0785d9 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/observations/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/observations/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { sdk, createDataplaneClientForRequest } from "@/lib/hindsight-client"; export async function GET(request: Request, { params }: { params: Promise<{ bankId: string }> }) { try { @@ -18,7 +18,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ bank // Note: tags filtering is not supported by the list_memories API endpoint const response = await sdk.listMemories({ - client: lowLevelClient, + client: createDataplaneClientForRequest(request), path: { bank_id: bankId }, query: { type: "observation", @@ -82,7 +82,7 @@ export async function DELETE( } const response = await sdk.clearObservations({ - client: lowLevelClient, + client: createDataplaneClientForRequest(request), path: { bank_id: bankId }, }); diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/observations/scopes/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/observations/scopes/route.ts index 6569c7316..4401e5509 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/observations/scopes/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/observations/scopes/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { DATAPLANE_URL, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { DATAPLANE_URL, getDataplaneHeadersForRequest } from "@/lib/hindsight-client"; export async function GET(request: Request, { params }: { params: Promise<{ bankId: string }> }) { try { @@ -18,7 +18,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ bank const response = await fetch( `${DATAPLANE_URL}/v1/default/banks/${bankId}/observations/scopes`, - { headers: getDataplaneHeaders() } + { headers: getDataplaneHeadersForRequest(request) } ); if (!response.ok) { diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/operations/[operationId]/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/operations/[operationId]/route.ts index 3110abde8..4e082298c 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/operations/[operationId]/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/operations/[operationId]/route.ts @@ -1,6 +1,11 @@ import { NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { sdk, lowLevelClient, dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { + sdk, + createDataplaneClientForRequest, + dataplaneBankUrl, + getDataplaneHeadersForRequest, +} from "@/lib/hindsight-client"; import { respondWithSdk } from "@/lib/sdk-response"; export async function GET( @@ -33,7 +38,7 @@ export async function GET( const includePayload = url.searchParams.get("include_payload") === "true"; const response = await sdk.getOperationStatus({ - client: lowLevelClient, + client: createDataplaneClientForRequest(request), path: { bank_id: bankId, operation_id: operationId }, query: includePayload ? { include_payload: true } : undefined, }); @@ -70,7 +75,7 @@ export async function POST( const url = dataplaneBankUrl(bankId, `/operations/${encodeURIComponent(operationId)}/retry`); const response = await fetch(url, { method: "POST", - headers: getDataplaneHeaders({ "Content-Type": "application/json" }), + headers: getDataplaneHeadersForRequest(request, { "Content-Type": "application/json" }), }); const data = await response.json(); diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/route.ts index 656ae4b0e..b419556ab 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { sdk, createDataplaneClientForRequest } from "@/lib/hindsight-client"; import { respondWithSdk } from "@/lib/sdk-response"; export async function PUT(request: Request, { params }: { params: Promise<{ bankId: string }> }) { @@ -29,7 +29,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ bank } const response = await sdk.createOrUpdateBank({ - client: lowLevelClient, + client: createDataplaneClientForRequest(request), path: { bank_id: bankId }, body: { name: body.name, @@ -66,7 +66,7 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ ba } const response = await sdk.updateBank({ - client: lowLevelClient, + client: createDataplaneClientForRequest(request), path: { bank_id: bankId }, body: { name: body.name, @@ -94,7 +94,7 @@ export async function DELETE( } const response = await sdk.deleteBank({ - client: lowLevelClient, + client: createDataplaneClientForRequest(request), path: { bank_id: bankId }, }); return respondWithSdk(response, "Failed to delete bank", { request }); diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/tags/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/tags/route.ts index 707a59366..4b3bfaf00 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/tags/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/tags/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { dataplaneBankUrl, getDataplaneHeadersForRequest } from "@/lib/hindsight-client"; export async function GET(request: Request, { params }: { params: Promise<{ bankId: string }> }) { try { @@ -28,7 +28,10 @@ export async function GET(request: Request, { params }: { params: Promise<{ bank if (offset) queryParams.append("offset", offset); const url = dataplaneBankUrl(bankId, `/tags${queryParams.toString() ? `?${queryParams}` : ""}`); - const response = await fetch(url, { method: "GET", headers: getDataplaneHeaders() }); + const response = await fetch(url, { + method: "GET", + headers: getDataplaneHeadersForRequest(request), + }); if (!response.ok) { const errorText = await response.text(); diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/webhooks/[webhookId]/deliveries/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/webhooks/[webhookId]/deliveries/route.ts index 3b8e6b161..3d1c541d0 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/webhooks/[webhookId]/deliveries/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/webhooks/[webhookId]/deliveries/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { dataplaneBankUrl, getDataplaneHeadersForRequest } from "@/lib/hindsight-client"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; export async function GET( @@ -15,7 +15,7 @@ export async function GET( const res = await fetch( dataplaneBankUrl(bankId, `/webhooks/${encodeURIComponent(webhookId)}/deliveries?${qs}`), { - headers: getDataplaneHeaders({ "Content-Type": "application/json" }), + headers: getDataplaneHeadersForRequest(request, { "Content-Type": "application/json" }), } ); const data = await res.json(); diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/webhooks/[webhookId]/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/webhooks/[webhookId]/route.ts index 87dba8760..e6a9e394a 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/webhooks/[webhookId]/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/webhooks/[webhookId]/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { dataplaneBankUrl, getDataplaneHeadersForRequest } from "@/lib/hindsight-client"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; export async function PATCH( @@ -10,7 +10,7 @@ export async function PATCH( const body = await request.json(); const res = await fetch(dataplaneBankUrl(bankId, `/webhooks/${encodeURIComponent(webhookId)}`), { method: "PATCH", - headers: getDataplaneHeaders({ "Content-Type": "application/json" }), + headers: getDataplaneHeadersForRequest(request, { "Content-Type": "application/json" }), body: JSON.stringify(body), }); const data = await res.json(); @@ -32,7 +32,7 @@ export async function DELETE( const { bankId, webhookId } = await params; const res = await fetch(dataplaneBankUrl(bankId, `/webhooks/${encodeURIComponent(webhookId)}`), { method: "DELETE", - headers: getDataplaneHeaders({ "Content-Type": "application/json" }), + headers: getDataplaneHeadersForRequest(request, { "Content-Type": "application/json" }), }); const data = await res.json(); if (!res.ok) diff --git a/hindsight-control-plane/src/app/api/banks/[bankId]/webhooks/route.ts b/hindsight-control-plane/src/app/api/banks/[bankId]/webhooks/route.ts index 61ffa9164..e14ee365d 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/webhooks/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/webhooks/route.ts @@ -1,11 +1,11 @@ import { NextResponse } from "next/server"; -import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { dataplaneBankUrl, getDataplaneHeadersForRequest } from "@/lib/hindsight-client"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; export async function GET(request: Request, { params }: { params: Promise<{ bankId: string }> }) { const { bankId } = await params; const res = await fetch(dataplaneBankUrl(bankId, "/webhooks"), { - headers: getDataplaneHeaders({ "Content-Type": "application/json" }), + headers: getDataplaneHeadersForRequest(request, { "Content-Type": "application/json" }), }); const data = await res.json(); if (!res.ok) @@ -24,7 +24,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ ban const body = await request.json(); const res = await fetch(dataplaneBankUrl(bankId, "/webhooks"), { method: "POST", - headers: getDataplaneHeaders({ "Content-Type": "application/json" }), + headers: getDataplaneHeadersForRequest(request, { "Content-Type": "application/json" }), body: JSON.stringify(body), }); const data = await res.json(); diff --git a/hindsight-control-plane/src/app/api/banks/route.ts b/hindsight-control-plane/src/app/api/banks/route.ts index d0a8d46ef..f9788f74a 100644 --- a/hindsight-control-plane/src/app/api/banks/route.ts +++ b/hindsight-control-plane/src/app/api/banks/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { sdk, createDataplaneClientForRequest } from "@/lib/hindsight-client"; import { respondWithSdk } from "@/lib/sdk-response"; const HTTP_CREATED = 201; export async function GET(request: Request) { - const response = await sdk.listBanks({ client: lowLevelClient }); + const response = await sdk.listBanks({ client: createDataplaneClientForRequest(request) }); return respondWithSdk(response, "Failed to fetch banks", { request }); } @@ -36,7 +36,7 @@ export async function POST(request: Request) { } const response = await sdk.createOrUpdateBank({ - client: lowLevelClient, + client: createDataplaneClientForRequest(request), path: { bank_id }, body: {}, }); diff --git a/hindsight-control-plane/src/app/api/chunks/[chunkId]/route.ts b/hindsight-control-plane/src/app/api/chunks/[chunkId]/route.ts index 2c142ab29..b273f4e82 100644 --- a/hindsight-control-plane/src/app/api/chunks/[chunkId]/route.ts +++ b/hindsight-control-plane/src/app/api/chunks/[chunkId]/route.ts @@ -1,5 +1,5 @@ import { NextRequest } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { sdk, createDataplaneClientForRequest } from "@/lib/hindsight-client"; import { respondWithSdk } from "@/lib/sdk-response"; export async function GET( @@ -8,7 +8,7 @@ export async function GET( ) { const { chunkId } = await params; const response = await sdk.getChunk({ - client: lowLevelClient, + client: createDataplaneClientForRequest(request), path: { chunk_id: chunkId }, }); return respondWithSdk(response, "Failed to fetch chunk", { request }); diff --git a/hindsight-control-plane/src/app/api/documents/[documentId]/chunks/route.ts b/hindsight-control-plane/src/app/api/documents/[documentId]/chunks/route.ts index 124196472..0153a67ef 100644 --- a/hindsight-control-plane/src/app/api/documents/[documentId]/chunks/route.ts +++ b/hindsight-control-plane/src/app/api/documents/[documentId]/chunks/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { DATAPLANE_URL, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { DATAPLANE_URL, getDataplaneHeadersForRequest } from "@/lib/hindsight-client"; export async function GET( request: NextRequest, @@ -27,7 +27,7 @@ export async function GET( const response = await fetch( `${DATAPLANE_URL}/v1/default/banks/${bankId}/documents/${documentId}/chunks?limit=${limit}&offset=${offset}`, { - headers: getDataplaneHeaders(), + headers: getDataplaneHeadersForRequest(request), } ); diff --git a/hindsight-control-plane/src/app/api/documents/[documentId]/reprocess/route.ts b/hindsight-control-plane/src/app/api/documents/[documentId]/reprocess/route.ts index 62921cb47..e48d591d6 100644 --- a/hindsight-control-plane/src/app/api/documents/[documentId]/reprocess/route.ts +++ b/hindsight-control-plane/src/app/api/documents/[documentId]/reprocess/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { DATAPLANE_URL, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { DATAPLANE_URL, getDataplaneHeadersForRequest } from "@/lib/hindsight-client"; export async function POST( request: NextRequest, @@ -25,7 +25,7 @@ export async function POST( `${DATAPLANE_URL}/v1/default/banks/${bankId}/documents/${documentId}/reprocess`, { method: "POST", - headers: getDataplaneHeaders({ "Content-Type": "application/json" }), + headers: getDataplaneHeadersForRequest(request, { "Content-Type": "application/json" }), } ); diff --git a/hindsight-control-plane/src/app/api/documents/[documentId]/route.ts b/hindsight-control-plane/src/app/api/documents/[documentId]/route.ts index 664ef4435..c4eba48cb 100644 --- a/hindsight-control-plane/src/app/api/documents/[documentId]/route.ts +++ b/hindsight-control-plane/src/app/api/documents/[documentId]/route.ts @@ -1,6 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { sdk, lowLevelClient, dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { + sdk, + createDataplaneClientForRequest, + dataplaneBankUrl, + getDataplaneHeadersForRequest, +} from "@/lib/hindsight-client"; import { respondWithSdk } from "@/lib/sdk-response"; export async function GET( @@ -22,7 +27,7 @@ export async function GET( } const response = await sdk.getDocument({ - client: lowLevelClient, + client: createDataplaneClientForRequest(request), path: { bank_id: bankId, document_id: documentId }, }); return respondWithSdk(response, "Failed to fetch document", { request }); @@ -52,7 +57,7 @@ export async function PATCH( dataplaneBankUrl(bankId, `/documents/${encodeURIComponent(documentId)}`), { method: "PATCH", - headers: getDataplaneHeaders({ "Content-Type": "application/json" }), + headers: getDataplaneHeadersForRequest(request, { "Content-Type": "application/json" }), body: JSON.stringify(body), } ); @@ -95,7 +100,7 @@ export async function DELETE( } const response = await sdk.deleteDocument({ - client: lowLevelClient, + client: createDataplaneClientForRequest(request), path: { bank_id: bankId, document_id: documentId }, }); return respondWithSdk(response, "Failed to delete document", { request }); diff --git a/hindsight-control-plane/src/app/api/documents/route.ts b/hindsight-control-plane/src/app/api/documents/route.ts index 3dd53cd79..ea6ea185f 100644 --- a/hindsight-control-plane/src/app/api/documents/route.ts +++ b/hindsight-control-plane/src/app/api/documents/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { sdk, createDataplaneClientForRequest } from "@/lib/hindsight-client"; import { respondWithSdk } from "@/lib/sdk-response"; export async function GET(request: NextRequest) { @@ -21,7 +21,7 @@ export async function GET(request: NextRequest) { const offset = searchParams.get("offset") ? Number(searchParams.get("offset")) : undefined; const response = await sdk.listDocuments({ - client: lowLevelClient, + client: createDataplaneClientForRequest(request), path: { bank_id: bankId }, query: { limit, offset }, }); diff --git a/hindsight-control-plane/src/app/api/documents/transfer/route.ts b/hindsight-control-plane/src/app/api/documents/transfer/route.ts index f70b600bf..9d02b2f4d 100644 --- a/hindsight-control-plane/src/app/api/documents/transfer/route.ts +++ b/hindsight-control-plane/src/app/api/documents/transfer/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { dataplaneBankUrl, getDataplaneHeadersForRequest } from "@/lib/hindsight-client"; /** * Export documents as a transfer ZIP archive. @@ -31,7 +31,7 @@ export async function GET(request: NextRequest) { const suffix = `/document-transfer${qs.toString() ? `?${qs.toString()}` : ""}`; const response = await fetch(dataplaneBankUrl(bankId, suffix), { - headers: getDataplaneHeaders(), + headers: getDataplaneHeadersForRequest(request), }); if (!response.ok) { const error = await response.json().catch(() => ({ detail: response.statusText })); @@ -98,7 +98,7 @@ export async function POST(request: NextRequest) { const suffix = `/document-transfer?on_conflict=${encodeURIComponent(onConflict)}`; const response = await fetch(dataplaneBankUrl(bankId, suffix), { method: "POST", - headers: getDataplaneHeaders(), + headers: getDataplaneHeadersForRequest(request), body: outForm, }); if (!response.ok) { diff --git a/hindsight-control-plane/src/app/api/entities/[entityId]/regenerate/route.ts b/hindsight-control-plane/src/app/api/entities/[entityId]/regenerate/route.ts index e849f631c..e36001d6b 100644 --- a/hindsight-control-plane/src/app/api/entities/[entityId]/regenerate/route.ts +++ b/hindsight-control-plane/src/app/api/entities/[entityId]/regenerate/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { sdk, createDataplaneClientForRequest } from "@/lib/hindsight-client"; import { respondWithSdk } from "@/lib/sdk-response"; export async function POST( @@ -24,7 +24,7 @@ export async function POST( const decodedEntityId = decodeURIComponent(entityId); const response = await sdk.regenerateEntityObservations({ - client: lowLevelClient, + client: createDataplaneClientForRequest(request), path: { bank_id: bankId, entity_id: decodedEntityId, diff --git a/hindsight-control-plane/src/app/api/entities/[entityId]/route.ts b/hindsight-control-plane/src/app/api/entities/[entityId]/route.ts index 4a8879a76..a7f8f1ee4 100644 --- a/hindsight-control-plane/src/app/api/entities/[entityId]/route.ts +++ b/hindsight-control-plane/src/app/api/entities/[entityId]/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { sdk, createDataplaneClientForRequest } from "@/lib/hindsight-client"; import { respondWithSdk } from "@/lib/sdk-response"; export async function GET( @@ -25,7 +25,7 @@ export async function GET( const decodedEntityId = decodeURIComponent(entityId); const response = await sdk.getEntity({ - client: lowLevelClient, + client: createDataplaneClientForRequest(request), path: { bank_id: bankId, entity_id: decodedEntityId, diff --git a/hindsight-control-plane/src/app/api/entities/graph/route.ts b/hindsight-control-plane/src/app/api/entities/graph/route.ts index 86ce58810..8174f85c7 100644 --- a/hindsight-control-plane/src/app/api/entities/graph/route.ts +++ b/hindsight-control-plane/src/app/api/entities/graph/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { sdk, createDataplaneClientForRequest } from "@/lib/hindsight-client"; import { respondWithSdk } from "@/lib/sdk-response"; export async function GET(request: NextRequest) { @@ -23,7 +23,7 @@ export async function GET(request: NextRequest) { : undefined; const response = await sdk.getEntityGraph({ - client: lowLevelClient, + client: createDataplaneClientForRequest(request), path: { bank_id: bankId }, query: { limit, min_count: minCount }, }); diff --git a/hindsight-control-plane/src/app/api/entities/route.ts b/hindsight-control-plane/src/app/api/entities/route.ts index af43f9b1c..86b825a13 100644 --- a/hindsight-control-plane/src/app/api/entities/route.ts +++ b/hindsight-control-plane/src/app/api/entities/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { sdk, createDataplaneClientForRequest } from "@/lib/hindsight-client"; import { respondWithSdk } from "@/lib/sdk-response"; export async function GET(request: NextRequest) { @@ -21,7 +21,7 @@ export async function GET(request: NextRequest) { const offset = searchParams.get("offset") ? Number(searchParams.get("offset")) : undefined; const response = await sdk.listEntities({ - client: lowLevelClient, + client: createDataplaneClientForRequest(request), path: { bank_id: bankId }, query: { limit, offset }, }); diff --git a/hindsight-control-plane/src/app/api/extract/route.ts b/hindsight-control-plane/src/app/api/extract/route.ts index aae274757..ffee03a54 100644 --- a/hindsight-control-plane/src/app/api/extract/route.ts +++ b/hindsight-control-plane/src/app/api/extract/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { dataplaneBankUrl, getDataplaneHeadersForRequest } from "@/lib/hindsight-client"; /** * Proxy for the dataplane dry-run extraction endpoint: extract facts from text with a candidate @@ -24,7 +24,7 @@ export async function POST(request: NextRequest) { const res = await fetch(dataplaneBankUrl(bankId, "/memories/dry-run-extract"), { method: "POST", - headers: getDataplaneHeaders({ "Content-Type": "application/json" }), + headers: getDataplaneHeadersForRequest(request, { "Content-Type": "application/json" }), body: JSON.stringify({ content, retain_mission, diff --git a/hindsight-control-plane/src/app/api/files/retain/route.ts b/hindsight-control-plane/src/app/api/files/retain/route.ts index 2b6bcfe77..37fc82628 100644 --- a/hindsight-control-plane/src/app/api/files/retain/route.ts +++ b/hindsight-control-plane/src/app/api/files/retain/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { dataplaneBankUrl, getDataplaneHeadersForRequest } from "@/lib/hindsight-client"; export async function POST(request: NextRequest) { try { @@ -50,7 +50,7 @@ export async function POST(request: NextRequest) { // Forward the form data to the dataplane const response = await fetch(url, { method: "POST", - headers: getDataplaneHeaders(), + headers: getDataplaneHeadersForRequest(request), body: formData, // Don't set Content-Type - let fetch handle multipart boundary }); diff --git a/hindsight-control-plane/src/app/api/graph/route.ts b/hindsight-control-plane/src/app/api/graph/route.ts index 95ecdf635..3c3578297 100644 --- a/hindsight-control-plane/src/app/api/graph/route.ts +++ b/hindsight-control-plane/src/app/api/graph/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { DATAPLANE_URL, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { DATAPLANE_URL, getDataplaneHeadersForRequest } from "@/lib/hindsight-client"; export async function GET(request: NextRequest) { try { @@ -43,7 +43,7 @@ export async function GET(request: NextRequest) { if (chunkId) params.append("chunk_id", chunkId); const response = await fetch(`${DATAPLANE_URL}/v1/default/banks/${bankId}/graph?${params}`, { - headers: getDataplaneHeaders(), + headers: getDataplaneHeadersForRequest(request), }); if (!response.ok) { diff --git a/hindsight-control-plane/src/app/api/health/route.ts b/hindsight-control-plane/src/app/api/health/route.ts index fa98cd0a3..59960cdd7 100644 --- a/hindsight-control-plane/src/app/api/health/route.ts +++ b/hindsight-control-plane/src/app/api/health/route.ts @@ -1,10 +1,10 @@ import { NextResponse } from "next/server"; import { createClient, createConfig, sdk } from "@vectorize-io/hindsight-client"; -import { getDataplaneHeaders } from "@/lib/hindsight-client"; +import { getDataplaneHeadersForRequest } from "@/lib/hindsight-client"; const HEALTH_CHECK_TIMEOUT_MS = 3000; -export async function GET() { +export async function GET(request: Request) { const status: { status: string; service: string; @@ -28,7 +28,7 @@ export async function GET() { createConfig({ baseUrl: dataplaneUrl, signal: controller.signal, - headers: getDataplaneHeaders(), + headers: getDataplaneHeadersForRequest(request), }) ); diff --git a/hindsight-control-plane/src/app/api/list/route.ts b/hindsight-control-plane/src/app/api/list/route.ts index eaf1110cc..7e7c6909f 100644 --- a/hindsight-control-plane/src/app/api/list/route.ts +++ b/hindsight-control-plane/src/app/api/list/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { hindsightClient } from "@/lib/hindsight-client"; +import { createDataplaneClientForRequest, sdk } from "@/lib/hindsight-client"; +import { respondWithSdk } from "@/lib/sdk-response"; export async function GET(request: NextRequest) { try { @@ -33,17 +34,24 @@ export async function GET(request: NextRequest) { const state = stateParam === "valid" || stateParam === "invalidated" ? stateParam : undefined; const documentId = searchParams.get("document_id") || undefined; - const response = await hindsightClient.listMemories(bankId, { - limit, - offset, - type, - q, - consolidationState, - state, - documentId, + const response = await sdk.listMemories({ + client: createDataplaneClientForRequest(request), + path: { bank_id: bankId }, + query: { + limit, + offset, + type, + q, + consolidation_state: consolidationState, + state, + document_id: documentId, + }, }); - return NextResponse.json(response, { status: 200 }); + return respondWithSdk(response, "Failed to list memory units", { + request, + errorKey: "api.errors.memories.list", + }); } catch (error) { console.error("Error listing memory units:", error); return NextResponse.json( diff --git a/hindsight-control-plane/src/app/api/me/route.ts b/hindsight-control-plane/src/app/api/me/route.ts new file mode 100644 index 000000000..aaea636b1 --- /dev/null +++ b/hindsight-control-plane/src/app/api/me/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; + +import { + getAuthenticatedUser, + getCurrentOrgContext, + jsonError, + listOrganizationsForUser, +} from "@/lib/supabase-org/store"; + +export async function GET(request: Request) { + try { + const user = await getAuthenticatedUser(request); + const organizations = await listOrganizationsForUser(user.id); + let current = null; + try { + const context = await getCurrentOrgContext(request); + current = { + org_id: context.selectedOrgId, + role: context.membership.role, + }; + } catch { + current = null; + } + return NextResponse.json({ user, organizations, current }, { status: 200 }); + } catch (error) { + return jsonError( + error instanceof Error ? error.message : "Failed to resolve current user", + 401 + ); + } +} diff --git a/hindsight-control-plane/src/app/api/memories/[memoryId]/history/route.ts b/hindsight-control-plane/src/app/api/memories/[memoryId]/history/route.ts index ca9cd7b0b..d2751787b 100644 --- a/hindsight-control-plane/src/app/api/memories/[memoryId]/history/route.ts +++ b/hindsight-control-plane/src/app/api/memories/[memoryId]/history/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { dataplaneBankUrl, getDataplaneHeadersForRequest } from "@/lib/hindsight-client"; export async function GET( request: NextRequest, @@ -25,7 +25,7 @@ export async function GET( dataplaneBankUrl(bankId, `/memories/${encodeURIComponent(memoryId)}/history`), { method: "GET", - headers: getDataplaneHeaders({ "Content-Type": "application/json" }), + headers: getDataplaneHeadersForRequest(request, { "Content-Type": "application/json" }), } ); diff --git a/hindsight-control-plane/src/app/api/memories/[memoryId]/route.ts b/hindsight-control-plane/src/app/api/memories/[memoryId]/route.ts index a09f0802d..f265a5bfd 100644 --- a/hindsight-control-plane/src/app/api/memories/[memoryId]/route.ts +++ b/hindsight-control-plane/src/app/api/memories/[memoryId]/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { dataplaneBankUrl, getDataplaneHeadersForRequest } from "@/lib/hindsight-client"; export async function GET( request: NextRequest, @@ -25,7 +25,7 @@ export async function GET( dataplaneBankUrl(bankId, `/memories/${encodeURIComponent(memoryId)}`), { method: "GET", - headers: getDataplaneHeaders({ "Content-Type": "application/json" }), + headers: getDataplaneHeadersForRequest(request, { "Content-Type": "application/json" }), } ); @@ -83,7 +83,7 @@ export async function PATCH( dataplaneBankUrl(bankId, `/memories/${encodeURIComponent(memoryId)}`), { method: "PATCH", - headers: getDataplaneHeaders({ "Content-Type": "application/json" }), + headers: getDataplaneHeadersForRequest(request, { "Content-Type": "application/json" }), body: JSON.stringify({ text, context, diff --git a/hindsight-control-plane/src/app/api/memories/retain/route.ts b/hindsight-control-plane/src/app/api/memories/retain/route.ts index 11b0db1a6..bee9597cd 100644 --- a/hindsight-control-plane/src/app/api/memories/retain/route.ts +++ b/hindsight-control-plane/src/app/api/memories/retain/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { hindsightClient } from "@/lib/hindsight-client"; +import { createDataplaneClientForRequest, sdk } from "@/lib/hindsight-client"; +import { respondWithSdk } from "@/lib/sdk-response"; export async function POST(request: NextRequest) { try { @@ -27,12 +28,23 @@ export async function POST(request: NextRequest) { })) : items; - const response = await hindsightClient.retainBatch(bankId, mappedItems, { - documentId: document_id, - documentTags: document_tags, + const itemsWithDocId = mappedItems?.map((item: any) => ({ + ...item, + document_id: item.document_id || document_id, + })); + const response = await sdk.retainMemories({ + client: createDataplaneClientForRequest(request), + path: { bank_id: bankId }, + body: { + items: itemsWithDocId, + document_tags, + }, }); - return NextResponse.json(response, { status: 200 }); + return respondWithSdk(response, "Failed to batch retain", { + request, + errorKey: "api.errors.memories.retain", + }); } catch (error: any) { console.error("Error batch retain:", error); diff --git a/hindsight-control-plane/src/app/api/memories/retain_async/route.ts b/hindsight-control-plane/src/app/api/memories/retain_async/route.ts index fb23feeed..9e9b0c1b2 100644 --- a/hindsight-control-plane/src/app/api/memories/retain_async/route.ts +++ b/hindsight-control-plane/src/app/api/memories/retain_async/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { sdk, createDataplaneClientForRequest } from "@/lib/hindsight-client"; import { respondWithSdk } from "@/lib/sdk-response"; export async function POST(request: NextRequest) { @@ -31,7 +31,7 @@ export async function POST(request: NextRequest) { const { items } = body; const response = await sdk.retainMemories({ - client: lowLevelClient, + client: createDataplaneClientForRequest(request), path: { bank_id: bankId }, body: { items, async: true }, }); diff --git a/hindsight-control-plane/src/app/api/operations/[agentId]/route.ts b/hindsight-control-plane/src/app/api/operations/[agentId]/route.ts index d0cc7e4cf..ad394d112 100644 --- a/hindsight-control-plane/src/app/api/operations/[agentId]/route.ts +++ b/hindsight-control-plane/src/app/api/operations/[agentId]/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { sdk, createDataplaneClientForRequest } from "@/lib/hindsight-client"; import { respondWithSdk } from "@/lib/sdk-response"; export async function GET( @@ -16,7 +16,7 @@ export async function GET( const excludeParents = searchParams.get("exclude_parents") === "true" ? true : undefined; const response = await sdk.listOperations({ - client: lowLevelClient, + client: createDataplaneClientForRequest(request), path: { bank_id: agentId }, query: { status, type, limit, offset, exclude_parents: excludeParents }, }); @@ -42,7 +42,7 @@ export async function DELETE( } const response = await sdk.cancelOperation({ - client: lowLevelClient, + client: createDataplaneClientForRequest(request), path: { bank_id: agentId, operation_id: operationId }, }); return respondWithSdk(response, "Failed to cancel operation", { request }); diff --git a/hindsight-control-plane/src/app/api/organizations/[id]/route.ts b/hindsight-control-plane/src/app/api/organizations/[id]/route.ts new file mode 100644 index 000000000..2e086a3c1 --- /dev/null +++ b/hindsight-control-plane/src/app/api/organizations/[id]/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; + +import { getCurrentOrgContext, jsonError, updateOrganizationName } from "@/lib/supabase-org/store"; + +export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const body = (await request.json()) as { name?: string }; + if (!body.name) return jsonError("name is required", 400); + const context = await getCurrentOrgContext(request); + const organization = await updateOrganizationName(context, id, body.name); + return NextResponse.json({ organization }, { status: 200 }); + } catch (error) { + return jsonError(error instanceof Error ? error.message : "Failed to update organization", 400); + } +} diff --git a/hindsight-control-plane/src/app/api/organizations/route.ts b/hindsight-control-plane/src/app/api/organizations/route.ts new file mode 100644 index 000000000..78e1dd539 --- /dev/null +++ b/hindsight-control-plane/src/app/api/organizations/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; + +import { getOrgCreationPolicy } from "@/lib/auth/provider"; +import { + createOrganization, + getAuthenticatedUser, + jsonError, + listOrganizationsForUser, +} from "@/lib/supabase-org/store"; + +export async function GET(request: Request) { + try { + const user = await getAuthenticatedUser(request); + return NextResponse.json( + { organizations: await listOrganizationsForUser(user.id) }, + { status: 200 } + ); + } catch (error) { + return jsonError(error instanceof Error ? error.message : "Failed to list organizations", 401); + } +} + +export async function POST(request: Request) { + try { + if (getOrgCreationPolicy() === "direct_signup_only") { + return jsonError("Organization creation is only allowed during direct signup", 403); + } + const body = (await request.json()) as { name?: string }; + if (!body.name) return jsonError("name is required", 400); + const user = await getAuthenticatedUser(request); + return NextResponse.json( + { organization: await createOrganization(user, body.name) }, + { status: 201 } + ); + } catch (error) { + return jsonError(error instanceof Error ? error.message : "Failed to create organization", 400); + } +} diff --git a/hindsight-control-plane/src/app/api/profile/[bankId]/route.ts b/hindsight-control-plane/src/app/api/profile/[bankId]/route.ts index b69910701..3a9404100 100644 --- a/hindsight-control-plane/src/app/api/profile/[bankId]/route.ts +++ b/hindsight-control-plane/src/app/api/profile/[bankId]/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { sdk, createDataplaneClientForRequest } from "@/lib/hindsight-client"; import { respondWithSdk } from "@/lib/sdk-response"; export async function GET( @@ -9,7 +9,7 @@ export async function GET( ) { const { bankId } = await params; const response = await sdk.getBankProfile({ - client: lowLevelClient, + client: createDataplaneClientForRequest(request), path: { bank_id: bankId }, }); return respondWithSdk(response, "Failed to fetch bank profile", { request }); @@ -34,7 +34,7 @@ export async function PUT( } const response = await sdk.createOrUpdateBank({ - client: lowLevelClient, + client: createDataplaneClientForRequest(request), path: { bank_id: bankId }, body: body, }); diff --git a/hindsight-control-plane/src/app/api/recall/route.ts b/hindsight-control-plane/src/app/api/recall/route.ts index 43126dd74..a534c3f65 100644 --- a/hindsight-control-plane/src/app/api/recall/route.ts +++ b/hindsight-control-plane/src/app/api/recall/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { lowLevelClient, sdk } from "@/lib/hindsight-client"; +import { createDataplaneClientForRequest, sdk } from "@/lib/hindsight-client"; export async function POST(request: NextRequest) { try { @@ -20,7 +20,7 @@ export async function POST(request: NextRequest) { } = body; const response = await sdk.recallMemories({ - client: lowLevelClient, + client: createDataplaneClientForRequest(request), path: { bank_id: bankId }, body: { query, diff --git a/hindsight-control-plane/src/app/api/reflect/route.ts b/hindsight-control-plane/src/app/api/reflect/route.ts index b6e3a091d..7447844d7 100644 --- a/hindsight-control-plane/src/app/api/reflect/route.ts +++ b/hindsight-control-plane/src/app/api/reflect/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { sdk, createDataplaneClientForRequest } from "@/lib/hindsight-client"; import { respondWithSdk } from "@/lib/sdk-response"; export async function POST(request: NextRequest) { @@ -55,7 +55,7 @@ export async function POST(request: NextRequest) { } const response = await sdk.reflect({ - client: lowLevelClient, + client: createDataplaneClientForRequest(request), path: { bank_id: bankId }, body: requestBody, }); diff --git a/hindsight-control-plane/src/app/api/stats/[agentId]/memories-timeseries/route.ts b/hindsight-control-plane/src/app/api/stats/[agentId]/memories-timeseries/route.ts index 531b87a7e..5a540f9d0 100644 --- a/hindsight-control-plane/src/app/api/stats/[agentId]/memories-timeseries/route.ts +++ b/hindsight-control-plane/src/app/api/stats/[agentId]/memories-timeseries/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { dataplaneBankUrl, getDataplaneHeadersForRequest } from "@/lib/hindsight-client"; export async function GET( request: NextRequest, @@ -14,7 +14,7 @@ export async function GET( agentId, `/stats/memories-timeseries?period=${encodeURIComponent(period)}&time_field=${encodeURIComponent(timeField)}` ); - const upstream = await fetch(url, { headers: getDataplaneHeaders() }); + const upstream = await fetch(url, { headers: getDataplaneHeadersForRequest(request) }); const body = await upstream.json(); return NextResponse.json(body, { status: upstream.status }); } catch (error) { diff --git a/hindsight-control-plane/src/app/api/stats/[agentId]/route.ts b/hindsight-control-plane/src/app/api/stats/[agentId]/route.ts index d5d7e011b..a5d66996c 100644 --- a/hindsight-control-plane/src/app/api/stats/[agentId]/route.ts +++ b/hindsight-control-plane/src/app/api/stats/[agentId]/route.ts @@ -1,5 +1,5 @@ import { NextRequest } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { sdk, createDataplaneClientForRequest } from "@/lib/hindsight-client"; import { respondWithSdk } from "@/lib/sdk-response"; export async function GET( @@ -8,7 +8,7 @@ export async function GET( ) { const { agentId } = await params; const response = await sdk.getAgentStats({ - client: lowLevelClient, + client: createDataplaneClientForRequest(request), path: { bank_id: agentId }, }); return respondWithSdk(response, "Failed to fetch stats", { request }); diff --git a/hindsight-control-plane/src/app/api/team/invites/[id]/accept/route.ts b/hindsight-control-plane/src/app/api/team/invites/[id]/accept/route.ts new file mode 100644 index 000000000..612a854f7 --- /dev/null +++ b/hindsight-control-plane/src/app/api/team/invites/[id]/accept/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; + +import { acceptInvite, jsonError } from "@/lib/supabase-org/store"; + +export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + await acceptInvite(request, id); + return NextResponse.json({ success: true }, { status: 200 }); + } catch (error) { + return jsonError(error instanceof Error ? error.message : "Failed to accept invite", 400); + } +} diff --git a/hindsight-control-plane/src/app/api/team/invites/[id]/route.ts b/hindsight-control-plane/src/app/api/team/invites/[id]/route.ts new file mode 100644 index 000000000..2e1a06d21 --- /dev/null +++ b/hindsight-control-plane/src/app/api/team/invites/[id]/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; + +import { getCurrentOrgContext, jsonError, revokeInvite } from "@/lib/supabase-org/store"; + +export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const context = await getCurrentOrgContext(request); + await revokeInvite(context, id); + return NextResponse.json({ success: true }, { status: 200 }); + } catch (error) { + return jsonError(error instanceof Error ? error.message : "Failed to revoke invite", 400); + } +} diff --git a/hindsight-control-plane/src/app/api/team/invites/route.ts b/hindsight-control-plane/src/app/api/team/invites/route.ts new file mode 100644 index 000000000..3958aaaf2 --- /dev/null +++ b/hindsight-control-plane/src/app/api/team/invites/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; + +import { + assertOrganizationRole, + createInvite, + getCurrentOrgContext, + jsonError, + listInvites, +} from "@/lib/supabase-org/store"; +import type { OrganizationRole } from "@/lib/supabase-org/store"; + +export async function GET(request: Request) { + try { + const context = await getCurrentOrgContext(request); + return NextResponse.json( + { invites: await listInvites(context.selectedOrgId) }, + { status: 200 } + ); + } catch (error) { + return jsonError(error instanceof Error ? error.message : "Failed to list invites", 400); + } +} + +export async function POST(request: Request) { + try { + const body = (await request.json()) as { email?: string; role?: OrganizationRole }; + if (!body.email) return jsonError("email is required", 400); + if (body.role) assertOrganizationRole(body.role); + const context = await getCurrentOrgContext(request); + const invite = await createInvite(context, body.email, body.role || "member"); + return NextResponse.json({ invite }, { status: 201 }); + } catch (error) { + return jsonError(error instanceof Error ? error.message : "Failed to create invite", 400); + } +} diff --git a/hindsight-control-plane/src/app/api/team/members/[userId]/route.ts b/hindsight-control-plane/src/app/api/team/members/[userId]/route.ts new file mode 100644 index 000000000..315ad8b9e --- /dev/null +++ b/hindsight-control-plane/src/app/api/team/members/[userId]/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; + +import { + assertOrganizationRole, + getCurrentOrgContext, + jsonError, + removeMember, + updateMemberRole, +} from "@/lib/supabase-org/store"; +import type { OrganizationRole } from "@/lib/supabase-org/store"; + +export async function PATCH(request: Request, { params }: { params: Promise<{ userId: string }> }) { + try { + const { userId } = await params; + const body = (await request.json()) as { role?: OrganizationRole }; + if (!body.role) return jsonError("role is required", 400); + assertOrganizationRole(body.role); + const context = await getCurrentOrgContext(request); + const member = await updateMemberRole(context, userId, body.role); + return NextResponse.json({ member }, { status: 200 }); + } catch (error) { + return jsonError(error instanceof Error ? error.message : "Failed to update member", 400); + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ userId: string }> } +) { + try { + const { userId } = await params; + const context = await getCurrentOrgContext(request); + await removeMember(context, userId); + return NextResponse.json({ success: true }, { status: 200 }); + } catch (error) { + return jsonError(error instanceof Error ? error.message : "Failed to remove member", 400); + } +} diff --git a/hindsight-control-plane/src/app/api/team/route.ts b/hindsight-control-plane/src/app/api/team/route.ts new file mode 100644 index 000000000..6aa18fe49 --- /dev/null +++ b/hindsight-control-plane/src/app/api/team/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; + +import { getCurrentOrgContext, jsonError, listMembers } from "@/lib/supabase-org/store"; + +export async function GET(request: Request) { + try { + const context = await getCurrentOrgContext(request); + return NextResponse.json( + { members: await listMembers(context.selectedOrgId) }, + { status: 200 } + ); + } catch (error) { + return jsonError(error instanceof Error ? error.message : "Failed to list team", 400); + } +} diff --git a/hindsight-control-plane/src/app/api/version/route.ts b/hindsight-control-plane/src/app/api/version/route.ts index 642f6f74e..1cd121f1d 100644 --- a/hindsight-control-plane/src/app/api/version/route.ts +++ b/hindsight-control-plane/src/app/api/version/route.ts @@ -1,11 +1,12 @@ import { NextResponse } from "next/server"; import { localizeApiErrorPayload } from "@/lib/i18n/api-errors"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { sdk, createDataplaneClientForRequest } from "@/lib/hindsight-client"; +import { getControlPlaneAuthProvider } from "@/lib/auth/provider"; export async function GET(request: Request) { try { const response = await sdk.getVersion({ - client: lowLevelClient, + client: createDataplaneClientForRequest(request), }); if (response.error) { @@ -20,8 +21,14 @@ export async function GET(request: Request) { } const data = response.data as Record; - const features = (data.features ?? {}) as Record; + const features = (data.features ?? {}) as Record; + const authProvider = getControlPlaneAuthProvider(); + const dataplaneProfile = features.authz_profile; features.access_key_auth = !!process.env.HINDSIGHT_CP_ACCESS_KEY; + features.auth_provider = authProvider; + features.profile_match = + authProvider !== "supabase_org" || + (dataplaneProfile === "supabase_org" && features.supabase_org_ready === true); data.features = features; return NextResponse.json(data, { status: 200 }); diff --git a/hindsight-control-plane/src/components/bank-selector.tsx b/hindsight-control-plane/src/components/bank-selector.tsx index e257e7d58..5b4194ab0 100644 --- a/hindsight-control-plane/src/components/bank-selector.tsx +++ b/hindsight-control-plane/src/components/bank-selector.tsx @@ -42,6 +42,7 @@ import { ChevronRight, LogOut, Copy, + Settings, } from "lucide-react"; import { toast } from "sonner"; import { useTheme } from "@/lib/theme-context"; @@ -658,7 +659,19 @@ function BankSelectorInner() { - {features?.access_key_auth && ( + {features?.auth_provider === "supabase_org" && ( + + )} + + {(features?.access_key_auth || features?.auth_provider === "supabase_org") && ( <>