diff --git a/.env.example b/.env.example index 7be4ee38f..c59357c7b 100644 --- a/.env.example +++ b/.env.example @@ -83,3 +83,13 @@ HINDSIGHT_API_LOG_LEVEL=info # Custom service name and environment (optional, defaults: hindsight-api, development) # HINDSIGHT_API_OTEL_SERVICE_NAME=hindsight-production # HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT=production + +# Control Plane Dashboard (Optional) +# Multi-tenant mode: the dashboard reads HINDSIGHT_API_TENANT_KEY_MAP automatically. +# HINDSIGHT_API_TENANT_KEY_MAP=key1:tenant_alpha;key2:tenant_beta +# +# Single-tenant mode (backwards-compatible, used when KEY_MAP is not set) +# HINDSIGHT_CP_DATAPLANE_API_KEY=your-api-key +# +# Dataplane API URL (default: http://localhost:8888) +# HINDSIGHT_CP_DATAPLANE_API_URL=http://localhost:8888 diff --git a/hindsight-api-slim/hindsight_api/extensions/builtin/bank_scoped_tenant.py b/hindsight-api-slim/hindsight_api/extensions/builtin/bank_scoped_tenant.py new file mode 100644 index 000000000..d61be79c2 --- /dev/null +++ b/hindsight-api-slim/hindsight_api/extensions/builtin/bank_scoped_tenant.py @@ -0,0 +1,226 @@ +""" +API Key Tenant Extension with Schema Isolation for Hindsight + +Maps API keys to isolated PostgreSQL schemas, providing database-level +memory isolation between tenants. Each key gets its own schema containing +independent banks, memories, and entities — no application-layer access +checks required. + +Why schema isolation instead of application-layer bank filtering? + The primary threat model is **prompt injection against AI agents**. + Agents execute tool calls (including Hindsight recall/retain) based on + conversation content. A prompt injection delivered via chat message, + email, or web search result can trick an agent into querying any bank + on the same Hindsight instance. + + Application-layer access control (checking bank_id in a validator + extension) is defense-in-depth but not a security boundary — it depends + on every code path calling the validator, and a single missed path or + engine bug grants cross-tenant access. + + Schema isolation is a security boundary. The API key determines the + PostgreSQL schema at authentication time, before any bank lookup or + memory query. Even if an agent is fully compromised by injection, its + queries are physically scoped to its schema. Banks from other schemas + don't exist in its view of the database. + +Configuration: + HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.bank_scoped_tenant:ApiKeySchemaTenantExtension + + # Semicolon-separated entries: api_key:schema_name + HINDSIGHT_API_TENANT_KEY_MAP=key1:tenant_alpha;key2:tenant_beta + + # Optional: prefix for schema names (default: none, uses schema name as-is) + HINDSIGHT_API_TENANT_SCHEMA_PREFIX=hs + + # Optional: disable auth for MCP endpoints + HINDSIGHT_API_TENANT_MCP_AUTH_DISABLED=true + +Example: + Two AI agent deployments sharing one Hindsight instance: + + HINDSIGHT_API_TENANT_KEY_MAP=abc123:team_alpha;xyz789:team_beta + + - Agent with key "abc123" → schema "team_alpha" (its own banks, memories) + - Agent with key "xyz789" → schema "team_beta" (its own banks, memories) + - A prompt-injected agent sending recall requests with the wrong bank name + gets "bank not found" — the bank doesn't exist in its schema + - Schemas are auto-created with full table migrations on first access + +License: MIT +""" + +from __future__ import annotations + +import logging +import re + +from hindsight_api.config import get_config +from hindsight_api.extensions.tenant import AuthenticationError, Tenant, TenantContext, TenantExtension +from hindsight_api.models import RequestContext + +logger = logging.getLogger(__name__) + +__all__ = ["ApiKeySchemaTenantExtension"] + +# Schema names must be valid Postgres identifiers +_SCHEMA_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$") + + +def _parse_key_map(raw: str) -> dict[str, str]: + """ + Parse a key map string into a dict of API key → schema name. + + Format: key1:schema1;key2:schema2 + + Returns: + Dict mapping API key strings to schema name strings. + + Raises: + ValueError: If the format is invalid or a schema name is not a valid + Postgres identifier. + """ + result: dict[str, str] = {} + if not raw or not raw.strip(): + return result + + for entry in raw.split(";"): + entry = entry.strip() + if not entry: + continue + if ":" not in entry: + raise ValueError( + f"Invalid key_map entry '{entry}'. " + f"Expected format: 'apikey:schema_name'. " + f"Full format: 'key1:schema1;key2:schema2'" + ) + key, schema = entry.split(":", 1) + key = key.strip() + schema = schema.strip() + if not key: + raise ValueError("Empty API key in key_map") + if not schema: + raise ValueError("Empty schema name for key in key_map") + if not _SCHEMA_RE.match(schema): + raise ValueError( + f"Invalid schema name '{schema}'. " + f"Must be a valid Postgres identifier " + f"(letters, digits, underscores, starting with a letter or underscore)." + ) + result[key] = schema + + return result + + +class ApiKeySchemaTenantExtension(TenantExtension): + """ + Tenant extension that maps API keys to isolated PostgreSQL schemas. + + Each API key resolves to a dedicated schema. All database operations + (bank creation, memory storage, recall, reflect) are scoped to that + schema. Schemas are auto-created with full table migrations on first + access. + + This provides database-level isolation — tenants cannot access each + other's data regardless of bank names, query parameters, or + application-layer bugs. + + Configuration: + HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.bank_scoped_tenant:ApiKeySchemaTenantExtension + HINDSIGHT_API_TENANT_KEY_MAP=key1:schema1;key2:schema2 + HINDSIGHT_API_TENANT_SCHEMA_PREFIX=hs (optional) + HINDSIGHT_API_TENANT_MCP_AUTH_DISABLED=true (optional) + """ + + def __init__(self, config: dict[str, str]) -> None: + super().__init__(config) + + raw_key_map = config.get("key_map", "") + self.schema_prefix = config.get("schema_prefix", "") + self.key_map = _parse_key_map(raw_key_map) + + if not self.key_map: + raise ValueError("HINDSIGHT_API_TENANT_KEY_MAP is required. Format: key1:schema1;key2:schema2") + + if self.schema_prefix and not _SCHEMA_RE.match(self.schema_prefix): + raise ValueError(f"Invalid schema_prefix '{self.schema_prefix}'. Must be a valid Postgres identifier.") + + self.mcp_auth_disabled = config.get("mcp_auth_disabled", "").lower() in ( + "true", + "1", + "yes", + ) + + # Track initialized schemas to avoid redundant migrations + self._initialized_schemas: set[str] = set() + + # Build full schema names (with optional prefix) + self._key_to_schema: dict[str, str] = {} + for key, schema in self.key_map.items(): + full_schema = f"{self.schema_prefix}_{schema}" if self.schema_prefix else schema + self._key_to_schema[key] = full_schema + + # Log configuration (without revealing full keys) + for key, schema in self._key_to_schema.items(): + masked = key[:4] + "..." + key[-4:] if len(key) > 12 else key[:4] + "..." + logger.info("Tenant key %s -> schema '%s'", masked, schema) + + async def authenticate(self, context: RequestContext) -> TenantContext: + """ + Authenticate API key and return tenant context with isolated schema. + + On first access for a schema, runs database migrations to create + all required tables. + + Args: + context: Request context containing the API key. + + Returns: + TenantContext with schema_name for database isolation. + + Raises: + AuthenticationError: If the API key is missing or not recognized. + """ + if not context.api_key: + raise AuthenticationError("Missing API key. Pass via Authorization: Bearer ") + + schema_name = self._key_to_schema.get(context.api_key) + if schema_name is None: + raise AuthenticationError("Invalid API key") + + # Initialize schema on first access (creates tables via migration) + if schema_name not in self._initialized_schemas: + await self._initialize_schema(schema_name) + + return TenantContext(schema_name=schema_name) + + async def list_tenants(self) -> list[Tenant]: + """Return all initialized tenant schemas for worker discovery.""" + return [Tenant(schema=schema) for schema in self._initialized_schemas] + + async def authenticate_mcp(self, context: RequestContext) -> TenantContext: + """ + Authenticate MCP requests. + + If mcp_auth_disabled is set, falls back to the default schema + from HINDSIGHT_API_DATABASE_SCHEMA. Otherwise delegates to + authenticate(). + + Note: Disabling MCP auth when using schema isolation means MCP + requests hit the default schema, not a tenant schema. This is + appropriate for admin MCP clients but not for tenant-facing ones. + """ + if self.mcp_auth_disabled: + return TenantContext(schema_name=get_config().database_schema) + return await self.authenticate(context) + + async def _initialize_schema(self, schema_name: str) -> None: + """Run migrations for a new tenant schema and cache the result.""" + logger.info("Initializing schema: %s", schema_name) + try: + await self.context.run_migration(schema_name) + self._initialized_schemas.add(schema_name) + logger.info("Schema ready: %s", schema_name) + except Exception as e: + logger.error("Schema initialization failed for %s: %s", schema_name, e) + raise AuthenticationError(f"Failed to initialize tenant: {e!s}") diff --git a/hindsight-api-slim/tests/test_bank_scoped.py b/hindsight-api-slim/tests/test_bank_scoped.py new file mode 100644 index 000000000..44a068fb2 --- /dev/null +++ b/hindsight-api-slim/tests/test_bank_scoped.py @@ -0,0 +1,182 @@ +"""Tests for API key schema tenant extension.""" + +import pytest + +from hindsight_api.extensions.builtin.bank_scoped_tenant import ( + ApiKeySchemaTenantExtension, + _parse_key_map, +) +from hindsight_api.extensions.tenant import AuthenticationError +from hindsight_api.models import RequestContext + +# ========================================================================= +# _parse_key_map tests +# ========================================================================= + + +class TestParseKeyMap: + """Tests for the key map parser.""" + + def test_single_entry(self): + result = _parse_key_map("key1:schema_a") + assert result == {"key1": "schema_a"} + + def test_multiple_entries(self): + result = _parse_key_map("key1:schema_a;key2:schema_b") + assert result == {"key1": "schema_a", "key2": "schema_b"} + + def test_whitespace_handling(self): + result = _parse_key_map(" key1 : schema_a ; key2 : schema_b ") + assert result == {"key1": "schema_a", "key2": "schema_b"} + + def test_invalid_no_colon(self): + with pytest.raises(ValueError, match="Expected format"): + _parse_key_map("key1-schema_a") + + def test_invalid_empty_key(self): + with pytest.raises(ValueError, match="Empty API key"): + _parse_key_map(":schema_a") + + def test_invalid_empty_schema(self): + with pytest.raises(ValueError, match="Empty schema name"): + _parse_key_map("key1:") + + def test_invalid_schema_not_postgres_identifier(self): + with pytest.raises(ValueError, match="valid Postgres identifier"): + _parse_key_map("key1:bad-schema") + + +# ========================================================================= +# ApiKeySchemaTenantExtension tests +# ========================================================================= + + +class TestApiKeySchemaTenantExtension: + """Tests for the schema-isolating tenant extension.""" + + def _make_ext(self, key_map: str, **kwargs) -> ApiKeySchemaTenantExtension: + config = {"key_map": key_map, **kwargs} + return ApiKeySchemaTenantExtension(config) + + def test_init_requires_key_map(self): + with pytest.raises(ValueError, match="HINDSIGHT_API_TENANT_KEY_MAP is required"): + ApiKeySchemaTenantExtension({}) + + def test_init_invalid_schema_prefix(self): + with pytest.raises(ValueError, match="Invalid schema_prefix"): + ApiKeySchemaTenantExtension( + { + "key_map": "key1:schema1", + "schema_prefix": "bad-prefix", + } + ) + + def test_schema_names_without_prefix(self): + ext = self._make_ext("key1:team_alpha;key2:team_beta") + assert ext._key_to_schema["key1"] == "team_alpha" + assert ext._key_to_schema["key2"] == "team_beta" + + def test_schema_names_with_prefix(self): + ext = self._make_ext("key1:alpha;key2:beta", schema_prefix="hs") + assert ext._key_to_schema["key1"] == "hs_alpha" + assert ext._key_to_schema["key2"] == "hs_beta" + + @pytest.mark.asyncio + async def test_authenticate_valid_key(self): + ext = self._make_ext("secret123:tenant_a") + ext._initialized_schemas.add("tenant_a") + ctx = RequestContext(api_key="secret123") + result = await ext.authenticate(ctx) + assert result.schema_name == "tenant_a" + + @pytest.mark.asyncio + async def test_authenticate_missing_key(self): + ext = self._make_ext("secret:tenant_a") + with pytest.raises(AuthenticationError, match="Missing API key"): + await ext.authenticate(RequestContext(api_key=None)) + + @pytest.mark.asyncio + async def test_authenticate_wrong_key(self): + ext = self._make_ext("secret:tenant_a") + with pytest.raises(AuthenticationError, match="Invalid API key"): + await ext.authenticate(RequestContext(api_key="wrong-key")) + + @pytest.mark.asyncio + async def test_different_keys_different_schemas(self): + """Core isolation test: two keys resolve to different schemas.""" + ext = self._make_ext("key_a:schema_a;key_b:schema_b") + ext._initialized_schemas.update(["schema_a", "schema_b"]) + + result_a = await ext.authenticate(RequestContext(api_key="key_a")) + result_b = await ext.authenticate(RequestContext(api_key="key_b")) + + assert result_a.schema_name == "schema_a" + assert result_b.schema_name == "schema_b" + assert result_a.schema_name != result_b.schema_name + + @pytest.mark.asyncio + async def test_mcp_auth_disabled_falls_back_to_default(self): + ext = self._make_ext("secret:tenant_a", mcp_auth_disabled="true") + result = await ext.authenticate_mcp(RequestContext(api_key=None)) + assert result.schema_name is not None + + @pytest.mark.asyncio + async def test_mcp_auth_enabled_rejects_missing_key(self): + ext = self._make_ext("secret:tenant_a") + with pytest.raises(AuthenticationError): + await ext.authenticate_mcp(RequestContext(api_key=None)) + + +# ========================================================================= +# Prompt injection defense tests +# ========================================================================= + + +class TestPromptInjectionDefense: + """ + Validates the core security property: an API key can only access its + own schema, regardless of what the agent requests. This is the defense + against prompt injection where a compromised agent tries to access + another tenant's memories. + """ + + @pytest.mark.asyncio + async def test_attacker_key_cannot_reach_victim_schema(self): + """The schema is determined solely by the API key, not the request.""" + ext = ApiKeySchemaTenantExtension( + { + "key_map": "victim_key:victim_schema;attacker_key:attacker_schema", + } + ) + ext._initialized_schemas.update(["victim_schema", "attacker_schema"]) + + result = await ext.authenticate(RequestContext(api_key="attacker_key")) + assert result.schema_name == "attacker_schema" + assert result.schema_name != "victim_schema" + + @pytest.mark.asyncio + async def test_unknown_key_rejected_not_defaulted(self): + """Unknown keys must be rejected, never mapped to a default schema.""" + ext = ApiKeySchemaTenantExtension( + { + "key_map": "real_key:real_schema", + } + ) + + with pytest.raises(AuthenticationError, match="Invalid API key"): + await ext.authenticate(RequestContext(api_key="guessed_key")) + + @pytest.mark.asyncio + async def test_empty_key_rejected(self): + """Empty and None keys must be rejected.""" + ext = ApiKeySchemaTenantExtension( + { + "key_map": "real_key:real_schema", + } + ) + + with pytest.raises(AuthenticationError, match="Missing API key"): + await ext.authenticate(RequestContext(api_key=None)) + + with pytest.raises(AuthenticationError, match="Missing API key"): + await ext.authenticate(RequestContext(api_key="")) 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 c6d040ea8..9408dba56 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,22 +1,24 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; -export async function GET(request: Request, { params }: { params: Promise<{ bankId: string }> }) { +export async function GET(request: NextRequest, { params }: { params: Promise<{ bankId: string }> }) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); const { bankId } = await params; if (!bankId) { return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); } - // Forward query params + // Forward query params (strip tenant — it's a CP-internal routing param, not a dataplane param) const { searchParams } = new URL(request.url); + searchParams.delete("tenant"); const query = searchParams.toString(); const url = dataplaneBankUrl(bankId, `/audit-logs${query ? `?${query}` : ""}`); const response = await fetch(url, { method: "GET", - headers: getDataplaneHeaders(), + headers: getDataplaneHeaders(tenant), }); 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 3f10afa8a..24083aa1e 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,20 +1,23 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; -export async function GET(request: Request, { params }: { params: Promise<{ bankId: string }> }) { +export async function GET(request: NextRequest, { params }: { params: Promise<{ bankId: string }> }) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); const { bankId } = await params; if (!bankId) { return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); } + // Strip tenant — it's a CP-internal routing param, not a dataplane param const { searchParams } = new URL(request.url); + searchParams.delete("tenant"); const query = searchParams.toString(); const url = dataplaneBankUrl(bankId, `/audit-logs/stats${query ? `?${query}` : ""}`); const response = await fetch(url, { method: "GET", - headers: getDataplaneHeaders(), + headers: getDataplaneHeaders(tenant), }); 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 20096dfdd..4a53e37a9 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,11 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; -import { lowLevelClient, sdk } from "@/lib/hindsight-client"; +import { sdk, getClientForTenant } from "@/lib/hindsight-client"; export async function GET( request: NextRequest, { params }: { params: Promise<{ bankId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { lowLevelClient } = getClientForTenant(tenant); const { bankId } = await params; const response = await sdk.getBankConfig({ @@ -30,6 +32,8 @@ export async function PATCH( { params }: { params: Promise<{ bankId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { lowLevelClient } = getClientForTenant(tenant); const { bankId } = await params; const body = await request.json(); const { updates } = body; @@ -57,6 +61,8 @@ export async function DELETE( { params }: { params: Promise<{ bankId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { lowLevelClient } = getClientForTenant(tenant); const { bankId } = await params; const response = await sdk.resetBankConfig({ 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 2d6c9e33a..98e0d0c5c 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,8 +1,10 @@ -import { NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { NextRequest, NextResponse } from "next/server"; +import { sdk, getClientForTenant } from "@/lib/hindsight-client"; -export async function POST(request: Request, { params }: { params: Promise<{ bankId: string }> }) { +export async function POST(request: NextRequest, { params }: { params: Promise<{ bankId: string }> }) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { lowLevelClient } = getClientForTenant(tenant); const { bankId } = await params; if (!bankId) { 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 42eabf04a..1ea96c936 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,8 +1,10 @@ -import { NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { NextRequest, NextResponse } from "next/server"; +import { sdk, getClientForTenant } from "@/lib/hindsight-client"; -export async function POST(request: Request, { params }: { params: Promise<{ bankId: string }> }) { +export async function POST(request: NextRequest, { params }: { params: Promise<{ bankId: string }> }) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { lowLevelClient } = getClientForTenant(tenant); const { bankId } = await params; if (!bankId) { 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 aa6c00bdd..6f8b9b1d1 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,11 +1,12 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; export async function GET( - request: Request, + request: NextRequest, { params }: { params: Promise<{ bankId: string; directiveId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); const { bankId, directiveId } = await params; if (!bankId || !directiveId) { @@ -14,7 +15,7 @@ export async function GET( const response = await fetch( dataplaneBankUrl(bankId, `/directives/${encodeURIComponent(directiveId)}`), - { method: "GET", headers: getDataplaneHeaders() } + { method: "GET", headers: getDataplaneHeaders(tenant) } ); if (!response.ok) { @@ -32,10 +33,11 @@ export async function GET( } export async function PATCH( - request: Request, + request: NextRequest, { params }: { params: Promise<{ bankId: string; directiveId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); const { bankId, directiveId } = await params; if (!bankId || !directiveId) { @@ -48,7 +50,7 @@ export async function PATCH( dataplaneBankUrl(bankId, `/directives/${encodeURIComponent(directiveId)}`), { method: "PATCH", - headers: getDataplaneHeaders({ "Content-Type": "application/json" }), + headers: getDataplaneHeaders(tenant, { "Content-Type": "application/json" }), body: JSON.stringify(body), } ); @@ -71,10 +73,11 @@ export async function PATCH( } export async function DELETE( - request: Request, + request: NextRequest, { params }: { params: Promise<{ bankId: string; directiveId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); const { bankId, directiveId } = await params; if (!bankId || !directiveId) { @@ -83,7 +86,7 @@ export async function DELETE( const response = await fetch( dataplaneBankUrl(bankId, `/directives/${encodeURIComponent(directiveId)}`), - { method: "DELETE", headers: getDataplaneHeaders() } + { method: "DELETE", headers: getDataplaneHeaders(tenant) } ); 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 1d436cc6f..3a7352d7b 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,8 +1,9 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; -export async function GET(request: Request, { params }: { params: Promise<{ bankId: string }> }) { +export async function GET(request: NextRequest, { params }: { params: Promise<{ bankId: string }> }) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); const { bankId } = await params; const { searchParams } = new URL(request.url); const tags = searchParams.getAll("tags"); @@ -24,7 +25,7 @@ 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: getDataplaneHeaders(tenant) }); if (!response.ok) { const errorText = await response.text(); @@ -40,8 +41,9 @@ export async function GET(request: Request, { params }: { params: Promise<{ bank } } -export async function POST(request: Request, { params }: { params: Promise<{ bankId: string }> }) { +export async function POST(request: NextRequest, { params }: { params: Promise<{ bankId: string }> }) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); const { bankId } = await params; if (!bankId) { @@ -52,7 +54,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: getDataplaneHeaders(tenant, { "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 a8ef5a998..e9916833c 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 @@ -6,11 +6,12 @@ export async function GET( { params }: { params: Promise<{ bankId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); const { bankId } = await params; const url = dataplaneBankUrl(bankId, "/export"); const response = await fetch(url, { - headers: getDataplaneHeaders(), + headers: getDataplaneHeaders(tenant), }); const data = await response.json(); 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 a452bb162..b85a035bd 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 @@ -6,6 +6,7 @@ export async function POST( { params }: { params: Promise<{ bankId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); const { bankId } = await params; const body = await request.json(); const dryRun = request.nextUrl.searchParams.get("dry_run") === "true"; @@ -14,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: getDataplaneHeaders(tenant, { "Content-Type": "application/json" }), body: JSON.stringify(body), }); 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 3b5d687ce..38808ec00 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,11 +1,12 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; export async function GET( - _request: Request, + request: NextRequest, { params }: { params: Promise<{ bankId: string; mentalModelId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); const { bankId, mentalModelId } = await params; if (!bankId || !mentalModelId) { @@ -17,7 +18,7 @@ export async function GET( const response = await fetch( dataplaneBankUrl(bankId, `/mental-models/${encodeURIComponent(mentalModelId)}/history`), - { method: "GET", headers: getDataplaneHeaders() } + { method: "GET", headers: getDataplaneHeaders(tenant) } ); 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 8f4d325c0..daf9b31e0 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,11 +1,12 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; export async function POST( - request: Request, + request: NextRequest, { params }: { params: Promise<{ bankId: string; mentalModelId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); const { bankId, mentalModelId } = await params; if (!bankId || !mentalModelId) { @@ -17,7 +18,7 @@ export async function POST( const response = await fetch( dataplaneBankUrl(bankId, `/mental-models/${encodeURIComponent(mentalModelId)}/refresh`), - { method: "POST", headers: getDataplaneHeaders() } + { method: "POST", headers: getDataplaneHeaders(tenant) } ); 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 037f67dac..8d54d6e4b 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,11 +1,12 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; export async function GET( - request: Request, + request: NextRequest, { params }: { params: Promise<{ bankId: string; mentalModelId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); const { bankId, mentalModelId } = await params; if (!bankId || !mentalModelId) { @@ -17,7 +18,7 @@ export async function GET( const response = await fetch( dataplaneBankUrl(bankId, `/mental-models/${encodeURIComponent(mentalModelId)}`), - { method: "GET", headers: getDataplaneHeaders() } + { method: "GET", headers: getDataplaneHeaders(tenant) } ); if (!response.ok) { @@ -38,10 +39,11 @@ export async function GET( } export async function PATCH( - request: Request, + request: NextRequest, { params }: { params: Promise<{ bankId: string; mentalModelId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); const { bankId, mentalModelId } = await params; if (!bankId || !mentalModelId) { @@ -57,7 +59,7 @@ export async function PATCH( dataplaneBankUrl(bankId, `/mental-models/${encodeURIComponent(mentalModelId)}`), { method: "PATCH", - headers: getDataplaneHeaders({ "Content-Type": "application/json" }), + headers: getDataplaneHeaders(tenant, { "Content-Type": "application/json" }), body: JSON.stringify(body), } ); @@ -80,10 +82,11 @@ export async function PATCH( } export async function DELETE( - request: Request, + request: NextRequest, { params }: { params: Promise<{ bankId: string; mentalModelId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); const { bankId, mentalModelId } = await params; if (!bankId || !mentalModelId) { @@ -95,7 +98,7 @@ export async function DELETE( const response = await fetch( dataplaneBankUrl(bankId, `/mental-models/${encodeURIComponent(mentalModelId)}`), - { method: "DELETE", headers: getDataplaneHeaders() } + { method: "DELETE", headers: getDataplaneHeaders(tenant) } ); 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 5917452fc..522dd2911 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,8 +1,9 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; -export async function GET(request: Request, { params }: { params: Promise<{ bankId: string }> }) { +export async function GET(request: NextRequest, { params }: { params: Promise<{ bankId: string }> }) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); const { bankId } = await params; const { searchParams } = new URL(request.url); const tags = searchParams.getAll("tags"); @@ -24,7 +25,7 @@ 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: getDataplaneHeaders(tenant) }); if (!response.ok) { const errorText = await response.text(); @@ -43,8 +44,9 @@ export async function GET(request: Request, { params }: { params: Promise<{ bank } } -export async function POST(request: Request, { params }: { params: Promise<{ bankId: string }> }) { +export async function POST(request: NextRequest, { params }: { params: Promise<{ bankId: string }> }) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); const { bankId } = await params; if (!bankId) { @@ -55,7 +57,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: getDataplaneHeaders(tenant, { "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 f8b1ae66f..e617c607d 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,11 +1,12 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; export async function GET( - request: Request, + request: NextRequest, { params }: { params: Promise<{ bankId: string; modelId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); const { bankId, modelId } = await params; if (!bankId || !modelId) { @@ -14,7 +15,7 @@ export async function GET( const response = await fetch( dataplaneBankUrl(bankId, `/memories/${encodeURIComponent(modelId)}`), - { method: "GET", headers: getDataplaneHeaders() } + { method: "GET", headers: getDataplaneHeaders(tenant) } ); 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 86bc80a44..af9fddb57 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,8 +1,10 @@ -import { NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { NextRequest, NextResponse } from "next/server"; +import { sdk, getClientForTenant } from "@/lib/hindsight-client"; -export async function GET(request: Request, { params }: { params: Promise<{ bankId: string }> }) { +export async function GET(request: NextRequest, { params }: { params: Promise<{ bankId: string }> }) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { lowLevelClient } = getClientForTenant(tenant); const { bankId } = await params; if (!bankId) { @@ -46,10 +48,12 @@ export async function GET(request: Request, { params }: { params: Promise<{ bank } export async function DELETE( - request: Request, + request: NextRequest, { params }: { params: Promise<{ bankId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { lowLevelClient } = getClientForTenant(tenant); const { bankId } = await params; if (!bankId) { 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 dbfa16d75..8c31037f9 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,11 +1,13 @@ -import { NextResponse } from "next/server"; -import { sdk, lowLevelClient, dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { NextRequest, NextResponse } from "next/server"; +import { sdk, getClientForTenant, dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; export async function GET( - request: Request, + request: NextRequest, { params }: { params: Promise<{ bankId: string; operationId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { lowLevelClient } = getClientForTenant(tenant); const { bankId, operationId } = await params; if (!bankId) { @@ -38,10 +40,11 @@ export async function GET( } export async function POST( - request: Request, + request: NextRequest, { params }: { params: Promise<{ bankId: string; operationId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); const { bankId, operationId } = await params; if (!bankId) { @@ -55,7 +58,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: getDataplaneHeaders(tenant, { "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 001716838..76408c3ff 100644 --- a/hindsight-control-plane/src/app/api/banks/[bankId]/route.ts +++ b/hindsight-control-plane/src/app/api/banks/[bankId]/route.ts @@ -1,8 +1,10 @@ -import { NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { NextRequest, NextResponse } from "next/server"; +import { sdk, getClientForTenant } from "@/lib/hindsight-client"; -export async function PUT(request: Request, { params }: { params: Promise<{ bankId: string }> }) { +export async function PUT(request: NextRequest, { params }: { params: Promise<{ bankId: string }> }) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { lowLevelClient } = getClientForTenant(tenant); const { bankId } = await params; const body = await request.json(); @@ -32,8 +34,10 @@ export async function PUT(request: Request, { params }: { params: Promise<{ bank } } -export async function PATCH(request: Request, { params }: { params: Promise<{ bankId: string }> }) { +export async function PATCH(request: NextRequest, { params }: { params: Promise<{ bankId: string }> }) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { lowLevelClient } = getClientForTenant(tenant); const { bankId } = await params; const body = await request.json(); @@ -64,10 +68,12 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ ba } export async function DELETE( - request: Request, + request: NextRequest, { params }: { params: Promise<{ bankId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { lowLevelClient } = getClientForTenant(tenant); const { bankId } = await params; if (!bankId) { 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 a213c02f3..3c5f53ab7 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,10 +1,11 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; export async function GET( - request: Request, + request: NextRequest, { params }: { params: Promise<{ bankId: string; webhookId: string }> } ) { + const tenant = request.nextUrl.searchParams.get("tenant"); const { bankId, webhookId } = await params; const { searchParams } = new URL(request.url); const limit = searchParams.get("limit") || "50"; @@ -14,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: getDataplaneHeaders(tenant, { "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 5aa4aac9a..50fbfbfbe 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,15 +1,16 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; export async function PATCH( - request: Request, + request: NextRequest, { params }: { params: Promise<{ bankId: string; webhookId: string }> } ) { + const tenant = request.nextUrl.searchParams.get("tenant"); const { bankId, webhookId } = await params; const body = await request.json(); const res = await fetch(dataplaneBankUrl(bankId, `/webhooks/${encodeURIComponent(webhookId)}`), { method: "PATCH", - headers: getDataplaneHeaders({ "Content-Type": "application/json" }), + headers: getDataplaneHeaders(tenant, { "Content-Type": "application/json" }), body: JSON.stringify(body), }); const data = await res.json(); @@ -18,13 +19,14 @@ export async function PATCH( } export async function DELETE( - request: Request, + request: NextRequest, { params }: { params: Promise<{ bankId: string; webhookId: string }> } ) { + const tenant = request.nextUrl.searchParams.get("tenant"); const { bankId, webhookId } = await params; const res = await fetch(dataplaneBankUrl(bankId, `/webhooks/${encodeURIComponent(webhookId)}`), { method: "DELETE", - headers: getDataplaneHeaders({ "Content-Type": "application/json" }), + headers: getDataplaneHeaders(tenant, { "Content-Type": "application/json" }), }); const data = await res.json(); if (!res.ok) return NextResponse.json({ error: data.detail || "Failed" }, { status: res.status }); 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 28d1955f6..846c0bec5 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,22 +1,24 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; -export async function GET(request: Request, { params }: { params: Promise<{ bankId: string }> }) { +export async function GET(request: NextRequest, { params }: { params: Promise<{ bankId: string }> }) { + const tenant = request.nextUrl.searchParams.get("tenant"); const { bankId } = await params; const res = await fetch(dataplaneBankUrl(bankId, "/webhooks"), { - headers: getDataplaneHeaders({ "Content-Type": "application/json" }), + headers: getDataplaneHeaders(tenant, { "Content-Type": "application/json" }), }); const data = await res.json(); if (!res.ok) return NextResponse.json({ error: data.detail || "Failed" }, { status: res.status }); return NextResponse.json(data); } -export async function POST(request: Request, { params }: { params: Promise<{ bankId: string }> }) { +export async function POST(request: NextRequest, { params }: { params: Promise<{ bankId: string }> }) { + const tenant = request.nextUrl.searchParams.get("tenant"); const { bankId } = await params; const body = await request.json(); const res = await fetch(dataplaneBankUrl(bankId, "/webhooks"), { method: "POST", - headers: getDataplaneHeaders({ "Content-Type": "application/json" }), + headers: getDataplaneHeaders(tenant, { "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 38d904d5d..f13ff1dc9 100644 --- a/hindsight-control-plane/src/app/api/banks/route.ts +++ b/hindsight-control-plane/src/app/api/banks/route.ts @@ -1,8 +1,10 @@ -import { NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { NextRequest, NextResponse } from "next/server"; +import { sdk, getClientForTenant } from "@/lib/hindsight-client"; -export async function GET() { +export async function GET(request: NextRequest) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { lowLevelClient } = getClientForTenant(tenant); const response = await sdk.listBanks({ client: lowLevelClient }); // Check if the response has an error or no data @@ -18,8 +20,10 @@ export async function GET() { } } -export async function POST(request: Request) { +export async function POST(request: NextRequest) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { lowLevelClient } = getClientForTenant(tenant); const body = await request.json(); const { 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 592625d64..d61fca28e 100644 --- a/hindsight-control-plane/src/app/api/chunks/[chunkId]/route.ts +++ b/hindsight-control-plane/src/app/api/chunks/[chunkId]/route.ts @@ -1,11 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { sdk, getClientForTenant } from "@/lib/hindsight-client"; export async function GET( request: NextRequest, { params }: { params: Promise<{ chunkId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { lowLevelClient } = getClientForTenant(tenant); const { chunkId } = await params; const response = await sdk.getChunk({ 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 23089589e..587ad148d 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,11 +1,12 @@ import { NextRequest, NextResponse } from "next/server"; -import { DATAPLANE_URL, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; export async function GET( request: NextRequest, { params }: { params: Promise<{ documentId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); const { documentId } = await params; const searchParams = request.nextUrl.searchParams; const bankId = searchParams.get("bank_id"); @@ -18,9 +19,12 @@ export async function GET( const offset = searchParams.get("offset") || "0"; const response = await fetch( - `${DATAPLANE_URL}/v1/default/banks/${bankId}/documents/${documentId}/chunks?limit=${limit}&offset=${offset}`, + dataplaneBankUrl( + bankId, + `/documents/${encodeURIComponent(documentId)}/chunks?limit=${limit}&offset=${offset}` + ), { - headers: getDataplaneHeaders(), + headers: getDataplaneHeaders(tenant), } ); 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 9f98c90e6..346577635 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,11 +1,12 @@ import { NextRequest, NextResponse } from "next/server"; -import { DATAPLANE_URL, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; export async function POST( request: NextRequest, { params }: { params: Promise<{ documentId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); const { documentId } = await params; const searchParams = request.nextUrl.searchParams; const bankId = searchParams.get("bank_id"); @@ -15,10 +16,10 @@ export async function POST( } const response = await fetch( - `${DATAPLANE_URL}/v1/default/banks/${bankId}/documents/${documentId}/reprocess`, + dataplaneBankUrl(bankId, `/documents/${encodeURIComponent(documentId)}/reprocess`), { method: "POST", - headers: getDataplaneHeaders({ "Content-Type": "application/json" }), + headers: getDataplaneHeaders(tenant, { "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 8d091a3d4..aedaa082b 100644 --- a/hindsight-control-plane/src/app/api/documents/[documentId]/route.ts +++ b/hindsight-control-plane/src/app/api/documents/[documentId]/route.ts @@ -1,11 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; -import { sdk, lowLevelClient, dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; +import { sdk, getClientForTenant, dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; export async function GET( request: NextRequest, { params }: { params: Promise<{ documentId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { lowLevelClient } = getClientForTenant(tenant); const { documentId } = await params; const searchParams = request.nextUrl.searchParams; const bankId = searchParams.get("bank_id"); @@ -31,6 +33,7 @@ export async function PATCH( { params }: { params: Promise<{ documentId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); const { documentId } = await params; const searchParams = request.nextUrl.searchParams; const bankId = searchParams.get("bank_id"); @@ -44,7 +47,7 @@ export async function PATCH( dataplaneBankUrl(bankId, `/documents/${encodeURIComponent(documentId)}`), { method: "PATCH", - headers: getDataplaneHeaders({ "Content-Type": "application/json" }), + headers: getDataplaneHeaders(tenant, { "Content-Type": "application/json" }), body: JSON.stringify(body), } ); @@ -67,6 +70,8 @@ export async function DELETE( { params }: { params: Promise<{ documentId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { lowLevelClient } = getClientForTenant(tenant); const { documentId } = await params; const searchParams = request.nextUrl.searchParams; const bankId = searchParams.get("bank_id"); diff --git a/hindsight-control-plane/src/app/api/documents/route.ts b/hindsight-control-plane/src/app/api/documents/route.ts index 3f7481ba6..a2091fc71 100644 --- a/hindsight-control-plane/src/app/api/documents/route.ts +++ b/hindsight-control-plane/src/app/api/documents/route.ts @@ -1,8 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { sdk, getClientForTenant } from "@/lib/hindsight-client"; export async function GET(request: NextRequest) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { lowLevelClient } = getClientForTenant(tenant); const searchParams = request.nextUrl.searchParams; const bankId = searchParams.get("bank_id"); 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 134b1ab2f..298d3b236 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,11 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { sdk, getClientForTenant } from "@/lib/hindsight-client"; export async function POST( request: NextRequest, { params }: { params: Promise<{ entityId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { lowLevelClient } = getClientForTenant(tenant); const { entityId } = await params; const searchParams = request.nextUrl.searchParams; const bankId = searchParams.get("bank_id"); 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 c674fafad..544c86528 100644 --- a/hindsight-control-plane/src/app/api/entities/[entityId]/route.ts +++ b/hindsight-control-plane/src/app/api/entities/[entityId]/route.ts @@ -1,11 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { sdk, getClientForTenant } from "@/lib/hindsight-client"; export async function GET( request: NextRequest, { params }: { params: Promise<{ entityId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { lowLevelClient } = getClientForTenant(tenant); const { entityId } = await params; const searchParams = request.nextUrl.searchParams; const bankId = searchParams.get("bank_id"); 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 cb6dd9d5b..efc237a53 100644 --- a/hindsight-control-plane/src/app/api/entities/graph/route.ts +++ b/hindsight-control-plane/src/app/api/entities/graph/route.ts @@ -1,9 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { sdk, getClientForTenant } from "@/lib/hindsight-client"; export async function GET(request: NextRequest) { try { const searchParams = request.nextUrl.searchParams; + const tenant = searchParams.get("tenant"); + const { lowLevelClient } = getClientForTenant(tenant); const bankId = searchParams.get("bank_id"); if (!bankId) { diff --git a/hindsight-control-plane/src/app/api/entities/route.ts b/hindsight-control-plane/src/app/api/entities/route.ts index a54628e4f..627f08ee4 100644 --- a/hindsight-control-plane/src/app/api/entities/route.ts +++ b/hindsight-control-plane/src/app/api/entities/route.ts @@ -1,8 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { sdk, getClientForTenant } from "@/lib/hindsight-client"; export async function GET(request: NextRequest) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { lowLevelClient } = getClientForTenant(tenant); const searchParams = request.nextUrl.searchParams; const bankId = searchParams.get("bank_id"); 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 528019ce1..d29a96721 100644 --- a/hindsight-control-plane/src/app/api/files/retain/route.ts +++ b/hindsight-control-plane/src/app/api/files/retain/route.ts @@ -3,6 +3,7 @@ import { dataplaneBankUrl, getDataplaneHeaders } from "@/lib/hindsight-client"; export async function POST(request: NextRequest) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); // Clone the form data to read bank_id without consuming it const formData = await request.formData(); @@ -25,7 +26,7 @@ export async function POST(request: NextRequest) { // Forward the form data to the dataplane const response = await fetch(url, { method: "POST", - headers: getDataplaneHeaders(), + headers: getDataplaneHeaders(tenant), 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 0abb80e02..3b511c170 100644 --- a/hindsight-control-plane/src/app/api/graph/route.ts +++ b/hindsight-control-plane/src/app/api/graph/route.ts @@ -3,6 +3,7 @@ import { DATAPLANE_URL, getDataplaneHeaders } from "@/lib/hindsight-client"; export async function GET(request: NextRequest) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); const searchParams = request.nextUrl.searchParams; const bankId = searchParams.get("bank_id") || searchParams.get("agent_id"); @@ -27,7 +28,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: getDataplaneHeaders(tenant), }); 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..1c514deac 100644 --- a/hindsight-control-plane/src/app/api/health/route.ts +++ b/hindsight-control-plane/src/app/api/health/route.ts @@ -1,10 +1,11 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { createClient, createConfig, sdk } from "@vectorize-io/hindsight-client"; import { getDataplaneHeaders } from "@/lib/hindsight-client"; const HEALTH_CHECK_TIMEOUT_MS = 3000; -export async function GET() { +export async function GET(request: NextRequest) { + const tenant = request.nextUrl.searchParams.get("tenant"); const status: { status: string; service: string; @@ -28,7 +29,7 @@ export async function GET() { createConfig({ baseUrl: dataplaneUrl, signal: controller.signal, - headers: getDataplaneHeaders(), + headers: getDataplaneHeaders(tenant), }) ); diff --git a/hindsight-control-plane/src/app/api/list/route.ts b/hindsight-control-plane/src/app/api/list/route.ts index d1b6a6ea8..edf169a97 100644 --- a/hindsight-control-plane/src/app/api/list/route.ts +++ b/hindsight-control-plane/src/app/api/list/route.ts @@ -1,8 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; -import { hindsightClient, sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { getClientForTenant } from "@/lib/hindsight-client"; export async function GET(request: NextRequest) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { hindsightClient } = getClientForTenant(tenant); const searchParams = request.nextUrl.searchParams; const bankId = searchParams.get("bank_id") || searchParams.get("agent_id"); 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 e98241dc9..d074c431b 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 @@ -6,6 +6,7 @@ export async function GET( { params }: { params: Promise<{ memoryId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); const { memoryId } = await params; const searchParams = request.nextUrl.searchParams; const bankId = searchParams.get("bank_id"); @@ -18,7 +19,7 @@ export async function GET( dataplaneBankUrl(bankId, `/memories/${encodeURIComponent(memoryId)}/history`), { method: "GET", - headers: getDataplaneHeaders({ "Content-Type": "application/json" }), + headers: getDataplaneHeaders(tenant, { "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 294a32638..16b3423e6 100644 --- a/hindsight-control-plane/src/app/api/memories/[memoryId]/route.ts +++ b/hindsight-control-plane/src/app/api/memories/[memoryId]/route.ts @@ -6,6 +6,7 @@ export async function GET( { params }: { params: Promise<{ memoryId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); const { memoryId } = await params; const searchParams = request.nextUrl.searchParams; const bankId = searchParams.get("bank_id"); @@ -18,7 +19,7 @@ export async function GET( dataplaneBankUrl(bankId, `/memories/${encodeURIComponent(memoryId)}`), { method: "GET", - headers: getDataplaneHeaders({ "Content-Type": "application/json" }), + headers: getDataplaneHeaders(tenant, { "Content-Type": "application/json" }), } ); 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 015ef0842..b4e27721d 100644 --- a/hindsight-control-plane/src/app/api/memories/retain/route.ts +++ b/hindsight-control-plane/src/app/api/memories/retain/route.ts @@ -1,8 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; -import { hindsightClient } from "@/lib/hindsight-client"; +import { getClientForTenant } from "@/lib/hindsight-client"; export async function POST(request: NextRequest) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { hindsightClient } = getClientForTenant(tenant); const body = await request.json(); const bankId = body.bank_id || body.agent_id; 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 ec0d5721f..2aca8da26 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,8 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { sdk, getClientForTenant } from "@/lib/hindsight-client"; export async function POST(request: NextRequest) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { lowLevelClient } = getClientForTenant(tenant); const body = await request.json(); const bankId = body.bank_id || body.agent_id; 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 9552c80b3..adf75db86 100644 --- a/hindsight-control-plane/src/app/api/operations/[agentId]/route.ts +++ b/hindsight-control-plane/src/app/api/operations/[agentId]/route.ts @@ -1,11 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { sdk, getClientForTenant } from "@/lib/hindsight-client"; export async function GET( request: NextRequest, { params }: { params: Promise<{ agentId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { lowLevelClient } = getClientForTenant(tenant); const { agentId } = await params; const searchParams = request.nextUrl.searchParams; const status = searchParams.get("status") || undefined; @@ -31,6 +33,8 @@ export async function DELETE( { params }: { params: Promise<{ agentId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { lowLevelClient } = getClientForTenant(tenant); const { agentId } = await params; const searchParams = request.nextUrl.searchParams; const operationId = searchParams.get("operation_id"); 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 583e56950..3d33dd2af 100644 --- a/hindsight-control-plane/src/app/api/profile/[bankId]/route.ts +++ b/hindsight-control-plane/src/app/api/profile/[bankId]/route.ts @@ -1,11 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { sdk, getClientForTenant } from "@/lib/hindsight-client"; export async function GET( request: NextRequest, { params }: { params: Promise<{ bankId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { lowLevelClient } = getClientForTenant(tenant); const { bankId } = await params; const response = await sdk.getBankProfile({ client: lowLevelClient, @@ -23,6 +25,8 @@ export async function PUT( { params }: { params: Promise<{ bankId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { lowLevelClient } = getClientForTenant(tenant); const { bankId } = await params; const body = await request.json(); diff --git a/hindsight-control-plane/src/app/api/recall/route.ts b/hindsight-control-plane/src/app/api/recall/route.ts index ce6ec1bcc..dd136531f 100644 --- a/hindsight-control-plane/src/app/api/recall/route.ts +++ b/hindsight-control-plane/src/app/api/recall/route.ts @@ -1,8 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; -import { lowLevelClient, sdk } from "@/lib/hindsight-client"; +import { sdk, getClientForTenant } from "@/lib/hindsight-client"; export async function POST(request: NextRequest) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { lowLevelClient } = getClientForTenant(tenant); const body = await request.json(); const bankId = body.bank_id || body.agent_id || "default"; const { diff --git a/hindsight-control-plane/src/app/api/reflect/route.ts b/hindsight-control-plane/src/app/api/reflect/route.ts index eaf7197b5..78a97eb01 100644 --- a/hindsight-control-plane/src/app/api/reflect/route.ts +++ b/hindsight-control-plane/src/app/api/reflect/route.ts @@ -1,8 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { sdk, getClientForTenant } from "@/lib/hindsight-client"; export async function POST(request: NextRequest) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { lowLevelClient } = getClientForTenant(tenant); const body = await request.json(); const bankId = body.bank_id || body.agent_id || "default"; const { 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 f948d5c98..9804b0f00 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 @@ -6,13 +6,14 @@ export async function GET( { params }: { params: Promise<{ agentId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); const { agentId } = await params; const period = request.nextUrl.searchParams.get("period") || "7d"; const url = dataplaneBankUrl( agentId, `/stats/memories-timeseries?period=${encodeURIComponent(period)}` ); - const upstream = await fetch(url, { headers: getDataplaneHeaders() }); + const upstream = await fetch(url, { headers: getDataplaneHeaders(tenant) }); 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 113405123..4f8300517 100644 --- a/hindsight-control-plane/src/app/api/stats/[agentId]/route.ts +++ b/hindsight-control-plane/src/app/api/stats/[agentId]/route.ts @@ -1,11 +1,13 @@ import { NextRequest, NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { sdk, getClientForTenant } from "@/lib/hindsight-client"; export async function GET( request: NextRequest, { params }: { params: Promise<{ agentId: string }> } ) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { lowLevelClient } = getClientForTenant(tenant); const { agentId } = await params; const response = await sdk.getAgentStats({ client: lowLevelClient, diff --git a/hindsight-control-plane/src/app/api/tenants/route.ts b/hindsight-control-plane/src/app/api/tenants/route.ts new file mode 100644 index 000000000..51ca888a3 --- /dev/null +++ b/hindsight-control-plane/src/app/api/tenants/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; +import { getTenantNames, isMultiTenant } from "@/lib/hindsight-client"; + +export async function GET() { + return NextResponse.json({ + tenants: getTenantNames(), + multi_tenant: isMultiTenant(), + }); +} diff --git a/hindsight-control-plane/src/app/api/version/route.ts b/hindsight-control-plane/src/app/api/version/route.ts index 06e32f8be..516fcf49a 100644 --- a/hindsight-control-plane/src/app/api/version/route.ts +++ b/hindsight-control-plane/src/app/api/version/route.ts @@ -1,8 +1,10 @@ -import { NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; +import { NextRequest, NextResponse } from "next/server"; +import { sdk, getClientForTenant } from "@/lib/hindsight-client"; -export async function GET() { +export async function GET(request: NextRequest) { try { + const tenant = request.nextUrl.searchParams.get("tenant"); + const { lowLevelClient } = getClientForTenant(tenant); const response = await sdk.getVersion({ client: lowLevelClient, }); diff --git a/hindsight-control-plane/src/app/layout.tsx b/hindsight-control-plane/src/app/layout.tsx index 69a6065b1..06147bfbe 100644 --- a/hindsight-control-plane/src/app/layout.tsx +++ b/hindsight-control-plane/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import "./globals.css"; import { BankProvider } from "@/lib/bank-context"; import { FeaturesProvider } from "@/lib/features-context"; +import { TenantProvider } from "@/lib/tenant-context"; import { ThemeProvider } from "@/lib/theme-context"; import { Toaster } from "@/components/ui/sonner"; @@ -23,7 +24,9 @@ export default function RootLayout({ - {children} + + {children} + diff --git a/hindsight-control-plane/src/components/bank-selector.tsx b/hindsight-control-plane/src/components/bank-selector.tsx index e2409e3bb..b0769f446 100644 --- a/hindsight-control-plane/src/components/bank-selector.tsx +++ b/hindsight-control-plane/src/components/bank-selector.tsx @@ -39,6 +39,7 @@ import { ChevronRight, } from "lucide-react"; import { useTheme } from "@/lib/theme-context"; +import { useTenant } from "@/lib/tenant-context"; import Image from "next/image"; import { Textarea } from "@/components/ui/textarea"; import { Checkbox } from "@/components/ui/checkbox"; @@ -57,6 +58,7 @@ function BankSelectorInner() { const router = useRouter(); const searchParams = useSearchParams(); const { currentBank, setCurrentBank, banks, loadBanks } = useBank(); + const { currentTenant, setCurrentTenant, tenants, isMultiTenant } = useTenant(); const { theme, toggleTheme } = useTheme(); const [open, setOpen] = React.useState(false); const [createDialogOpen, setCreateDialogOpen] = React.useState(false); @@ -427,6 +429,22 @@ function BankSelectorInner() { {/* Separator */}
+ {/* Tenant Selector (multi-tenant mode only) */} + {isMultiTenant && ( + + )} + {/* Memory Bank Selector */} (path: string, options?: RequestInit): Promise { + // Append ?tenant= to the path if set + let url = path; + if (this._tenant) { + const separator = path.includes("?") ? "&" : "?"; + url = `${path}${separator}tenant=${encodeURIComponent(this._tenant)}`; + } + try { - const response = await fetch(path, { + const response = await fetch(url, { ...options, headers: { "Content-Type": "application/json", @@ -1165,6 +1183,14 @@ export class ControlPlaneClient { >(bankApi(bankId, `/mental-models/${encodeURIComponent(mentalModelId)}/history`)); } + /** + * List configured tenants + */ + async listTenants() { + const response = await fetch("/api/tenants", { cache: "no-store" }); + return response.json() as Promise<{ tenants: string[]; multi_tenant: boolean }>; + } + /** * Get API version and feature flags * Use this to check which capabilities are available in the dataplane @@ -1218,8 +1244,12 @@ export class ControlPlaneClient { formData.append("request", JSON.stringify(requestData)); - // Use fetch directly for multipart/form-data - const response = await fetch(`/api/files/retain`, { + // Use fetch directly for multipart/form-data (can't use fetchApi for FormData) + let url = `/api/files/retain`; + if (this._tenant) { + url += `?tenant=${encodeURIComponent(this._tenant)}`; + } + const response = await fetch(url, { method: "POST", body: formData, // Don't set Content-Type - browser will set it with boundary diff --git a/hindsight-control-plane/src/lib/bank-context.tsx b/hindsight-control-plane/src/lib/bank-context.tsx index aca17c101..4be36575b 100644 --- a/hindsight-control-plane/src/lib/bank-context.tsx +++ b/hindsight-control-plane/src/lib/bank-context.tsx @@ -1,8 +1,9 @@ "use client"; -import React, { createContext, useContext, useState, useEffect } from "react"; -import { usePathname } from "next/navigation"; +import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from "react"; +import { usePathname, useRouter } from "next/navigation"; import { client } from "./api"; +import { useTenant } from "./tenant-context"; interface BankContextType { currentBank: string | null; @@ -15,31 +16,58 @@ const BankContext = createContext(undefined); export function BankProvider({ children }: { children: React.ReactNode }) { const pathname = usePathname(); + const router = useRouter(); + const { currentTenant } = useTenant(); const [currentBank, setCurrentBank] = useState(null); const [banks, setBanks] = useState([]); + const [banksLoaded, setBanksLoaded] = useState(false); + // Guard against stale responses when tenant switches rapidly + const loadIdRef = useRef(0); - const loadBanks = async () => { + const loadBanks = useCallback(async () => { + const id = ++loadIdRef.current; try { const response = await client.listBanks(); - // Extract bank_id from each bank object + // Drop the result if a newer load was started while we were waiting + if (id !== loadIdRef.current) return; const bankIds = response.banks?.map((bank: any) => bank.bank_id) || []; setBanks(bankIds); + setBanksLoaded(true); } catch (error) { + if (id !== loadIdRef.current) return; console.error("Error loading banks:", error); } - }; + }, []); - // Initialize bank from URL on mount + // Keep currentBank in sync with the URL. Routing is the source of truth — + // navigating to /dashboard or another non-bank page clears it; navigating to + // /banks/ sets it. Driving this from the URL avoids racing with the + // tenant-switch effect below. useEffect(() => { const bankMatch = pathname?.match(/^\/banks\/([^/?]+)/); - if (bankMatch) { - setCurrentBank(decodeURIComponent(bankMatch[1])); - } + setCurrentBank(bankMatch ? decodeURIComponent(bankMatch[1]) : null); }, [pathname]); + // Reload the bank list when the active tenant changes. useEffect(() => { + if (currentTenant === null) return; + setBanksLoaded(false); loadBanks(); - }, []); + }, [currentTenant, loadBanks]); + + // Direct-link guard: a bookmarked /banks/ URL may target a bank from a + // different tenant than the one restored from localStorage. Once the current + // tenant's banks have loaded, redirect away from any URL bank that isn't in + // that tenant — otherwise the page would render against an inaccessible bank. + useEffect(() => { + if (!banksLoaded) return; + const bankMatch = pathname?.match(/^\/banks\/([^/?]+)/); + if (!bankMatch) return; + const urlBank = decodeURIComponent(bankMatch[1]); + if (!banks.includes(urlBank)) { + router.push("/dashboard"); + } + }, [banksLoaded, banks, pathname, router]); return ( diff --git a/hindsight-control-plane/src/lib/hindsight-client.ts b/hindsight-control-plane/src/lib/hindsight-client.ts index 8ea79f4e0..2ed21d7ab 100644 --- a/hindsight-control-plane/src/lib/hindsight-client.ts +++ b/hindsight-control-plane/src/lib/hindsight-client.ts @@ -1,6 +1,12 @@ /** - * Shared Hindsight API client instance for the control plane. - * Configured to connect to the dataplane API server. + * Tenant-aware Hindsight API client factory for the control plane. + * + * Supports two modes: + * 1. Multi-tenant: HINDSIGHT_API_TENANT_KEY_MAP=key1:name1;key2:name2 + * 2. Single-tenant (backwards-compat): HINDSIGHT_CP_DATAPLANE_API_KEY=key + * + * In multi-tenant mode, getClientForTenant(name) returns a client scoped to + * that tenant's schema. In single-tenant mode, all calls use the single key. */ import { @@ -11,53 +17,139 @@ import { sdk, } from "@vectorize-io/hindsight-client"; -export const DATAPLANE_URL = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://localhost:8888"; -const DATAPLANE_API_KEY = process.env.HINDSIGHT_CP_DATAPLANE_API_KEY || ""; +export const DATAPLANE_URL = + process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://localhost:8888"; + +// --- Tenant key map parsing --- + +interface TenantEntry { + name: string; + apiKey: string; +} + +function parseTenantKeyMap(raw: string): TenantEntry[] { + if (!raw.trim()) return []; + return raw.split(";").filter(Boolean).map((entry) => { + const colonIdx = entry.indexOf(":"); + if (colonIdx === -1) { + throw new Error( + `Invalid HINDSIGHT_API_TENANT_KEY_MAP entry "${entry}". Expected "key:name".` + ); + } + return { + apiKey: entry.slice(0, colonIdx).trim(), + name: entry.slice(colonIdx + 1).trim(), + }; + }); +} + +// Same env var as the API server — one key map for both. +const TENANT_KEY_MAP_RAW = process.env.HINDSIGHT_API_TENANT_KEY_MAP || ""; +const SINGLE_KEY = process.env.HINDSIGHT_CP_DATAPLANE_API_KEY || ""; + +const tenantEntries: TenantEntry[] = TENANT_KEY_MAP_RAW + ? parseTenantKeyMap(TENANT_KEY_MAP_RAW) + : SINGLE_KEY + ? [{ name: "default", apiKey: SINGLE_KEY }] + : []; + +const tenantsByName = new Map(tenantEntries.map((e) => [e.name, e])); + +// --- Client cache (one per tenant, created lazily) --- + +interface TenantClients { + hindsightClient: HindsightClient; + lowLevelClient: ReturnType; + apiKey: string; +} + +const clientCache = new Map(); + +function buildClients(apiKey: string): TenantClients { + return { + hindsightClient: new HindsightClient({ + baseUrl: DATAPLANE_URL, + apiKey: apiKey || undefined, + }), + lowLevelClient: createClient( + createConfig({ + baseUrl: DATAPLANE_URL, + headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined, + }) + ), + apiKey, + }; +} /** - * Auth headers for direct fetch calls to the dataplane API. + * Get SDK clients for a specific tenant. + * + * In multi-tenant mode, providing an unknown tenant name throws an error + * to prevent silent cross-tenant data leakage. In single-tenant mode + * (or when no name is provided), falls back to the first configured tenant. */ -export function getDataplaneHeaders(extra?: Record): Record { - const headers: Record = { ...extra }; - if (DATAPLANE_API_KEY) { - headers["Authorization"] = `Bearer ${DATAPLANE_API_KEY}`; +export function getClientForTenant(tenantName?: string | null): TenantClients { + let name: string; + if (tenantName) { + if (!tenantsByName.has(tenantName)) { + if (isMultiTenant()) { + throw new Error(`Unknown tenant: ${tenantName}`); + } + // Single-tenant fallback + name = tenantEntries[0]?.name ?? "default"; + } else { + name = tenantName; + } + } else { + name = tenantEntries[0]?.name ?? "default"; } - return headers; + + let clients = clientCache.get(name); + if (!clients) { + const entry = tenantsByName.get(name); + clients = buildClients(entry?.apiKey ?? ""); + clientCache.set(name, clients); + } + return clients; } /** - * Build a dataplane URL for a bank-scoped endpoint with the bank id properly encoded. - * Bank ids may contain `:`, `/`, `%`, etc. (e.g. openclaw `agent::channel::user`), - * which must be percent-encoded before being interpolated into a URL path. + * Return the list of configured tenant names. + * Used by the /api/tenants route and TenantContext. */ -export function dataplaneBankUrl(bankId: string, suffix = ""): string { - return `${DATAPLANE_URL}/v1/default/banks/${encodeURIComponent(bankId)}${suffix}`; +export function getTenantNames(): string[] { + return tenantEntries.map((e) => e.name); } /** - * High-level client with convenience methods + * Whether multi-tenant mode is active (more than one tenant configured). */ -export const hindsightClient = new HindsightClient({ - baseUrl: DATAPLANE_URL, - apiKey: DATAPLANE_API_KEY || undefined, -}); +export function isMultiTenant(): boolean { + return tenantEntries.length > 1; +} /** - * Low-level client for direct SDK access + * Auth headers for direct fetch calls to the dataplane API. */ -export const lowLevelClient = createClient( - createConfig({ - baseUrl: DATAPLANE_URL, - headers: DATAPLANE_API_KEY ? { Authorization: `Bearer ${DATAPLANE_API_KEY}` } : undefined, - }) -); +export function getDataplaneHeaders( + tenantName?: string | null, + extra?: Record +): Record { + const { apiKey } = getClientForTenant(tenantName); + const headers: Record = { ...extra }; + if (apiKey) { + headers["Authorization"] = `Bearer ${apiKey}`; + } + return headers; +} /** - * Export SDK functions for direct API access + * Build a dataplane URL for a bank-scoped endpoint with the bank id properly encoded. + * Bank ids may contain `:`, `/`, `%`, etc. (e.g. openclaw `agent::channel::user`), + * which must be percent-encoded before being interpolated into a URL path. */ -export { sdk }; +export function dataplaneBankUrl(bankId: string, suffix = ""): string { + return `${DATAPLANE_URL}/v1/default/banks/${encodeURIComponent(bankId)}${suffix}`; +} -/** - * Export HindsightError for error handling - */ -export { HindsightError }; +export { sdk, HindsightError }; diff --git a/hindsight-control-plane/src/lib/tenant-context.tsx b/hindsight-control-plane/src/lib/tenant-context.tsx new file mode 100644 index 000000000..b9f6bdbf2 --- /dev/null +++ b/hindsight-control-plane/src/lib/tenant-context.tsx @@ -0,0 +1,97 @@ +"use client"; + +import React, { createContext, useContext, useState, useEffect, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { client } from "./api"; + +interface TenantContextType { + /** Currently selected tenant name, or null if not yet loaded */ + currentTenant: string | null; + /** Switch to a different tenant */ + setCurrentTenant: (tenant: string) => void; + /** All available tenant names */ + tenants: string[]; + /** Whether multi-tenant mode is active */ + isMultiTenant: boolean; + /** Whether tenants are still loading */ + loading: boolean; +} + +const TenantContext = createContext(undefined); + +const STORAGE_KEY = "hindsight-cp-tenant"; + +export function TenantProvider({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const [tenants, setTenants] = useState([]); + const [isMultiTenant, setIsMultiTenant] = useState(false); + const [currentTenant, setCurrentTenantState] = useState(null); + const [loading, setLoading] = useState(true); + + const setCurrentTenant = useCallback( + (tenant: string) => { + setCurrentTenantState(tenant); + client.setTenant(tenant); + try { + localStorage.setItem(STORAGE_KEY, tenant); + } catch { + // localStorage may be unavailable + } + // The previous tenant's bank can't exist in the new schema, and per-bank + // views cache fetched data in component state. Returning to /dashboard + // unmounts those components so they don't render stale results. + router.push("/dashboard"); + }, + [router] + ); + + useEffect(() => { + async function loadTenants() { + try { + const data = await client.listTenants(); + setTenants(data.tenants); + setIsMultiTenant(data.multi_tenant); + + // Restore saved tenant or use first available + let saved: string | null = null; + try { + saved = localStorage.getItem(STORAGE_KEY); + } catch { + // localStorage may be unavailable + } + + const initial = + saved && data.tenants.includes(saved) + ? saved + : data.tenants[0] ?? null; + + if (initial) { + setCurrentTenantState(initial); + client.setTenant(initial); + } + } catch (error) { + console.error("Failed to load tenants:", error); + } finally { + setLoading(false); + } + } + + loadTenants(); + }, []); + + return ( + + {children} + + ); +} + +export function useTenant() { + const context = useContext(TenantContext); + if (context === undefined) { + throw new Error("useTenant must be used within a TenantProvider"); + } + return context; +} diff --git a/package-lock.json b/package-lock.json index 565b7c579..e7885d12a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ }, "hindsight-all-npm": { "name": "@vectorize-io/hindsight-all", - "version": "0.5.1", + "version": "0.5.0", "license": "MIT", "devDependencies": { "@types/node": "^22.0.0", @@ -138,7 +138,7 @@ }, "hindsight-clients/typescript": { "name": "@vectorize-io/hindsight-client", - "version": "0.5.1", + "version": "0.5.0", "license": "MIT", "devDependencies": { "@hey-api/openapi-ts": "0.88.0", @@ -357,7 +357,7 @@ }, "hindsight-control-plane": { "name": "@vectorize-io/hindsight-control-plane", - "version": "0.5.1", + "version": "0.5.0", "license": "ISC", "dependencies": { "@chenglou/pretext": "^0.0.3",