From 4d88741683aa97e850d397ac07b9d39d5635082b Mon Sep 17 00:00:00 2001 From: Brian Jones Date: Thu, 2 Apr 2026 20:01:26 -0700 Subject: [PATCH 01/11] =?UTF-8?q?feat:=20API=20key=20schema=20isolation=20?= =?UTF-8?q?=E2=80=94=20database-level=20tenant=20separation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ApiKeySchemaTenantExtension: maps API keys to isolated PostgreSQL schemas, providing database-level memory isolation between tenants. Threat model: prompt injection against AI agents. Agents execute tool calls based on conversation content. A prompt injection can trick an agent into querying another tenant's banks. Schema isolation scopes all SQL to the authenticated schema — banks from other schemas don't exist. Configuration: HINDSIGHT_API_TENANT_EXTENSION=...bank_scoped_tenant:ApiKeySchemaTenantExtension HINDSIGHT_API_TENANT_KEY_MAP=key_a:schema_a;key_b:schema_b Follows the SupabaseTenantExtension pattern. Opt-in, zero breaking changes. Includes 20 tests. --- .../extensions/builtin/bank_scoped_tenant.py | 236 ++++++++++++++++++ hindsight-api-slim/tests/test_bank_scoped.py | 175 +++++++++++++ 2 files changed, 411 insertions(+) create mode 100644 hindsight-api-slim/hindsight_api/extensions/builtin/bank_scoped_tenant.py create mode 100644 hindsight-api-slim/tests/test_bank_scoped.py 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..dd45101b6 --- /dev/null +++ b/hindsight-api-slim/hindsight_api/extensions/builtin/bank_scoped_tenant.py @@ -0,0 +1,236 @@ +""" +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(f"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}'. " + f"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..e7a1c6b9d --- /dev/null +++ b/hindsight-api-slim/tests/test_bank_scoped.py @@ -0,0 +1,175 @@ +"""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="")) From 455ee0aae93e0e67a5d2e03459f4c599783181d1 Mon Sep 17 00:00:00 2001 From: Brian Jones Date: Fri, 3 Apr 2026 02:03:20 -0700 Subject: [PATCH 02/11] feat(cp): tenant-aware client factory in hindsight-client.ts Replaces singleton HINDSIGHT_CP_DATAPLANE_API_KEY with factory pattern. Supports HINDSIGHT_CP_TENANT_KEY_MAP=key:name;key:name for multi-tenant. Backwards-compatible: single key still works via default export. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/lib/hindsight-client.ts | 149 ++++++++++++++---- 1 file changed, 118 insertions(+), 31 deletions(-) diff --git a/hindsight-control-plane/src/lib/hindsight-client.ts b/hindsight-control-plane/src/lib/hindsight-client.ts index 8ea79f4e0..0fc96aec2 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_CP_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,16 +17,113 @@ 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_CP_TENANT_KEY_MAP entry "${entry}". Expected "key:name".` + ); + } + return { + apiKey: entry.slice(0, colonIdx).trim(), + name: entry.slice(colonIdx + 1).trim(), + }; + }); +} + +const TENANT_KEY_MAP_RAW = process.env.HINDSIGHT_CP_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, + }; +} + +/** + * Get SDK clients for a specific tenant. + * Falls back to the first tenant if name is not found. + */ +export function getClientForTenant(tenantName?: string | null): TenantClients { + const name = tenantName && tenantsByName.has(tenantName) + ? tenantName + : tenantEntries[0]?.name ?? "default"; + + let clients = clientCache.get(name); + if (!clients) { + const entry = tenantsByName.get(name); + clients = buildClients(entry?.apiKey ?? ""); + clientCache.set(name, clients); + } + return clients; +} + +/** + * Return the list of configured tenant names. + * Used by the /api/tenants route and TenantContext. + */ +export function getTenantNames(): string[] { + return tenantEntries.map((e) => e.name); +} + +/** + * Whether multi-tenant mode is active (more than one tenant configured). + */ +export function isMultiTenant(): boolean { + return tenantEntries.length > 1; +} /** * Auth headers for direct fetch calls to the dataplane API. */ -export function getDataplaneHeaders(extra?: Record): Record { +export function getDataplaneHeaders( + tenantName?: string | null, + extra?: Record +): Record { + const { apiKey } = getClientForTenant(tenantName); const headers: Record = { ...extra }; - if (DATAPLANE_API_KEY) { - headers["Authorization"] = `Bearer ${DATAPLANE_API_KEY}`; + if (apiKey) { + headers["Authorization"] = `Bearer ${apiKey}`; } return headers; } @@ -34,30 +137,14 @@ export function dataplaneBankUrl(bankId: string, suffix = ""): string { return `${DATAPLANE_URL}/v1/default/banks/${encodeURIComponent(bankId)}${suffix}`; } -/** - * High-level client with convenience methods - */ -export const hindsightClient = new HindsightClient({ - baseUrl: DATAPLANE_URL, - apiKey: DATAPLANE_API_KEY || undefined, -}); +// --- Backwards-compatible default exports --- +// These use the first configured tenant (or the single key). -/** - * Low-level client for direct SDK access - */ -export const lowLevelClient = createClient( - createConfig({ - baseUrl: DATAPLANE_URL, - headers: DATAPLANE_API_KEY ? { Authorization: `Bearer ${DATAPLANE_API_KEY}` } : undefined, - }) -); +const defaultClients = getClientForTenant(); -/** - * Export SDK functions for direct API access - */ -export { sdk }; +/** @deprecated Use getClientForTenant() instead */ +export const hindsightClient = defaultClients.hindsightClient; +/** @deprecated Use getClientForTenant() instead */ +export const lowLevelClient = defaultClients.lowLevelClient; -/** - * Export HindsightError for error handling - */ -export { HindsightError }; +export { sdk, HindsightError }; From c25165b709fa02e8bc9eab7812d0d97f8e30d46d Mon Sep 17 00:00:00 2001 From: Brian Jones Date: Fri, 3 Apr 2026 02:12:17 -0700 Subject: [PATCH 03/11] feat(cp): multi-tenant dashboard support - Add tenant-aware BFF: all API routes read ?tenant= param and use getClientForTenant() instead of singleton lowLevelClient - Add /api/tenants route for tenant discovery - Add TenantContext + TenantProvider for client-side tenant state - ControlPlaneClient.fetchApi() auto-appends ?tenant= to all requests - Tenant selector dropdown in header (hidden in single-tenant mode) - BankProvider re-fetches banks and resets selection on tenant change - Backwards-compatible: HINDSIGHT_CP_DATAPLANE_API_KEY still works - New env var: HINDSIGHT_CP_TENANT_KEY_MAP=key:name;key:name Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 10 +++ .../api/banks/[bankId]/audit-logs/route.ts | 7 +- .../banks/[bankId]/audit-logs/stats/route.ts | 7 +- .../app/api/banks/[bankId]/config/route.ts | 8 +- .../api/banks/[bankId]/consolidate/route.ts | 8 +- .../[bankId]/consolidation-recover/route.ts | 8 +- .../directives/[directiveId]/route.ts | 17 ++-- .../api/banks/[bankId]/directives/route.ts | 12 +-- .../app/api/banks/[bankId]/export/route.ts | 3 +- .../app/api/banks/[bankId]/import/route.ts | 3 +- .../[mentalModelId]/history/route.ts | 7 +- .../[mentalModelId]/refresh/route.ts | 7 +- .../mental-models/[mentalModelId]/route.ts | 17 ++-- .../api/banks/[bankId]/mental-models/route.ts | 12 +-- .../[bankId]/observations/[modelId]/route.ts | 7 +- .../api/banks/[bankId]/observations/route.ts | 12 ++- .../operations/[operationId]/route.ts | 13 +-- .../src/app/api/banks/[bankId]/route.ts | 16 ++-- .../webhooks/[webhookId]/deliveries/route.ts | 7 +- .../[bankId]/webhooks/[webhookId]/route.ts | 12 +-- .../app/api/banks/[bankId]/webhooks/route.ts | 12 +-- .../src/app/api/banks/route.ts | 12 ++- .../src/app/api/chunks/[chunkId]/route.ts | 4 +- .../app/api/documents/[documentId]/route.ts | 9 +- .../src/app/api/documents/route.ts | 4 +- .../entities/[entityId]/regenerate/route.ts | 4 +- .../src/app/api/entities/[entityId]/route.ts | 4 +- .../src/app/api/entities/route.ts | 4 +- .../src/app/api/files/retain/route.ts | 3 +- .../src/app/api/graph/route.ts | 3 +- .../src/app/api/health/route.ts | 7 +- .../src/app/api/list/route.ts | 4 +- .../api/memories/[memoryId]/history/route.ts | 3 +- .../src/app/api/memories/[memoryId]/route.ts | 3 +- .../src/app/api/memories/retain/route.ts | 4 +- .../app/api/memories/retain_async/route.ts | 4 +- .../src/app/api/operations/[agentId]/route.ts | 6 +- .../src/app/api/profile/[bankId]/route.ts | 6 +- .../src/app/api/recall/route.ts | 4 +- .../src/app/api/reflect/route.ts | 4 +- .../src/app/api/stats/[agentId]/route.ts | 4 +- .../src/app/api/tenants/route.ts | 9 ++ .../src/app/api/version/route.ts | 8 +- hindsight-control-plane/src/app/layout.tsx | 5 +- .../src/components/bank-selector.tsx | 18 ++++ hindsight-control-plane/src/lib/api.ts | 28 +++++- .../src/lib/bank-context.tsx | 5 +- .../src/lib/tenant-context.tsx | 88 +++++++++++++++++++ 48 files changed, 355 insertions(+), 107 deletions(-) create mode 100644 hindsight-control-plane/src/app/api/tenants/route.ts create mode 100644 hindsight-control-plane/src/lib/tenant-context.tsx diff --git a/.env.example b/.env.example index 7be4ee38f..342923ece 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 dashboard: map API keys to tenant names (same format as API server) +# HINDSIGHT_CP_TENANT_KEY_MAP=key1:tenant_alpha;key2:tenant_beta +# +# Single-tenant dashboard (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-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..3fc8fe2d8 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,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; if (!bankId) { @@ -16,7 +17,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: 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..eebe54bd5 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,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; if (!bankId) { return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); @@ -14,7 +15,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: 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]/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/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..d41e5ef39 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 { sdk, 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]/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 diff --git a/hindsight-control-plane/src/lib/bank-context.tsx b/hindsight-control-plane/src/lib/bank-context.tsx index aca17c101..cf4cc1063 100644 --- a/hindsight-control-plane/src/lib/bank-context.tsx +++ b/hindsight-control-plane/src/lib/bank-context.tsx @@ -3,6 +3,7 @@ import React, { createContext, useContext, useState, useEffect } from "react"; import { usePathname } from "next/navigation"; import { client } from "./api"; +import { useTenant } from "./tenant-context"; interface BankContextType { currentBank: string | null; @@ -15,6 +16,7 @@ const BankContext = createContext(undefined); export function BankProvider({ children }: { children: React.ReactNode }) { const pathname = usePathname(); + const { currentTenant } = useTenant(); const [currentBank, setCurrentBank] = useState(null); const [banks, setBanks] = useState([]); @@ -38,8 +40,9 @@ export function BankProvider({ children }: { children: React.ReactNode }) { }, [pathname]); useEffect(() => { + setCurrentBank(null); loadBanks(); - }, []); + }, [currentTenant]); return ( 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..98e4dd1f9 --- /dev/null +++ b/hindsight-control-plane/src/lib/tenant-context.tsx @@ -0,0 +1,88 @@ +"use client"; + +import React, { createContext, useContext, useState, useEffect, useCallback } from "react"; +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 [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 + } + }, []); + + 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; +} From 3d6adb938a920eace119b7891c95aeef045ba182 Mon Sep 17 00:00:00 2001 From: Brian Jones Date: Fri, 3 Apr 2026 02:24:19 -0700 Subject: [PATCH 04/11] fix(cp): review fixes for multi-tenant dashboard - Fix uploadFiles() bypassing fetchApi and missing ?tenant= param - Remove unused sdk import from list/route.ts - Guard BankProvider loadBanks() until tenant is resolved (avoid double-load) Co-Authored-By: Claude Opus 4.6 (1M context) --- hindsight-control-plane/src/app/api/list/route.ts | 2 +- hindsight-control-plane/src/lib/api.ts | 8 ++++++-- hindsight-control-plane/src/lib/bank-context.tsx | 1 + 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/hindsight-control-plane/src/app/api/list/route.ts b/hindsight-control-plane/src/app/api/list/route.ts index d41e5ef39..edf169a97 100644 --- a/hindsight-control-plane/src/app/api/list/route.ts +++ b/hindsight-control-plane/src/app/api/list/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { sdk, getClientForTenant } from "@/lib/hindsight-client"; +import { getClientForTenant } from "@/lib/hindsight-client"; export async function GET(request: NextRequest) { try { diff --git a/hindsight-control-plane/src/lib/api.ts b/hindsight-control-plane/src/lib/api.ts index 57ddb1157..7afe39e5e 100644 --- a/hindsight-control-plane/src/lib/api.ts +++ b/hindsight-control-plane/src/lib/api.ts @@ -1244,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 cf4cc1063..4c0d6fb27 100644 --- a/hindsight-control-plane/src/lib/bank-context.tsx +++ b/hindsight-control-plane/src/lib/bank-context.tsx @@ -40,6 +40,7 @@ export function BankProvider({ children }: { children: React.ReactNode }) { }, [pathname]); useEffect(() => { + if (currentTenant === null) return; setCurrentBank(null); loadBanks(); }, [currentTenant]); From 8645f58cc7721a7953fa3a4c74e935bd8c3f7022 Mon Sep 17 00:00:00 2001 From: Brian Jones Date: Fri, 3 Apr 2026 14:44:46 -0700 Subject: [PATCH 05/11] fix(cp): tenant isolation bugs + Playwright e2e tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix silent cross-tenant data leakage in getClientForTenant() — invalid tenant names now throw in multi-tenant mode instead of silently falling back to the first tenant. Fix race condition in bank-context.tsx where rapid tenant switches could interleave bank list responses, showing banks from the wrong tenant. Uses a monotonic load ID to discard stale responses. Add Playwright e2e test suite (18 tests) covering tenant discovery, switching, bank loading, navigation, and cross-tenant isolation. Includes an mTLS proxy for testing against prod deployments behind mutual TLS. Co-Authored-By: Claude Opus 4.6 --- hindsight-control-plane/.env.e2e | 12 + hindsight-control-plane/.gitignore | 6 + hindsight-control-plane/e2e/mtls-proxy.mjs | 95 ++ .../e2e/multi-tenant-dashboard.spec.ts | 337 ++++ hindsight-control-plane/e2e/run-prod.sh | 88 + hindsight-control-plane/package.json | 4 + hindsight-control-plane/playwright.config.ts | 44 + .../src/lib/bank-context.tsx | 15 +- .../src/lib/hindsight-client.ts | 22 +- package-lock.json | 1443 +++-------------- 10 files changed, 811 insertions(+), 1255 deletions(-) create mode 100644 hindsight-control-plane/.env.e2e create mode 100644 hindsight-control-plane/e2e/mtls-proxy.mjs create mode 100644 hindsight-control-plane/e2e/multi-tenant-dashboard.spec.ts create mode 100644 hindsight-control-plane/e2e/run-prod.sh create mode 100644 hindsight-control-plane/playwright.config.ts diff --git a/hindsight-control-plane/.env.e2e b/hindsight-control-plane/.env.e2e new file mode 100644 index 000000000..b731a7293 --- /dev/null +++ b/hindsight-control-plane/.env.e2e @@ -0,0 +1,12 @@ +# E2E test environment — multi-tenant mode against prod deployment +# +# The mTLS proxy (e2e/mtls-proxy.mjs) forwards plain HTTP on :18888 +# to the prod API at 34.208.169.77:443 with client certificates. +# +# To get the tenant keys, SSH to the prod instance and read /opt/openclaw/.env: +# ssh openclaw@34.208.169.77 +# grep HINDSIGHT_KEY_ /opt/openclaw/.env +# +# Format: key1:name1;key2:name2 +HINDSIGHT_CP_TENANT_KEY_MAP=JONES_KEY_HERE:household_jones;PAULA_KEY_HERE:household_paula +HINDSIGHT_CP_DATAPLANE_API_URL=http://localhost:18888/hindsight-api diff --git a/hindsight-control-plane/.gitignore b/hindsight-control-plane/.gitignore index d07c312bb..e7fb9ad2c 100644 --- a/hindsight-control-plane/.gitignore +++ b/hindsight-control-plane/.gitignore @@ -36,3 +36,9 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/hindsight-control-plane/e2e/mtls-proxy.mjs b/hindsight-control-plane/e2e/mtls-proxy.mjs new file mode 100644 index 000000000..c2def5c7c --- /dev/null +++ b/hindsight-control-plane/e2e/mtls-proxy.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +/** + * Local mTLS proxy for testing the control plane against a prod deployment + * that requires mutual TLS (client certificates). + * + * Listens on LOCAL_PORT (default 18888) as plain HTTP and forwards all + * requests to the remote HTTPS endpoint using the provided client certs. + * + * Usage: + * node e2e/mtls-proxy.mjs + * + * Environment: + * MTLS_CA_CERT - path to CA certificate (default: ../openclaw-infra/ca.crt) + * MTLS_CLIENT_CERT - path to client certificate (default: ../openclaw-infra/client.crt) + * MTLS_CLIENT_KEY - path to client key (default: ../openclaw-infra/client.key) + * MTLS_REMOTE_HOST - remote hostname (default: 34.208.169.77) + * MTLS_REMOTE_PORT - remote port (default: 443) + * MTLS_LOCAL_PORT - local listen port (default: 18888) + */ + +import http from "node:http"; +import https from "node:https"; +import tls from "node:tls"; +import fs from "node:fs"; +import path from "node:path"; + +const REMOTE_HOST = process.env.MTLS_REMOTE_HOST || "34.208.169.77"; +const REMOTE_PORT = parseInt(process.env.MTLS_REMOTE_PORT || "443", 10); +const LOCAL_PORT = parseInt(process.env.MTLS_LOCAL_PORT || "18888", 10); + +// e2e/ → control-plane/ → hindsight-contrib/ → code/ → openclaw-infra/ +const infraDir = path.resolve( + process.env.MTLS_INFRA_DIR || path.join(import.meta.dirname, "..", "..", "..", "openclaw-infra") +); + +const caCert = fs.readFileSync(process.env.MTLS_CA_CERT || path.join(infraDir, "ca.crt")); +const clientCert = fs.readFileSync(process.env.MTLS_CLIENT_CERT || path.join(infraDir, "client.crt")); +const clientKey = fs.readFileSync(process.env.MTLS_CLIENT_KEY || path.join(infraDir, "client.key")); + +// The server cert has CN=openclaw with no SAN — Node.js rejects it when +// connecting by IP. We verify the cert was signed by our CA (rejectUnauthorized +// + ca) but skip hostname matching since this is a self-signed internal CA. +const tlsOptions = { + ca: caCert, + cert: clientCert, + key: clientKey, + rejectUnauthorized: true, + servername: "openclaw", + checkServerIdentity: (hostname, cert) => { + // Accept any cert signed by our CA — the CA check in rejectUnauthorized + // ensures we're talking to the right server. + return undefined; + }, +}; + +const server = http.createServer((req, res) => { + const options = { + hostname: REMOTE_HOST, + port: REMOTE_PORT, + path: req.url, + method: req.method, + headers: { ...req.headers, host: REMOTE_HOST }, + ...tlsOptions, + }; + + const proxy = https.request(options, (proxyRes) => { + res.writeHead(proxyRes.statusCode, proxyRes.headers); + proxyRes.pipe(res, { end: true }); + }); + + proxy.on("error", (err) => { + console.error(`[mtls-proxy] ${req.method} ${req.url} → error: ${err.message}`); + if (!res.headersSent) { + res.writeHead(502, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Proxy connection failed", detail: err.message })); + } + }); + + req.pipe(proxy, { end: true }); +}); + +server.listen(LOCAL_PORT, () => { + console.log(`[mtls-proxy] Listening on http://localhost:${LOCAL_PORT}`); + console.log(`[mtls-proxy] Forwarding to https://${REMOTE_HOST}:${REMOTE_PORT} with mTLS`); + console.log(`[mtls-proxy] Certs from ${infraDir}`); +}); + +// Graceful shutdown +process.on("SIGINT", () => { + console.log("\n[mtls-proxy] Shutting down..."); + server.close(() => process.exit(0)); +}); +process.on("SIGTERM", () => { + server.close(() => process.exit(0)); +}); diff --git a/hindsight-control-plane/e2e/multi-tenant-dashboard.spec.ts b/hindsight-control-plane/e2e/multi-tenant-dashboard.spec.ts new file mode 100644 index 000000000..5a6d7ec47 --- /dev/null +++ b/hindsight-control-plane/e2e/multi-tenant-dashboard.spec.ts @@ -0,0 +1,337 @@ +import { test, expect, type Page } from "@playwright/test"; + +/** + * E2E tests for the multi-tenant dashboard feature. + * + * Prerequisites: + * 1. mTLS proxy running: node e2e/mtls-proxy.mjs + * 2. .env.local configured with HINDSIGHT_CP_TENANT_KEY_MAP pointing at prod + * 3. Dev server running: npx next dev --turbopack -p 9999 + * + * These tests are READ-ONLY — they never create, modify, or delete data on prod. + */ + +// Helper: wait for the app to finish loading (tenant + bank contexts) +async function waitForAppReady(page: Page) { + await page.waitForSelector("img[alt='Hindsight']", { timeout: 15_000 }); + await page.waitForTimeout(1000); +} + +// Helper: get the tenant selector trigger (Radix Select) +function tenantSelector(page: Page) { + return page.locator("button[role='combobox']").filter({ hasText: /jones|paula|Select tenant/i }); +} + +// Helper: get the bank selector trigger (cmdk Popover) +function bankSelector(page: Page) { + return page.locator("button[role='combobox']").filter({ hasText: /Select a memory bank|openclaw/i }); +} + +// Helper: select jones tenant and wait for banks to load +async function selectJonesTenant(page: Page) { + const trigger = tenantSelector(page); + await trigger.click(); + await page.getByRole("option", { name: "household_jones" }).click(); + await page.waitForTimeout(3000); // wait for banks to load from prod +} + +// Helper: open bank selector and pick the first bank +async function selectFirstBank(page: Page) { + const bankTrigger = bankSelector(page); + await bankTrigger.click(); + await page.locator("[cmdk-item]").first().waitFor({ timeout: 10_000 }); + await page.locator("[cmdk-item]").first().click(); + await expect(page).toHaveURL(/\/banks\/.+/); + await page.waitForTimeout(1000); // let the bank page render +} + +// ───────────────────────────────────────────────────────────────────────────── +// 1. Tenant Discovery +// ───────────────────────────────────────────────────────────────────────────── + +test.describe("Tenant Discovery", () => { + test("tenants API returns jones and paula in multi-tenant mode", async ({ request }) => { + const res = await request.get("/api/tenants"); + expect(res.ok()).toBeTruthy(); + + const data = await res.json(); + expect(data.multi_tenant).toBe(true); + expect(data.tenants).toContain("household_jones"); + expect(data.tenants).toContain("household_paula"); + expect(data.tenants.length).toBe(2); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 2. Dashboard Landing +// ───────────────────────────────────────────────────────────────────────────── + +test.describe("Dashboard Landing", () => { + test("root redirects to /dashboard", async ({ page }) => { + await page.goto("/"); + await expect(page).toHaveURL(/\/dashboard/); + }); + + test("dashboard shows welcome message", async ({ page }) => { + await page.goto("/dashboard"); + await waitForAppReady(page); + await expect(page.getByText("Welcome to Hindsight")).toBeVisible(); + }); + + test("tenant selector is visible in multi-tenant mode", async ({ page }) => { + await page.goto("/dashboard"); + await waitForAppReady(page); + + const selector = tenantSelector(page); + await expect(selector).toBeVisible(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 3. Tenant Switching +// ───────────────────────────────────────────────────────────────────────────── + +test.describe("Tenant Switching", () => { + test("can switch between tenants", async ({ page }) => { + await page.goto("/dashboard"); + await waitForAppReady(page); + + const trigger = tenantSelector(page); + await expect(trigger).toBeVisible(); + + const initialText = await trigger.textContent(); + + // Click to open the dropdown + await trigger.click(); + + // Both tenant options should be visible + await expect(page.getByRole("option", { name: "household_jones" })).toBeVisible(); + await expect(page.getByRole("option", { name: "household_paula" })).toBeVisible(); + + // Switch to the other tenant + const targetTenant = initialText?.includes("household_jones") ? "household_paula" : "household_jones"; + await page.getByRole("option", { name: targetTenant }).click(); + + await expect(trigger).toHaveText(new RegExp(targetTenant, "i")); + }); + + test("tenant selection persists across navigation", async ({ page }) => { + await page.goto("/dashboard"); + await waitForAppReady(page); + + const trigger = tenantSelector(page); + + // Switch to paula + await trigger.click(); + await page.getByRole("option", { name: "household_paula" }).click(); + await expect(trigger).toHaveText(/paula/i); + + // Navigate away and back + await page.goto("/dashboard"); + await waitForAppReady(page); + + // Tenant should still be paula (restored from localStorage) + const triggerAfter = tenantSelector(page); + await expect(triggerAfter).toHaveText(/paula/i); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 4. Bank Loading per Tenant +// ───────────────────────────────────────────────────────────────────────────── + +test.describe("Bank Loading per Tenant", () => { + test("banks API returns banks for jones tenant", async ({ request }) => { + const res = await request.get("/api/banks?tenant=household_jones"); + expect(res.ok()).toBeTruthy(); + + const data = await res.json(); + expect(data.banks).toBeDefined(); + expect(Array.isArray(data.banks)).toBe(true); + expect(data.banks.length).toBeGreaterThan(0); + }); + + test("both tenant APIs return valid responses", async ({ request }) => { + const jonesRes = await request.get("/api/banks?tenant=household_jones"); + const paulaRes = await request.get("/api/banks?tenant=household_paula"); + + expect(jonesRes.ok()).toBeTruthy(); + expect(paulaRes.ok()).toBeTruthy(); + + const jonesData = await jonesRes.json(); + const paulaData = await paulaRes.json(); + + // Jones should have banks (prod has data), paula may not + expect(jonesData.banks.length).toBeGreaterThan(0); + expect(Array.isArray(paulaData.banks)).toBe(true); + }); + + test("switching tenant refreshes bank list in UI", async ({ page }) => { + await page.goto("/dashboard"); + await waitForAppReady(page); + + // Select jones tenant (which has banks) + await selectJonesTenant(page); + + // Open bank selector and wait for items to appear + const bankTrigger = bankSelector(page); + await bankTrigger.click(); + await page.locator("[cmdk-item]").first().waitFor({ timeout: 10_000 }); + + const jonesItems = await page.locator("[cmdk-item]").allTextContents(); + expect(jonesItems.length).toBeGreaterThan(0); + + // Close popover + await page.keyboard.press("Escape"); + await page.waitForTimeout(500); + + // Switch to paula — verify the bank list changes (may be empty) + const trigger = tenantSelector(page); + await trigger.click(); + await page.getByRole("option", { name: "household_paula" }).click(); + await page.waitForTimeout(3000); + + // Re-open bank selector — paula may have no banks, which is fine + // The key assertion is that switching didn't error out + await bankSelector(page).click(); + await page.waitForTimeout(1000); + + // The empty state shows "No memory banks yet." or bank items + const hasBanks = await page.locator("[cmdk-item]").count(); + const hasEmpty = await page.getByText("No memory banks yet").count(); + expect(hasBanks + hasEmpty).toBeGreaterThan(0); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 5. Bank Navigation +// ───────────────────────────────────────────────────────────────────────────── + +test.describe("Bank Navigation", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/dashboard"); + await waitForAppReady(page); + await selectJonesTenant(page); + }); + + test("selecting a bank navigates to bank detail page", async ({ page }) => { + await selectFirstBank(page); + // Already asserted in helper — URL matches /banks/... + }); + + test("bank detail page has sidebar with navigation links", async ({ page }) => { + await selectFirstBank(page); + + // Sidebar is collapsed by default — links have title attributes + await expect(page.locator("a[title='Recall']")).toBeVisible(); + await expect(page.locator("a[title='Reflect']")).toBeVisible(); + await expect(page.locator("a[title='Memories']")).toBeVisible(); + await expect(page.locator("a[title='Documents']")).toBeVisible(); + await expect(page.locator("a[title='Entities']")).toBeVisible(); + await expect(page.locator("a[title='Bank Configuration']")).toBeVisible(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 6. Bank Detail Views (read-only) +// ───────────────────────────────────────────────────────────────────────────── + +test.describe("Bank Detail Views", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/dashboard"); + await waitForAppReady(page); + await selectJonesTenant(page); + await selectFirstBank(page); + }); + + test("can navigate to Memories view with sub-tabs", async ({ page }) => { + await page.locator("a[title='Memories']").click(); + await expect(page).toHaveURL(/view=data/); + + // Sub-tabs are plain buttons (not Radix Tabs) + await expect(page.getByRole("button", { name: /World Facts/i })).toBeVisible(); + await expect(page.getByRole("button", { name: /Experience/i })).toBeVisible(); + }); + + test("can navigate to Recall view", async ({ page }) => { + await page.locator("a[title='Recall']").click(); + await expect(page).toHaveURL(/view=recall/); + }); + + test("can navigate to Reflect view", async ({ page }) => { + await page.locator("a[title='Reflect']").click(); + await expect(page).toHaveURL(/view=reflect/); + }); + + test("can navigate to Documents view", async ({ page }) => { + await page.locator("a[title='Documents']").click(); + await expect(page).toHaveURL(/view=documents/); + }); + + test("can navigate to Entities view", async ({ page }) => { + await page.locator("a[title='Entities']").click(); + await expect(page).toHaveURL(/view=entities/); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 7. Theme Toggle +// ───────────────────────────────────────────────────────────────────────────── + +test.describe("Theme Toggle", () => { + test("can toggle between light and dark theme", async ({ page }) => { + await page.goto("/dashboard"); + await waitForAppReady(page); + + const themeButton = page.locator("button").filter({ has: page.locator("svg.lucide-sun, svg.lucide-moon") }); + await expect(themeButton).toBeVisible(); + + const htmlEl = page.locator("html"); + const initialClass = await htmlEl.getAttribute("class"); + + await themeButton.click(); + await page.waitForTimeout(300); + + const newClass = await htmlEl.getAttribute("class"); + expect(newClass).not.toBe(initialClass); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 8. Cross-tenant Isolation +// ───────────────────────────────────────────────────────────────────────────── + +test.describe("Cross-tenant Isolation", () => { + test("tenant selector correctly scopes API calls", async ({ page }) => { + // Set up request listener BEFORE navigating + const apiCalls: string[] = []; + page.on("request", (req) => { + const url = req.url(); + if (url.includes("/api/") && !url.includes("_next")) { + apiCalls.push(url); + } + }); + + await page.goto("/dashboard"); + await waitForAppReady(page); + + // Switch to jones — this should trigger bank fetch with tenant param + const trigger = tenantSelector(page); + await trigger.click(); + await page.getByRole("option", { name: "household_jones" }).click(); + await page.waitForTimeout(3000); + + // Verify at least one API call included the tenant parameter + const jonesCalls = apiCalls.filter((url) => url.includes("tenant=household_jones")); + expect(jonesCalls.length).toBeGreaterThan(0); + + // Clear and switch to paula + apiCalls.length = 0; + await trigger.click(); + await page.getByRole("option", { name: "household_paula" }).click(); + await page.waitForTimeout(3000); + + const paulaCalls = apiCalls.filter((url) => url.includes("tenant=household_paula")); + expect(paulaCalls.length).toBeGreaterThan(0); + }); +}); diff --git a/hindsight-control-plane/e2e/run-prod.sh b/hindsight-control-plane/e2e/run-prod.sh new file mode 100644 index 000000000..985787596 --- /dev/null +++ b/hindsight-control-plane/e2e/run-prod.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Run Playwright e2e tests against the production deployment. +# +# Prerequisites: +# 1. Copy .env.e2e → .env.local and fill in the tenant API keys +# 2. Client certificates available at ../openclaw-infra/{ca,client}.{crt,key} +# +# Usage: +# ./e2e/run-prod.sh # run all tests +# ./e2e/run-prod.sh --headed # run with browser visible +# ./e2e/run-prod.sh --debug # run in debug mode +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +cd "$CP_DIR" + +# ── Validate .env.local has tenant keys ────────────────────────────────────── +if [[ ! -f .env.local ]]; then + echo "ERROR: .env.local not found." + echo "Copy .env.e2e to .env.local and fill in the tenant API keys." + echo " cp .env.e2e .env.local" + exit 1 +fi + +if grep -q "KEY_HERE" .env.local; then + echo "ERROR: .env.local still has placeholder keys." + echo "Fill in the real tenant API keys from the prod instance:" + echo " ssh openclaw@34.208.169.77 'grep HINDSIGHT_KEY_ /opt/openclaw/.env'" + exit 1 +fi + +# ── Validate certs exist ───────────────────────────────────────────────────── +# CP_DIR is hindsight-control-plane/, go up to code/ then into openclaw-infra/ +INFRA_DIR="${MTLS_INFRA_DIR:-$CP_DIR/../../../openclaw-infra}" +for f in ca.crt client.crt client.key; do + if [[ ! -f "$INFRA_DIR/$f" ]]; then + echo "ERROR: Missing $INFRA_DIR/$f" + echo "Ensure openclaw-infra repo is at ../openclaw-infra relative to hindsight-contrib/" + exit 1 + fi +done + +# ── Start mTLS proxy in background ────────────────────────────────────────── +echo "Starting mTLS proxy..." +MTLS_INFRA_DIR="$INFRA_DIR" node e2e/mtls-proxy.mjs & +PROXY_PID=$! + +cleanup() { + echo "Stopping mTLS proxy (pid $PROXY_PID)..." + kill "$PROXY_PID" 2>/dev/null || true + wait "$PROXY_PID" 2>/dev/null || true +} +trap cleanup EXIT + +# Wait for proxy to be ready +for i in $(seq 1 10); do + if curl -sf http://localhost:18888/ >/dev/null 2>&1 || [[ $i -eq 10 ]]; then + break + fi + sleep 0.5 +done +echo "mTLS proxy ready on :18888" + +# ── Check if dev server is running ─────────────────────────────────────────── +if ! curl -sf http://localhost:9999/ >/dev/null 2>&1; then + echo "" + echo "WARNING: Dev server not detected on :9999." + echo "Start it in another terminal: npm run dev" + echo "Waiting for dev server..." + for i in $(seq 1 30); do + if curl -sf http://localhost:9999/ >/dev/null 2>&1; then + break + fi + if [[ $i -eq 30 ]]; then + echo "ERROR: Dev server not responding after 15s. Start it with: npm run dev" + exit 1 + fi + sleep 0.5 + done +fi +echo "Dev server ready on :9999" + +# ── Run Playwright ─────────────────────────────────────────────────────────── +echo "" +echo "Running Playwright tests..." +npx playwright test "$@" diff --git a/hindsight-control-plane/package.json b/hindsight-control-plane/package.json index 8da974fbc..f6fea3645 100644 --- a/hindsight-control-plane/package.json +++ b/hindsight-control-plane/package.json @@ -16,6 +16,9 @@ "build:standalone": "rm -rf standalone && SERVER_JS=$(find .next/standalone -path '*/node_modules' -prune -o -name 'server.js' -print | head -1) && test -n \"$SERVER_JS\" || (echo 'Error: server.js not found in .next/standalone - standalone build failed' && exit 1) && STANDALONE_ROOT=$(dirname \"$SERVER_JS\") && cp -r \"$STANDALONE_ROOT\" standalone && cp -r .next/standalone/node_modules standalone/node_modules && mkdir -p standalone/.next && cp -r .next/static standalone/.next/static && mkdir -p standalone/public && (cp -r public/* standalone/public/ 2>/dev/null || true)", "start": "next start", "lint": "next lint", + "test:e2e": "npx playwright test", + "test:e2e:headed": "npx playwright test --headed", + "test:e2e:prod": "bash e2e/run-prod.sh", "prepublishOnly": "npm run build" }, "keywords": [ @@ -77,6 +80,7 @@ "devDependencies": { "@eslint/eslintrc": "^3.3.3", "@eslint/js": "^9.39.2", + "@playwright/test": "^1.59.1", "@vectorize-io/hindsight-client": "file:../hindsight-clients/typescript", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", diff --git a/hindsight-control-plane/playwright.config.ts b/hindsight-control-plane/playwright.config.ts new file mode 100644 index 000000000..7f9338982 --- /dev/null +++ b/hindsight-control-plane/playwright.config.ts @@ -0,0 +1,44 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * Playwright config for e2e testing the multi-tenant dashboard. + * + * Run against prod: + * 1. Start the mTLS proxy: node e2e/mtls-proxy.mjs + * 2. Copy .env.e2e to .env.local and fill in tenant keys + * 3. Start dev server: npm run dev + * 4. Run tests: npx playwright test + */ +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: 1, // sequential — tests share browser state for tenant/bank selection + reporter: [["html", { open: "never" }], ["list"]], + + use: { + baseURL: process.env.E2E_BASE_URL || "http://localhost:9999", + trace: "on-first-retry", + screenshot: "only-on-failure", + // Increase timeouts for prod latency + actionTimeout: 15_000, + navigationTimeout: 30_000, + }, + + timeout: 60_000, + + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + + // Don't auto-start the dev server — we expect it (and the mTLS proxy) to already be running + // webServer: { + // command: "npm run dev", + // url: "http://localhost:9999", + // reuseExistingServer: true, + // }, +}); diff --git a/hindsight-control-plane/src/lib/bank-context.tsx b/hindsight-control-plane/src/lib/bank-context.tsx index 4c0d6fb27..3c0d1af0f 100644 --- a/hindsight-control-plane/src/lib/bank-context.tsx +++ b/hindsight-control-plane/src/lib/bank-context.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { createContext, useContext, useState, useEffect } from "react"; +import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from "react"; import { usePathname } from "next/navigation"; import { client } from "./api"; import { useTenant } from "./tenant-context"; @@ -19,17 +19,22 @@ export function BankProvider({ children }: { children: React.ReactNode }) { const { currentTenant } = useTenant(); const [currentBank, setCurrentBank] = useState(null); const [banks, setBanks] = useState([]); + // 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); } catch (error) { + if (id !== loadIdRef.current) return; console.error("Error loading banks:", error); } - }; + }, []); // Initialize bank from URL on mount useEffect(() => { @@ -43,7 +48,7 @@ export function BankProvider({ children }: { children: React.ReactNode }) { if (currentTenant === null) return; setCurrentBank(null); loadBanks(); - }, [currentTenant]); + }, [currentTenant, loadBanks]); return ( diff --git a/hindsight-control-plane/src/lib/hindsight-client.ts b/hindsight-control-plane/src/lib/hindsight-client.ts index 0fc96aec2..7fcee87e6 100644 --- a/hindsight-control-plane/src/lib/hindsight-client.ts +++ b/hindsight-control-plane/src/lib/hindsight-client.ts @@ -82,12 +82,26 @@ function buildClients(apiKey: string): TenantClients { /** * Get SDK clients for a specific tenant. - * Falls back to the first tenant if name is not found. + * + * 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 getClientForTenant(tenantName?: string | null): TenantClients { - const name = tenantName && tenantsByName.has(tenantName) - ? tenantName - : tenantEntries[0]?.name ?? "default"; + 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"; + } let clients = clientCache.get(name); if (!clients) { diff --git a/package-lock.json b/package-lock.json index 565b7c579..4866e1606 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,137 +8,12 @@ "workspaces": [ "hindsight-clients/typescript", "hindsight-control-plane", - "hindsight-docs", - "hindsight-all-npm" + "hindsight-docs" ] }, - "hindsight-all-npm": { - "name": "@vectorize-io/hindsight-all", - "version": "0.5.1", - "license": "MIT", - "devDependencies": { - "@types/node": "^22.0.0", - "tsup": "^8.5.1", - "typescript": "^5.7.0", - "vitest": "^4.1.2" - }, - "engines": { - "node": ">=22" - } - }, - "hindsight-all-npm/node_modules/@types/node": { - "version": "22.19.17", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", - "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "hindsight-all-npm/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "hindsight-all-npm/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "hindsight-all-npm/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "hindsight-all-npm/node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "hindsight-all-npm/node_modules/tsup": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", - "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", - "dev": true, - "license": "MIT", - "dependencies": { - "bundle-require": "^5.1.0", - "cac": "^6.7.14", - "chokidar": "^4.0.3", - "consola": "^3.4.0", - "debug": "^4.4.0", - "esbuild": "^0.27.0", - "fix-dts-default-cjs-exports": "^1.0.0", - "joycon": "^3.1.1", - "picocolors": "^1.1.1", - "postcss-load-config": "^6.0.1", - "resolve-from": "^5.0.0", - "rollup": "^4.34.8", - "source-map": "^0.7.6", - "sucrase": "^3.35.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.11", - "tree-kill": "^1.2.2" - }, - "bin": { - "tsup": "dist/cli-default.js", - "tsup-node": "dist/cli-node.js" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@microsoft/api-extractor": "^7.36.0", - "@swc/core": "^1", - "postcss": "^8.4.12", - "typescript": ">=4.5.0" - }, - "peerDependenciesMeta": { - "@microsoft/api-extractor": { - "optional": true - }, - "@swc/core": { - "optional": true - }, - "postcss": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, "hindsight-clients/typescript": { "name": "@vectorize-io/hindsight-client", - "version": "0.5.1", + "version": "0.4.22", "license": "MIT", "devDependencies": { "@hey-api/openapi-ts": "0.88.0", @@ -258,6 +133,49 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "hindsight-clients/typescript/node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "hindsight-clients/typescript/node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -357,7 +275,7 @@ }, "hindsight-control-plane": { "name": "@vectorize-io/hindsight-control-plane", - "version": "0.5.1", + "version": "0.4.22", "license": "ISC", "dependencies": { "@chenglou/pretext": "^0.0.3", @@ -413,6 +331,7 @@ "devDependencies": { "@eslint/eslintrc": "^3.3.3", "@eslint/js": "^9.39.2", + "@playwright/test": "^1.59.1", "@vectorize-io/hindsight-client": "file:../hindsight-clients/typescript", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", @@ -5202,20 +5121,20 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", "license": "MIT", "optional": true, "dependencies": { @@ -5223,9 +5142,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", "license": "MIT", "optional": true, "dependencies": { @@ -7440,14 +7359,20 @@ "node": ">=8.0.0" } }, - "node_modules/@oxc-project/types": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", - "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" } }, "node_modules/@pnpm/config.env-replace": { @@ -8695,27 +8620,38 @@ "url": "https://opencollective.com/immer" } }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", - "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", "cpu": [ - "arm64" + "arm" ], "dev": true, "license": "MIT", "optional": true, "os": [ "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", - "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", "cpu": [ "arm64" ], @@ -8724,15 +8660,12 @@ "optional": true, "os": [ "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", - "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", "cpu": [ "x64" ], @@ -8741,15 +8674,26 @@ "optional": true, "os": [ "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", - "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", "cpu": [ "x64" ], @@ -8758,15 +8702,12 @@ "optional": true, "os": [ "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", - "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", "cpu": [ "arm" ], @@ -8775,32 +8716,26 @@ "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", "cpu": [ - "arm64" + "arm" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", - "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", "cpu": [ "arm64" ], @@ -8809,307 +8744,14 @@ "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", - "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", - "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", - "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.9.2", - "@emnapi/runtime": "1.9.2", - "@napi-rs/wasm-runtime": "^1.1.3" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", - "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", - "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", - "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", - "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", - "cpu": [ - "arm64" + "arm64" ], "dev": true, "license": "MIT", @@ -10058,17 +9700,6 @@ "@types/node": "*" } }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -10356,13 +9987,6 @@ "@types/ms": "*" } }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -11265,10 +10889,6 @@ "d3-transition": "^3.0.1" } }, - "node_modules/@vectorize-io/hindsight-all": { - "resolved": "hindsight-all-npm", - "link": true - }, "node_modules/@vectorize-io/hindsight-client": { "resolved": "hindsight-clients/typescript", "link": true @@ -11286,119 +10906,6 @@ "node": ">= 20" } }, - "node_modules/@vitest/expect": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", - "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.4", - "@vitest/utils": "4.1.4", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", - "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.4", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", - "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", - "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.4", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", - "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.4", - "@vitest/utils": "4.1.4", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", - "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", - "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.4", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -12117,16 +11624,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -12937,16 +12434,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -15038,9 +14525,9 @@ } }, "node_modules/defu": { - "version": "6.1.7", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", - "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", "dev": true, "license": "MIT" }, @@ -16460,16 +15947,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -20414,15 +19891,15 @@ } }, "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash-es": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", - "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", "license": "MIT" }, "node_modules/lodash.debounce": { @@ -23603,17 +23080,6 @@ "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", "license": "MIT" }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT" - }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -24260,6 +23726,52 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -24307,9 +23819,9 @@ } }, "node_modules/postcss": { - "version": "8.5.9", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", - "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "funding": [ { "type": "opencollective", @@ -24906,49 +24418,6 @@ "postcss": "^8.4" } }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.1.1" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, "node_modules/postcss-loader": { "version": "7.3.4", "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-7.3.4.tgz", @@ -27329,40 +26798,6 @@ "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", "license": "Unlicense" }, - "node_modules/rolldown": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", - "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.124.0", - "@rolldown/pluginutils": "1.0.0-rc.15" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.15", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", - "@rolldown/binding-darwin-x64": "1.0.0-rc.15", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" - } - }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -28147,13 +27582,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -28410,13 +27838,6 @@ "node": ">=8" } }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -29211,13 +28632,6 @@ "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", "license": "MIT" }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -29269,16 +28683,6 @@ "node": "^18.0.0 || >=20.0.0" } }, - "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -30349,442 +29753,6 @@ "d3-timer": "^3.0.1" } }, - "node_modules/vite": { - "version": "8.0.8", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", - "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.15", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/vite/node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vitest": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", - "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.4", - "@vitest/mocker": "4.1.4", - "@vitest/pretty-format": "4.1.4", - "@vitest/runner": "4.1.4", - "@vitest/snapshot": "4.1.4", - "@vitest/spy": "4.1.4", - "@vitest/utils": "4.1.4", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.4", - "@vitest/browser-preview": "4.1.4", - "@vitest/browser-webdriverio": "4.1.4", - "@vitest/coverage-istanbul": "4.1.4", - "@vitest/coverage-v8": "4.1.4", - "@vitest/ui": "4.1.4", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "node_modules/vitest/node_modules/std-env": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", - "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", - "dev": true, - "license": "MIT" - }, "node_modules/vscode-jsonrpc": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", @@ -31477,23 +30445,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/widest-line": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", From 33c7ecd2f899f280fa717754f042832ee54d8ea2 Mon Sep 17 00:00:00 2001 From: Brian Jones Date: Fri, 3 Apr 2026 15:34:29 -0700 Subject: [PATCH 06/11] fix(cp): share tenant key map with API, eliminating config duplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The control plane now falls back to HINDSIGHT_API_TENANT_KEY_MAP when HINDSIGHT_CP_TENANT_KEY_MAP is not set. Operators no longer need to duplicate API keys across two env vars — both the API and dashboard read from the same source. Co-Authored-By: Claude Opus 4.6 --- hindsight-control-plane/.env.e2e | 3 ++- hindsight-control-plane/src/lib/hindsight-client.ts | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/hindsight-control-plane/.env.e2e b/hindsight-control-plane/.env.e2e index b731a7293..93fd03290 100644 --- a/hindsight-control-plane/.env.e2e +++ b/hindsight-control-plane/.env.e2e @@ -8,5 +8,6 @@ # grep HINDSIGHT_KEY_ /opt/openclaw/.env # # Format: key1:name1;key2:name2 -HINDSIGHT_CP_TENANT_KEY_MAP=JONES_KEY_HERE:household_jones;PAULA_KEY_HERE:household_paula +# Uses the same env var as the API — no need for HINDSIGHT_CP_TENANT_KEY_MAP +HINDSIGHT_API_TENANT_KEY_MAP=JONES_KEY_HERE:household_jones;PAULA_KEY_HERE:household_paula HINDSIGHT_CP_DATAPLANE_API_URL=http://localhost:18888/hindsight-api diff --git a/hindsight-control-plane/src/lib/hindsight-client.ts b/hindsight-control-plane/src/lib/hindsight-client.ts index 7fcee87e6..3fab109d8 100644 --- a/hindsight-control-plane/src/lib/hindsight-client.ts +++ b/hindsight-control-plane/src/lib/hindsight-client.ts @@ -43,7 +43,11 @@ function parseTenantKeyMap(raw: string): TenantEntry[] { }); } -const TENANT_KEY_MAP_RAW = process.env.HINDSIGHT_CP_TENANT_KEY_MAP || ""; +// CP-specific var takes precedence; falls back to the API key map so +// operators don't have to duplicate keys across two env vars. +const TENANT_KEY_MAP_RAW = process.env.HINDSIGHT_CP_TENANT_KEY_MAP + || process.env.HINDSIGHT_API_TENANT_KEY_MAP + || ""; const SINGLE_KEY = process.env.HINDSIGHT_CP_DATAPLANE_API_KEY || ""; const tenantEntries: TenantEntry[] = TENANT_KEY_MAP_RAW From ed9346c51bebeb14fcc8bc28699f5f813acb542b Mon Sep 17 00:00:00 2001 From: Brian Jones Date: Tue, 7 Apr 2026 08:50:12 -0700 Subject: [PATCH 07/11] style: fix ruff lint in bank_scoped_tenant.py Co-Authored-By: Claude Opus 4.6 (1M context) --- .../extensions/builtin/bank_scoped_tenant.py | 20 +++++-------------- 1 file changed, 5 insertions(+), 15 deletions(-) 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 index dd45101b6..d61be79c2 100644 --- a/hindsight-api-slim/hindsight_api/extensions/builtin/bank_scoped_tenant.py +++ b/hindsight-api-slim/hindsight_api/extensions/builtin/bank_scoped_tenant.py @@ -100,7 +100,7 @@ def _parse_key_map(raw: str) -> dict[str, str]: if not key: raise ValueError("Empty API key in key_map") if not schema: - raise ValueError(f"Empty schema name for key in key_map") + raise ValueError("Empty schema name for key in key_map") if not _SCHEMA_RE.match(schema): raise ValueError( f"Invalid schema name '{schema}'. " @@ -140,16 +140,10 @@ def __init__(self, config: dict[str, str]) -> None: 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" - ) + 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}'. " - f"Must be a valid Postgres identifier." - ) + 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", @@ -188,9 +182,7 @@ async def authenticate(self, context: RequestContext) -> TenantContext: AuthenticationError: If the API key is missing or not recognized. """ if not context.api_key: - raise AuthenticationError( - "Missing API key. Pass via Authorization: Bearer " - ) + raise AuthenticationError("Missing API key. Pass via Authorization: Bearer ") schema_name = self._key_to_schema.get(context.api_key) if schema_name is None: @@ -230,7 +222,5 @@ async def _initialize_schema(self, schema_name: str) -> None: 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 - ) + logger.error("Schema initialization failed for %s: %s", schema_name, e) raise AuthenticationError(f"Failed to initialize tenant: {e!s}") From b405f0ae68c069eef508e9dcf44ae1570d008864 Mon Sep 17 00:00:00 2001 From: Brian Jones Date: Sat, 11 Apr 2026 10:24:51 -0700 Subject: [PATCH 08/11] =?UTF-8?q?fix(cp):=20code=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20lint,=20dead=20exports,=20tenant=20param=20leak?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix ruff import sort and formatting in test_bank_scoped.py - Remove unused deprecated hindsightClient/lowLevelClient exports - Strip tenant query param before forwarding to dataplane in audit-logs routes Co-Authored-By: Claude Opus 4.6 (1M context) --- hindsight-api-slim/tests/test_bank_scoped.py | 35 +++++++++++-------- .../api/banks/[bankId]/audit-logs/route.ts | 3 +- .../banks/[bankId]/audit-logs/stats/route.ts | 2 ++ .../src/lib/hindsight-client.ts | 10 ------ 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/hindsight-api-slim/tests/test_bank_scoped.py b/hindsight-api-slim/tests/test_bank_scoped.py index e7a1c6b9d..44a068fb2 100644 --- a/hindsight-api-slim/tests/test_bank_scoped.py +++ b/hindsight-api-slim/tests/test_bank_scoped.py @@ -9,7 +9,6 @@ from hindsight_api.extensions.tenant import AuthenticationError from hindsight_api.models import RequestContext - # ========================================================================= # _parse_key_map tests # ========================================================================= @@ -65,10 +64,12 @@ def test_init_requires_key_map(self): def test_init_invalid_schema_prefix(self): with pytest.raises(ValueError, match="Invalid schema_prefix"): - ApiKeySchemaTenantExtension({ - "key_map": "key1:schema1", - "schema_prefix": "bad-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") @@ -142,9 +143,11 @@ class TestPromptInjectionDefense: @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 = 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")) @@ -154,9 +157,11 @@ async def test_attacker_key_cannot_reach_victim_schema(self): @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", - }) + ext = ApiKeySchemaTenantExtension( + { + "key_map": "real_key:real_schema", + } + ) with pytest.raises(AuthenticationError, match="Invalid API key"): await ext.authenticate(RequestContext(api_key="guessed_key")) @@ -164,9 +169,11 @@ async def test_unknown_key_rejected_not_defaulted(self): @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", - }) + ext = ApiKeySchemaTenantExtension( + { + "key_map": "real_key:real_schema", + } + ) with pytest.raises(AuthenticationError, match="Missing API key"): await ext.authenticate(RequestContext(api_key=None)) 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 3fc8fe2d8..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 @@ -10,8 +10,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ 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}` : ""}`); 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 eebe54bd5..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 @@ -9,7 +9,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ 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}` : ""}`); diff --git a/hindsight-control-plane/src/lib/hindsight-client.ts b/hindsight-control-plane/src/lib/hindsight-client.ts index 3fab109d8..8b58378a0 100644 --- a/hindsight-control-plane/src/lib/hindsight-client.ts +++ b/hindsight-control-plane/src/lib/hindsight-client.ts @@ -155,14 +155,4 @@ export function dataplaneBankUrl(bankId: string, suffix = ""): string { return `${DATAPLANE_URL}/v1/default/banks/${encodeURIComponent(bankId)}${suffix}`; } -// --- Backwards-compatible default exports --- -// These use the first configured tenant (or the single key). - -const defaultClients = getClientForTenant(); - -/** @deprecated Use getClientForTenant() instead */ -export const hindsightClient = defaultClients.hindsightClient; -/** @deprecated Use getClientForTenant() instead */ -export const lowLevelClient = defaultClients.lowLevelClient; - export { sdk, HindsightError }; From e4ca400065085bfde4a5cbbb90df091f764a33c2 Mon Sep 17 00:00:00 2001 From: Brian Jones Date: Sat, 11 Apr 2026 10:37:35 -0700 Subject: [PATCH 09/11] fix(cp): remove e2e infra, consolidate tenant env var MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove Playwright e2e tests, config, and mTLS proxy (manual-run only, not suitable for upstream CI) - Remove HINDSIGHT_CP_TENANT_KEY_MAP — dashboard reads HINDSIGHT_API_TENANT_KEY_MAP directly (one key map for both) - Update .env.example to document the consolidated config Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 6 +- hindsight-control-plane/.env.e2e | 13 - hindsight-control-plane/.gitignore | 6 - hindsight-control-plane/e2e/mtls-proxy.mjs | 95 -- .../e2e/multi-tenant-dashboard.spec.ts | 337 ---- hindsight-control-plane/e2e/run-prod.sh | 88 - hindsight-control-plane/package.json | 4 - hindsight-control-plane/playwright.config.ts | 44 - .../src/lib/hindsight-client.ts | 11 +- package-lock.json | 1447 ++++++++++++++--- 10 files changed, 1255 insertions(+), 796 deletions(-) delete mode 100644 hindsight-control-plane/.env.e2e delete mode 100644 hindsight-control-plane/e2e/mtls-proxy.mjs delete mode 100644 hindsight-control-plane/e2e/multi-tenant-dashboard.spec.ts delete mode 100644 hindsight-control-plane/e2e/run-prod.sh delete mode 100644 hindsight-control-plane/playwright.config.ts diff --git a/.env.example b/.env.example index 342923ece..c59357c7b 100644 --- a/.env.example +++ b/.env.example @@ -85,10 +85,10 @@ HINDSIGHT_API_LOG_LEVEL=info # HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT=production # Control Plane Dashboard (Optional) -# Multi-tenant dashboard: map API keys to tenant names (same format as API server) -# HINDSIGHT_CP_TENANT_KEY_MAP=key1:tenant_alpha;key2:tenant_beta +# 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 dashboard (backwards-compatible, used when KEY_MAP is not set) +# 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) diff --git a/hindsight-control-plane/.env.e2e b/hindsight-control-plane/.env.e2e deleted file mode 100644 index 93fd03290..000000000 --- a/hindsight-control-plane/.env.e2e +++ /dev/null @@ -1,13 +0,0 @@ -# E2E test environment — multi-tenant mode against prod deployment -# -# The mTLS proxy (e2e/mtls-proxy.mjs) forwards plain HTTP on :18888 -# to the prod API at 34.208.169.77:443 with client certificates. -# -# To get the tenant keys, SSH to the prod instance and read /opt/openclaw/.env: -# ssh openclaw@34.208.169.77 -# grep HINDSIGHT_KEY_ /opt/openclaw/.env -# -# Format: key1:name1;key2:name2 -# Uses the same env var as the API — no need for HINDSIGHT_CP_TENANT_KEY_MAP -HINDSIGHT_API_TENANT_KEY_MAP=JONES_KEY_HERE:household_jones;PAULA_KEY_HERE:household_paula -HINDSIGHT_CP_DATAPLANE_API_URL=http://localhost:18888/hindsight-api diff --git a/hindsight-control-plane/.gitignore b/hindsight-control-plane/.gitignore index e7fb9ad2c..d07c312bb 100644 --- a/hindsight-control-plane/.gitignore +++ b/hindsight-control-plane/.gitignore @@ -36,9 +36,3 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts - -# playwright -/test-results/ -/playwright-report/ -/blob-report/ -/playwright/.cache/ diff --git a/hindsight-control-plane/e2e/mtls-proxy.mjs b/hindsight-control-plane/e2e/mtls-proxy.mjs deleted file mode 100644 index c2def5c7c..000000000 --- a/hindsight-control-plane/e2e/mtls-proxy.mjs +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env node -/** - * Local mTLS proxy for testing the control plane against a prod deployment - * that requires mutual TLS (client certificates). - * - * Listens on LOCAL_PORT (default 18888) as plain HTTP and forwards all - * requests to the remote HTTPS endpoint using the provided client certs. - * - * Usage: - * node e2e/mtls-proxy.mjs - * - * Environment: - * MTLS_CA_CERT - path to CA certificate (default: ../openclaw-infra/ca.crt) - * MTLS_CLIENT_CERT - path to client certificate (default: ../openclaw-infra/client.crt) - * MTLS_CLIENT_KEY - path to client key (default: ../openclaw-infra/client.key) - * MTLS_REMOTE_HOST - remote hostname (default: 34.208.169.77) - * MTLS_REMOTE_PORT - remote port (default: 443) - * MTLS_LOCAL_PORT - local listen port (default: 18888) - */ - -import http from "node:http"; -import https from "node:https"; -import tls from "node:tls"; -import fs from "node:fs"; -import path from "node:path"; - -const REMOTE_HOST = process.env.MTLS_REMOTE_HOST || "34.208.169.77"; -const REMOTE_PORT = parseInt(process.env.MTLS_REMOTE_PORT || "443", 10); -const LOCAL_PORT = parseInt(process.env.MTLS_LOCAL_PORT || "18888", 10); - -// e2e/ → control-plane/ → hindsight-contrib/ → code/ → openclaw-infra/ -const infraDir = path.resolve( - process.env.MTLS_INFRA_DIR || path.join(import.meta.dirname, "..", "..", "..", "openclaw-infra") -); - -const caCert = fs.readFileSync(process.env.MTLS_CA_CERT || path.join(infraDir, "ca.crt")); -const clientCert = fs.readFileSync(process.env.MTLS_CLIENT_CERT || path.join(infraDir, "client.crt")); -const clientKey = fs.readFileSync(process.env.MTLS_CLIENT_KEY || path.join(infraDir, "client.key")); - -// The server cert has CN=openclaw with no SAN — Node.js rejects it when -// connecting by IP. We verify the cert was signed by our CA (rejectUnauthorized -// + ca) but skip hostname matching since this is a self-signed internal CA. -const tlsOptions = { - ca: caCert, - cert: clientCert, - key: clientKey, - rejectUnauthorized: true, - servername: "openclaw", - checkServerIdentity: (hostname, cert) => { - // Accept any cert signed by our CA — the CA check in rejectUnauthorized - // ensures we're talking to the right server. - return undefined; - }, -}; - -const server = http.createServer((req, res) => { - const options = { - hostname: REMOTE_HOST, - port: REMOTE_PORT, - path: req.url, - method: req.method, - headers: { ...req.headers, host: REMOTE_HOST }, - ...tlsOptions, - }; - - const proxy = https.request(options, (proxyRes) => { - res.writeHead(proxyRes.statusCode, proxyRes.headers); - proxyRes.pipe(res, { end: true }); - }); - - proxy.on("error", (err) => { - console.error(`[mtls-proxy] ${req.method} ${req.url} → error: ${err.message}`); - if (!res.headersSent) { - res.writeHead(502, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "Proxy connection failed", detail: err.message })); - } - }); - - req.pipe(proxy, { end: true }); -}); - -server.listen(LOCAL_PORT, () => { - console.log(`[mtls-proxy] Listening on http://localhost:${LOCAL_PORT}`); - console.log(`[mtls-proxy] Forwarding to https://${REMOTE_HOST}:${REMOTE_PORT} with mTLS`); - console.log(`[mtls-proxy] Certs from ${infraDir}`); -}); - -// Graceful shutdown -process.on("SIGINT", () => { - console.log("\n[mtls-proxy] Shutting down..."); - server.close(() => process.exit(0)); -}); -process.on("SIGTERM", () => { - server.close(() => process.exit(0)); -}); diff --git a/hindsight-control-plane/e2e/multi-tenant-dashboard.spec.ts b/hindsight-control-plane/e2e/multi-tenant-dashboard.spec.ts deleted file mode 100644 index 5a6d7ec47..000000000 --- a/hindsight-control-plane/e2e/multi-tenant-dashboard.spec.ts +++ /dev/null @@ -1,337 +0,0 @@ -import { test, expect, type Page } from "@playwright/test"; - -/** - * E2E tests for the multi-tenant dashboard feature. - * - * Prerequisites: - * 1. mTLS proxy running: node e2e/mtls-proxy.mjs - * 2. .env.local configured with HINDSIGHT_CP_TENANT_KEY_MAP pointing at prod - * 3. Dev server running: npx next dev --turbopack -p 9999 - * - * These tests are READ-ONLY — they never create, modify, or delete data on prod. - */ - -// Helper: wait for the app to finish loading (tenant + bank contexts) -async function waitForAppReady(page: Page) { - await page.waitForSelector("img[alt='Hindsight']", { timeout: 15_000 }); - await page.waitForTimeout(1000); -} - -// Helper: get the tenant selector trigger (Radix Select) -function tenantSelector(page: Page) { - return page.locator("button[role='combobox']").filter({ hasText: /jones|paula|Select tenant/i }); -} - -// Helper: get the bank selector trigger (cmdk Popover) -function bankSelector(page: Page) { - return page.locator("button[role='combobox']").filter({ hasText: /Select a memory bank|openclaw/i }); -} - -// Helper: select jones tenant and wait for banks to load -async function selectJonesTenant(page: Page) { - const trigger = tenantSelector(page); - await trigger.click(); - await page.getByRole("option", { name: "household_jones" }).click(); - await page.waitForTimeout(3000); // wait for banks to load from prod -} - -// Helper: open bank selector and pick the first bank -async function selectFirstBank(page: Page) { - const bankTrigger = bankSelector(page); - await bankTrigger.click(); - await page.locator("[cmdk-item]").first().waitFor({ timeout: 10_000 }); - await page.locator("[cmdk-item]").first().click(); - await expect(page).toHaveURL(/\/banks\/.+/); - await page.waitForTimeout(1000); // let the bank page render -} - -// ───────────────────────────────────────────────────────────────────────────── -// 1. Tenant Discovery -// ───────────────────────────────────────────────────────────────────────────── - -test.describe("Tenant Discovery", () => { - test("tenants API returns jones and paula in multi-tenant mode", async ({ request }) => { - const res = await request.get("/api/tenants"); - expect(res.ok()).toBeTruthy(); - - const data = await res.json(); - expect(data.multi_tenant).toBe(true); - expect(data.tenants).toContain("household_jones"); - expect(data.tenants).toContain("household_paula"); - expect(data.tenants.length).toBe(2); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// 2. Dashboard Landing -// ───────────────────────────────────────────────────────────────────────────── - -test.describe("Dashboard Landing", () => { - test("root redirects to /dashboard", async ({ page }) => { - await page.goto("/"); - await expect(page).toHaveURL(/\/dashboard/); - }); - - test("dashboard shows welcome message", async ({ page }) => { - await page.goto("/dashboard"); - await waitForAppReady(page); - await expect(page.getByText("Welcome to Hindsight")).toBeVisible(); - }); - - test("tenant selector is visible in multi-tenant mode", async ({ page }) => { - await page.goto("/dashboard"); - await waitForAppReady(page); - - const selector = tenantSelector(page); - await expect(selector).toBeVisible(); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// 3. Tenant Switching -// ───────────────────────────────────────────────────────────────────────────── - -test.describe("Tenant Switching", () => { - test("can switch between tenants", async ({ page }) => { - await page.goto("/dashboard"); - await waitForAppReady(page); - - const trigger = tenantSelector(page); - await expect(trigger).toBeVisible(); - - const initialText = await trigger.textContent(); - - // Click to open the dropdown - await trigger.click(); - - // Both tenant options should be visible - await expect(page.getByRole("option", { name: "household_jones" })).toBeVisible(); - await expect(page.getByRole("option", { name: "household_paula" })).toBeVisible(); - - // Switch to the other tenant - const targetTenant = initialText?.includes("household_jones") ? "household_paula" : "household_jones"; - await page.getByRole("option", { name: targetTenant }).click(); - - await expect(trigger).toHaveText(new RegExp(targetTenant, "i")); - }); - - test("tenant selection persists across navigation", async ({ page }) => { - await page.goto("/dashboard"); - await waitForAppReady(page); - - const trigger = tenantSelector(page); - - // Switch to paula - await trigger.click(); - await page.getByRole("option", { name: "household_paula" }).click(); - await expect(trigger).toHaveText(/paula/i); - - // Navigate away and back - await page.goto("/dashboard"); - await waitForAppReady(page); - - // Tenant should still be paula (restored from localStorage) - const triggerAfter = tenantSelector(page); - await expect(triggerAfter).toHaveText(/paula/i); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// 4. Bank Loading per Tenant -// ───────────────────────────────────────────────────────────────────────────── - -test.describe("Bank Loading per Tenant", () => { - test("banks API returns banks for jones tenant", async ({ request }) => { - const res = await request.get("/api/banks?tenant=household_jones"); - expect(res.ok()).toBeTruthy(); - - const data = await res.json(); - expect(data.banks).toBeDefined(); - expect(Array.isArray(data.banks)).toBe(true); - expect(data.banks.length).toBeGreaterThan(0); - }); - - test("both tenant APIs return valid responses", async ({ request }) => { - const jonesRes = await request.get("/api/banks?tenant=household_jones"); - const paulaRes = await request.get("/api/banks?tenant=household_paula"); - - expect(jonesRes.ok()).toBeTruthy(); - expect(paulaRes.ok()).toBeTruthy(); - - const jonesData = await jonesRes.json(); - const paulaData = await paulaRes.json(); - - // Jones should have banks (prod has data), paula may not - expect(jonesData.banks.length).toBeGreaterThan(0); - expect(Array.isArray(paulaData.banks)).toBe(true); - }); - - test("switching tenant refreshes bank list in UI", async ({ page }) => { - await page.goto("/dashboard"); - await waitForAppReady(page); - - // Select jones tenant (which has banks) - await selectJonesTenant(page); - - // Open bank selector and wait for items to appear - const bankTrigger = bankSelector(page); - await bankTrigger.click(); - await page.locator("[cmdk-item]").first().waitFor({ timeout: 10_000 }); - - const jonesItems = await page.locator("[cmdk-item]").allTextContents(); - expect(jonesItems.length).toBeGreaterThan(0); - - // Close popover - await page.keyboard.press("Escape"); - await page.waitForTimeout(500); - - // Switch to paula — verify the bank list changes (may be empty) - const trigger = tenantSelector(page); - await trigger.click(); - await page.getByRole("option", { name: "household_paula" }).click(); - await page.waitForTimeout(3000); - - // Re-open bank selector — paula may have no banks, which is fine - // The key assertion is that switching didn't error out - await bankSelector(page).click(); - await page.waitForTimeout(1000); - - // The empty state shows "No memory banks yet." or bank items - const hasBanks = await page.locator("[cmdk-item]").count(); - const hasEmpty = await page.getByText("No memory banks yet").count(); - expect(hasBanks + hasEmpty).toBeGreaterThan(0); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// 5. Bank Navigation -// ───────────────────────────────────────────────────────────────────────────── - -test.describe("Bank Navigation", () => { - test.beforeEach(async ({ page }) => { - await page.goto("/dashboard"); - await waitForAppReady(page); - await selectJonesTenant(page); - }); - - test("selecting a bank navigates to bank detail page", async ({ page }) => { - await selectFirstBank(page); - // Already asserted in helper — URL matches /banks/... - }); - - test("bank detail page has sidebar with navigation links", async ({ page }) => { - await selectFirstBank(page); - - // Sidebar is collapsed by default — links have title attributes - await expect(page.locator("a[title='Recall']")).toBeVisible(); - await expect(page.locator("a[title='Reflect']")).toBeVisible(); - await expect(page.locator("a[title='Memories']")).toBeVisible(); - await expect(page.locator("a[title='Documents']")).toBeVisible(); - await expect(page.locator("a[title='Entities']")).toBeVisible(); - await expect(page.locator("a[title='Bank Configuration']")).toBeVisible(); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// 6. Bank Detail Views (read-only) -// ───────────────────────────────────────────────────────────────────────────── - -test.describe("Bank Detail Views", () => { - test.beforeEach(async ({ page }) => { - await page.goto("/dashboard"); - await waitForAppReady(page); - await selectJonesTenant(page); - await selectFirstBank(page); - }); - - test("can navigate to Memories view with sub-tabs", async ({ page }) => { - await page.locator("a[title='Memories']").click(); - await expect(page).toHaveURL(/view=data/); - - // Sub-tabs are plain buttons (not Radix Tabs) - await expect(page.getByRole("button", { name: /World Facts/i })).toBeVisible(); - await expect(page.getByRole("button", { name: /Experience/i })).toBeVisible(); - }); - - test("can navigate to Recall view", async ({ page }) => { - await page.locator("a[title='Recall']").click(); - await expect(page).toHaveURL(/view=recall/); - }); - - test("can navigate to Reflect view", async ({ page }) => { - await page.locator("a[title='Reflect']").click(); - await expect(page).toHaveURL(/view=reflect/); - }); - - test("can navigate to Documents view", async ({ page }) => { - await page.locator("a[title='Documents']").click(); - await expect(page).toHaveURL(/view=documents/); - }); - - test("can navigate to Entities view", async ({ page }) => { - await page.locator("a[title='Entities']").click(); - await expect(page).toHaveURL(/view=entities/); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// 7. Theme Toggle -// ───────────────────────────────────────────────────────────────────────────── - -test.describe("Theme Toggle", () => { - test("can toggle between light and dark theme", async ({ page }) => { - await page.goto("/dashboard"); - await waitForAppReady(page); - - const themeButton = page.locator("button").filter({ has: page.locator("svg.lucide-sun, svg.lucide-moon") }); - await expect(themeButton).toBeVisible(); - - const htmlEl = page.locator("html"); - const initialClass = await htmlEl.getAttribute("class"); - - await themeButton.click(); - await page.waitForTimeout(300); - - const newClass = await htmlEl.getAttribute("class"); - expect(newClass).not.toBe(initialClass); - }); -}); - -// ───────────────────────────────────────────────────────────────────────────── -// 8. Cross-tenant Isolation -// ───────────────────────────────────────────────────────────────────────────── - -test.describe("Cross-tenant Isolation", () => { - test("tenant selector correctly scopes API calls", async ({ page }) => { - // Set up request listener BEFORE navigating - const apiCalls: string[] = []; - page.on("request", (req) => { - const url = req.url(); - if (url.includes("/api/") && !url.includes("_next")) { - apiCalls.push(url); - } - }); - - await page.goto("/dashboard"); - await waitForAppReady(page); - - // Switch to jones — this should trigger bank fetch with tenant param - const trigger = tenantSelector(page); - await trigger.click(); - await page.getByRole("option", { name: "household_jones" }).click(); - await page.waitForTimeout(3000); - - // Verify at least one API call included the tenant parameter - const jonesCalls = apiCalls.filter((url) => url.includes("tenant=household_jones")); - expect(jonesCalls.length).toBeGreaterThan(0); - - // Clear and switch to paula - apiCalls.length = 0; - await trigger.click(); - await page.getByRole("option", { name: "household_paula" }).click(); - await page.waitForTimeout(3000); - - const paulaCalls = apiCalls.filter((url) => url.includes("tenant=household_paula")); - expect(paulaCalls.length).toBeGreaterThan(0); - }); -}); diff --git a/hindsight-control-plane/e2e/run-prod.sh b/hindsight-control-plane/e2e/run-prod.sh deleted file mode 100644 index 985787596..000000000 --- a/hindsight-control-plane/e2e/run-prod.sh +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env bash -# Run Playwright e2e tests against the production deployment. -# -# Prerequisites: -# 1. Copy .env.e2e → .env.local and fill in the tenant API keys -# 2. Client certificates available at ../openclaw-infra/{ca,client}.{crt,key} -# -# Usage: -# ./e2e/run-prod.sh # run all tests -# ./e2e/run-prod.sh --headed # run with browser visible -# ./e2e/run-prod.sh --debug # run in debug mode -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" - -cd "$CP_DIR" - -# ── Validate .env.local has tenant keys ────────────────────────────────────── -if [[ ! -f .env.local ]]; then - echo "ERROR: .env.local not found." - echo "Copy .env.e2e to .env.local and fill in the tenant API keys." - echo " cp .env.e2e .env.local" - exit 1 -fi - -if grep -q "KEY_HERE" .env.local; then - echo "ERROR: .env.local still has placeholder keys." - echo "Fill in the real tenant API keys from the prod instance:" - echo " ssh openclaw@34.208.169.77 'grep HINDSIGHT_KEY_ /opt/openclaw/.env'" - exit 1 -fi - -# ── Validate certs exist ───────────────────────────────────────────────────── -# CP_DIR is hindsight-control-plane/, go up to code/ then into openclaw-infra/ -INFRA_DIR="${MTLS_INFRA_DIR:-$CP_DIR/../../../openclaw-infra}" -for f in ca.crt client.crt client.key; do - if [[ ! -f "$INFRA_DIR/$f" ]]; then - echo "ERROR: Missing $INFRA_DIR/$f" - echo "Ensure openclaw-infra repo is at ../openclaw-infra relative to hindsight-contrib/" - exit 1 - fi -done - -# ── Start mTLS proxy in background ────────────────────────────────────────── -echo "Starting mTLS proxy..." -MTLS_INFRA_DIR="$INFRA_DIR" node e2e/mtls-proxy.mjs & -PROXY_PID=$! - -cleanup() { - echo "Stopping mTLS proxy (pid $PROXY_PID)..." - kill "$PROXY_PID" 2>/dev/null || true - wait "$PROXY_PID" 2>/dev/null || true -} -trap cleanup EXIT - -# Wait for proxy to be ready -for i in $(seq 1 10); do - if curl -sf http://localhost:18888/ >/dev/null 2>&1 || [[ $i -eq 10 ]]; then - break - fi - sleep 0.5 -done -echo "mTLS proxy ready on :18888" - -# ── Check if dev server is running ─────────────────────────────────────────── -if ! curl -sf http://localhost:9999/ >/dev/null 2>&1; then - echo "" - echo "WARNING: Dev server not detected on :9999." - echo "Start it in another terminal: npm run dev" - echo "Waiting for dev server..." - for i in $(seq 1 30); do - if curl -sf http://localhost:9999/ >/dev/null 2>&1; then - break - fi - if [[ $i -eq 30 ]]; then - echo "ERROR: Dev server not responding after 15s. Start it with: npm run dev" - exit 1 - fi - sleep 0.5 - done -fi -echo "Dev server ready on :9999" - -# ── Run Playwright ─────────────────────────────────────────────────────────── -echo "" -echo "Running Playwright tests..." -npx playwright test "$@" diff --git a/hindsight-control-plane/package.json b/hindsight-control-plane/package.json index f6fea3645..8da974fbc 100644 --- a/hindsight-control-plane/package.json +++ b/hindsight-control-plane/package.json @@ -16,9 +16,6 @@ "build:standalone": "rm -rf standalone && SERVER_JS=$(find .next/standalone -path '*/node_modules' -prune -o -name 'server.js' -print | head -1) && test -n \"$SERVER_JS\" || (echo 'Error: server.js not found in .next/standalone - standalone build failed' && exit 1) && STANDALONE_ROOT=$(dirname \"$SERVER_JS\") && cp -r \"$STANDALONE_ROOT\" standalone && cp -r .next/standalone/node_modules standalone/node_modules && mkdir -p standalone/.next && cp -r .next/static standalone/.next/static && mkdir -p standalone/public && (cp -r public/* standalone/public/ 2>/dev/null || true)", "start": "next start", "lint": "next lint", - "test:e2e": "npx playwright test", - "test:e2e:headed": "npx playwright test --headed", - "test:e2e:prod": "bash e2e/run-prod.sh", "prepublishOnly": "npm run build" }, "keywords": [ @@ -80,7 +77,6 @@ "devDependencies": { "@eslint/eslintrc": "^3.3.3", "@eslint/js": "^9.39.2", - "@playwright/test": "^1.59.1", "@vectorize-io/hindsight-client": "file:../hindsight-clients/typescript", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", diff --git a/hindsight-control-plane/playwright.config.ts b/hindsight-control-plane/playwright.config.ts deleted file mode 100644 index 7f9338982..000000000 --- a/hindsight-control-plane/playwright.config.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { defineConfig, devices } from "@playwright/test"; - -/** - * Playwright config for e2e testing the multi-tenant dashboard. - * - * Run against prod: - * 1. Start the mTLS proxy: node e2e/mtls-proxy.mjs - * 2. Copy .env.e2e to .env.local and fill in tenant keys - * 3. Start dev server: npm run dev - * 4. Run tests: npx playwright test - */ -export default defineConfig({ - testDir: "./e2e", - fullyParallel: true, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 1 : 0, - workers: 1, // sequential — tests share browser state for tenant/bank selection - reporter: [["html", { open: "never" }], ["list"]], - - use: { - baseURL: process.env.E2E_BASE_URL || "http://localhost:9999", - trace: "on-first-retry", - screenshot: "only-on-failure", - // Increase timeouts for prod latency - actionTimeout: 15_000, - navigationTimeout: 30_000, - }, - - timeout: 60_000, - - projects: [ - { - name: "chromium", - use: { ...devices["Desktop Chrome"] }, - }, - ], - - // Don't auto-start the dev server — we expect it (and the mTLS proxy) to already be running - // webServer: { - // command: "npm run dev", - // url: "http://localhost:9999", - // reuseExistingServer: true, - // }, -}); diff --git a/hindsight-control-plane/src/lib/hindsight-client.ts b/hindsight-control-plane/src/lib/hindsight-client.ts index 8b58378a0..2ed21d7ab 100644 --- a/hindsight-control-plane/src/lib/hindsight-client.ts +++ b/hindsight-control-plane/src/lib/hindsight-client.ts @@ -2,7 +2,7 @@ * Tenant-aware Hindsight API client factory for the control plane. * * Supports two modes: - * 1. Multi-tenant: HINDSIGHT_CP_TENANT_KEY_MAP=key1:name1;key2:name2 + * 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 @@ -33,7 +33,7 @@ function parseTenantKeyMap(raw: string): TenantEntry[] { const colonIdx = entry.indexOf(":"); if (colonIdx === -1) { throw new Error( - `Invalid HINDSIGHT_CP_TENANT_KEY_MAP entry "${entry}". Expected "key:name".` + `Invalid HINDSIGHT_API_TENANT_KEY_MAP entry "${entry}". Expected "key:name".` ); } return { @@ -43,11 +43,8 @@ function parseTenantKeyMap(raw: string): TenantEntry[] { }); } -// CP-specific var takes precedence; falls back to the API key map so -// operators don't have to duplicate keys across two env vars. -const TENANT_KEY_MAP_RAW = process.env.HINDSIGHT_CP_TENANT_KEY_MAP - || process.env.HINDSIGHT_API_TENANT_KEY_MAP - || ""; +// 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 diff --git a/package-lock.json b/package-lock.json index 4866e1606..e7885d12a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,137 @@ "workspaces": [ "hindsight-clients/typescript", "hindsight-control-plane", - "hindsight-docs" + "hindsight-docs", + "hindsight-all-npm" ] }, + "hindsight-all-npm": { + "name": "@vectorize-io/hindsight-all", + "version": "0.5.0", + "license": "MIT", + "devDependencies": { + "@types/node": "^22.0.0", + "tsup": "^8.5.1", + "typescript": "^5.7.0", + "vitest": "^4.1.2" + }, + "engines": { + "node": ">=22" + } + }, + "hindsight-all-npm/node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "hindsight-all-npm/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "hindsight-all-npm/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "hindsight-all-npm/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "hindsight-all-npm/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "hindsight-all-npm/node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "hindsight-clients/typescript": { "name": "@vectorize-io/hindsight-client", - "version": "0.4.22", + "version": "0.5.0", "license": "MIT", "devDependencies": { "@hey-api/openapi-ts": "0.88.0", @@ -133,49 +258,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "hindsight-clients/typescript/node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.1.1" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, "hindsight-clients/typescript/node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -275,7 +357,7 @@ }, "hindsight-control-plane": { "name": "@vectorize-io/hindsight-control-plane", - "version": "0.4.22", + "version": "0.5.0", "license": "ISC", "dependencies": { "@chenglou/pretext": "^0.0.3", @@ -331,7 +413,6 @@ "devDependencies": { "@eslint/eslintrc": "^3.3.3", "@eslint/js": "^9.39.2", - "@playwright/test": "^1.59.1", "@vectorize-io/hindsight-client": "file:../hindsight-clients/typescript", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", @@ -5121,20 +5202,20 @@ } }, "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", "license": "MIT", "optional": true, "dependencies": { @@ -5142,9 +5223,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "license": "MIT", "optional": true, "dependencies": { @@ -7359,20 +7440,14 @@ "node": ">=8.0.0" } }, - "node_modules/@playwright/test": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", - "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.59.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" + "node_modules/@oxc-project/types": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" } }, "node_modules/@pnpm/config.env-replace": { @@ -8620,24 +8695,10 @@ "url": "https://opencollective.com/immer" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", "cpu": [ "arm64" ], @@ -8646,12 +8707,15 @@ "optional": true, "os": [ "android" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", "cpu": [ "arm64" ], @@ -8660,12 +8724,15 @@ "optional": true, "os": [ "darwin" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", "cpu": [ "x64" ], @@ -8674,26 +8741,15 @@ "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", - "cpu": [ - "arm64" ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", "cpu": [ "x64" ], @@ -8702,12 +8758,15 @@ "optional": true, "os": [ "freebsd" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", "cpu": [ "arm" ], @@ -8716,26 +8775,32 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", "cpu": [ - "arm" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", "cpu": [ "arm64" ], @@ -8744,22 +8809,315 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", "cpu": [ - "arm64" + "ppc64" ], "dev": true, "license": "MIT", "optional": true, "os": [ "linux" - ] - }, + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@rollup/rollup-linux-loong64-gnu": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", @@ -9700,6 +10058,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -9987,6 +10356,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -10889,6 +11265,10 @@ "d3-transition": "^3.0.1" } }, + "node_modules/@vectorize-io/hindsight-all": { + "resolved": "hindsight-all-npm", + "link": true + }, "node_modules/@vectorize-io/hindsight-client": { "resolved": "hindsight-clients/typescript", "link": true @@ -10906,6 +11286,119 @@ "node": ">= 20" } }, + "node_modules/@vitest/expect": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.4", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.4", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -11624,6 +12117,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -12434,6 +12937,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -14525,9 +15038,9 @@ } }, "node_modules/defu": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", "dev": true, "license": "MIT" }, @@ -15947,6 +16460,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -19891,15 +20414,15 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash-es": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", - "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, "node_modules/lodash.debounce": { @@ -23080,6 +23603,17 @@ "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -23726,52 +24260,6 @@ "pathe": "^2.0.3" } }, - "node_modules/playwright": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", - "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", - "devOptional": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.59.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", - "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -23819,9 +24307,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", "funding": [ { "type": "opencollective", @@ -24418,6 +24906,49 @@ "postcss": "^8.4" } }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/postcss-loader": { "version": "7.3.4", "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-7.3.4.tgz", @@ -26798,6 +27329,40 @@ "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", "license": "Unlicense" }, + "node_modules/rolldown": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + } + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -27582,6 +28147,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -27838,6 +28410,13 @@ "node": ">=8" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -28632,6 +29211,13 @@ "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", @@ -28683,6 +29269,16 @@ "node": "^18.0.0 || >=20.0.0" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -29753,6 +30349,442 @@ "d3-timer": "^3.0.1" } }, + "node_modules/vite": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.15", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/vscode-jsonrpc": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", @@ -30445,6 +31477,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/widest-line": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", From 6e478db388977ca4b45636db70e720b89d9128a6 Mon Sep 17 00:00:00 2001 From: Brian Jones Date: Sat, 25 Apr 2026 20:19:40 -0700 Subject: [PATCH 10/11] fix(cp): migrate upstream-added routes to tenant-aware client These four routes arrived from upstream during the rebase onto main and still used the legacy single-tenant client signatures, which break against this branch's tenant-scoped getDataplaneHeaders(tenant, extra) and the removed lowLevelClient module export: - entities/graph: switched to getClientForTenant(tenant).lowLevelClient - documents/[id]/reprocess: tenant + dataplaneBankUrl + encodeURIComponent - documents/[id]/chunks: tenant + dataplaneBankUrl + encodeURIComponent - stats/[agentId]/memories-timeseries: pass tenant to getDataplaneHeaders Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/app/api/documents/[documentId]/chunks/route.ts | 10 +++++++--- .../app/api/documents/[documentId]/reprocess/route.ts | 7 ++++--- .../src/app/api/entities/graph/route.ts | 4 +++- .../api/stats/[agentId]/memories-timeseries/route.ts | 3 ++- 4 files changed, 16 insertions(+), 8 deletions(-) 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/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/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) { From b55c8f9d99f9ebd5ed54d885232c394936b76eff Mon Sep 17 00:00:00 2001 From: Brian Jones Date: Sat, 25 Apr 2026 20:55:45 -0700 Subject: [PATCH 11/11] fix(cp): repair multi-tenant dashboard navigation quirks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related issues in the multi-tenant dashboard from c25165b7: 1. Switching tenant left the previous bank's data on screen because per-bank views (DataView, MentalModelsView, etc.) cache fetched results in component state and don't have access to the tenant change. setCurrentTenant now navigates to /dashboard so those components unmount. 2. A bookmarked /banks/ URL could resolve against a different tenant restored from localStorage, leaving the page rendering against an inaccessible bank. BankProvider now redirects to /dashboard once the tenant's banks have loaded if the URL bank isn't in that tenant. 3. The URL effect (setCurrentBank from /banks/) raced with the tenant effect (setCurrentBank(null) on tenant change), so direct links loaded with an empty bank dropdown until the user manually re-selected. Made currentBank a pure derivation of the URL — the tenant effect now only triggers a banks reload. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/lib/bank-context.tsx | 31 +++++++++++++++---- .../src/lib/tenant-context.tsx | 27 ++++++++++------ 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/hindsight-control-plane/src/lib/bank-context.tsx b/hindsight-control-plane/src/lib/bank-context.tsx index 3c0d1af0f..4be36575b 100644 --- a/hindsight-control-plane/src/lib/bank-context.tsx +++ b/hindsight-control-plane/src/lib/bank-context.tsx @@ -1,7 +1,7 @@ "use client"; import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from "react"; -import { usePathname } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { client } from "./api"; import { useTenant } from "./tenant-context"; @@ -16,9 +16,11 @@ 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); @@ -30,26 +32,43 @@ export function BankProvider({ children }: { children: React.ReactNode }) { 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; - setCurrentBank(null); + 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 ( {children} diff --git a/hindsight-control-plane/src/lib/tenant-context.tsx b/hindsight-control-plane/src/lib/tenant-context.tsx index 98e4dd1f9..b9f6bdbf2 100644 --- a/hindsight-control-plane/src/lib/tenant-context.tsx +++ b/hindsight-control-plane/src/lib/tenant-context.tsx @@ -1,6 +1,7 @@ "use client"; import React, { createContext, useContext, useState, useEffect, useCallback } from "react"; +import { useRouter } from "next/navigation"; import { client } from "./api"; interface TenantContextType { @@ -21,20 +22,28 @@ 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 - } - }, []); + 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() {