mirror of
https://github.com/vectorize-io/hindsight.git
synced 2026-09-14 19:31:49 +08:00
feat(extensions): add hindsight-extensions registry and unbundle Supabase (#3988)
* feat(extensions): add hindsight-extensions registry and unbundle Supabase
Extensions were only ever bundle-able or nothing: shipping one meant putting
it in `hindsight_api.extensions.builtin`, where it becomes maintainer-owned
forever, lands in every image, and — because `extensions/__init__.py` eagerly
re-exported every implementation — drags its dependencies into core's import
graph. That pipe is why a third-party IdP's JWT client was a direct dependency
of every Hindsight install.
Add `hindsight-extensions/` as the registry for extensions distributed
separately from the server. Its README is the contract: slots and how config
env vars map onto them, how to write an extension, the package layout and
naming (`hindsight-extensions/<name>/` -> `hindsight-ext-<name>` ->
`hindsight_ext_<name>`), and Docker packaging.
Move `SupabaseTenantExtension` there as the first entry, published as
`hindsight-ext-supabase-tenant`. All 54 of its tests move with it, plus two new
ones asserting the documented `hindsight_ext_supabase_tenant:...` env value
actually resolves through `load_extension`.
Two decisions worth their comments:
- The extension does NOT declare `hindsight-api-slim` as a runtime dependency.
The server is the host process that imports it, not something it installs;
declaring it would let `pip install` of an extension silently move the server
version underneath a running deployment. It is a dev extra, resolved from the
local checkout via `tool.uv.sources` (dev-only metadata, verified absent from
the built wheel).
- The Docker example installs with `uv pip install --python
/app/api/.venv/bin/python`, matching docker-compose/custom-models: the image's
venv was created by `uv sync` and ships no `pip`, so a bare `pip install`
lands in user site-packages and is invisible to the server.
Core changes:
- `extensions/__init__.py` and `builtin/__init__.py` export interfaces only.
Nothing needed the concrete re-exports — the loader imports by path — and
dropping them is what lets an extension have optional dependencies at all.
- `builtin/supabase_tenant.py` stays for one minor release as a module whose
`__getattr__` raises the migration instructions. `load_extension` wraps a
missing *attribute*, not a failed import, so the ImportError propagates with
its message intact instead of surfacing as "class not found".
- Drop the direct `PyJWT[crypto]` dependency: no core module imports `jwt` any
more. Note this does not shrink the install — `mcp` pulls pyjwt transitively
and `cryptography` is already pinned directly — so the win here is ownership
and import graph, not bytes.
Locks are not checked in for extensions: `tool.uv.sources` pins the whole
api-slim tree, so every core dependency bump would leave them stale. CI runs
`uv sync --extra dev` and retriggers on `core` changes, since these tests run
against the server's interfaces.
Docs point at the registry rather than restating it, and the Deploying section's
Docker recipe was replaced — it named an image (`vectorize/hindsight-api`) and a
PYTHONPATH volume-mount pattern that no longer exist.
Also includes two one-line generated-file syncs in skills/hindsight-docs
(quickstart, installation) that were already stale on main; regenerating the
docs skill picks them up.
* refactor(extensions): ship extensions by image, drop the compat shim
Follow-up on review. Three changes to how an extension is distributed:
- Delete `builtin/supabase_tenant.py`. An install pinned to the old path now
fails at startup with ModuleNotFoundError rather than a guided message. The
docs carry the migration instead.
- Extensions are not published to PyPI. There is no wheel, no version and no
release step: the unit of distribution is an image built on top of Hindsight
that installs the extension's dependencies and copies the package onto
PYTHONPATH. That drops the whole "declare hindsight-api-slim only as a dev
extra" problem — nothing resolves dependencies against a running server any
more.
- The pyproject is now test-harness only (`package = false`, no build backend,
no distribution metadata), and says so in a comment so nobody re-adds
packaging to it.
Docs say 0.9.3, not 0.10.
Since the Dockerfile is now the distribution mechanism rather than an example,
CI builds it — its final `import` step is the only thing proving the extension
is reachable from the interpreter the server actually runs. It builds against
`:latest-slim` via a HINDSIGHT_IMAGE build arg to keep the pull cheap.
Verified against the real image, not just locally:
docker build -f hindsight-extensions/supabase-tenant/Dockerfile \
--build-arg HINDSIGHT_IMAGE=ghcr.io/vectorize-io/hindsight:latest-slim ...
-> load_extension('TENANT', TenantExtension) inside the container returns
SupabaseTenantExtension with its config resolved from the env vars.
Worth noting from that build: `uv pip install 'PyJWT[crypto]' httpx` reports
"Checked 2 packages" — both are already in the base image transitively. The
line stays because the extension should pin what it imports rather than rely on
the server's transitive tree, but it costs nothing today.
56 extension tests pass; the 3 remaining core tests (which assert no
implementation is re-exported and no core module imports jwt) pass.
* fix(tests): import ApiKeyTenantExtension from its module, not the package
Dropping the concrete re-exports from `hindsight_api.extensions` broke
`tests/test_extensions.py`, which imported `ApiKeyTenantExtension` from the
package inside a multi-line parenthesised import. A collection ImportError
fails the whole shard, which is why all three test-api shards and all six LLM
acceptance jobs went red at once on the previous push.
I'd checked for this with a single-line grep, which cannot see a name inside a
parenthesised import list. Re-checked with an AST scan over every package in
the repo (this was the only occurrence) and by collecting the full suite:
7780 tests collect clean.
This commit is contained in:
@@ -46,6 +46,7 @@ jobs:
|
||||
integrations-zcode: ${{ steps.filter.outputs.integrations-zcode }}
|
||||
integrations-agent-plugin: ${{ steps.filter.outputs.integrations-agent-plugin }}
|
||||
integrations-copilot-cli: ${{ steps.filter.outputs.integrations-copilot-cli }}
|
||||
extensions-supabase-tenant: ${{ steps.filter.outputs.extensions-supabase-tenant }}
|
||||
integrations-crewai: ${{ steps.filter.outputs.integrations-crewai }}
|
||||
integrations-litellm: ${{ steps.filter.outputs.integrations-litellm }}
|
||||
integrations-pydantic-ai: ${{ steps.filter.outputs.integrations-pydantic-ai }}
|
||||
@@ -168,6 +169,8 @@ jobs:
|
||||
- 'hindsight-integrations/cursor-cli/**'
|
||||
integrations-copilot-cli:
|
||||
- 'hindsight-integrations/copilot-cli/**'
|
||||
extensions-supabase-tenant:
|
||||
- 'hindsight-extensions/supabase-tenant/**'
|
||||
integrations-crewai:
|
||||
- 'hindsight-integrations/crewai/**'
|
||||
integrations-litellm:
|
||||
@@ -3786,6 +3789,53 @@ jobs:
|
||||
working-directory: ./hindsight-integrations/copilot-cli
|
||||
run: uv run pytest tests -v
|
||||
|
||||
test-extension-supabase-tenant:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.extensions-supabase-tenant == 'true' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
# No --frozen: the lock is not checked in, because tool.uv.sources resolves
|
||||
# hindsight-api-slim from the local checkout and its dependency tree moves
|
||||
# with core.
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-extensions/supabase-tenant
|
||||
run: uv sync
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-extensions/supabase-tenant
|
||||
run: uv run pytest tests -v
|
||||
|
||||
# The Dockerfile IS the distribution mechanism — its final `import` step is
|
||||
# the only thing proving the extension is reachable from the interpreter the
|
||||
# server runs. Build it from the repo root, where the sources are in context.
|
||||
- name: Build the extension image
|
||||
run: |
|
||||
docker build \
|
||||
-f hindsight-extensions/supabase-tenant/Dockerfile \
|
||||
--build-arg HINDSIGHT_IMAGE=ghcr.io/vectorize-io/hindsight:latest-slim \
|
||||
-t hindsight-with-supabase .
|
||||
|
||||
test-crewai-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
@@ -5373,6 +5423,7 @@ jobs:
|
||||
- test-flowise-integration
|
||||
- test-obsidian-integration
|
||||
- test-agent-framework-integration
|
||||
- test-extension-supabase-tenant
|
||||
- test-crewai-integration
|
||||
- test-langgraph-integration
|
||||
- test-superagent-integration
|
||||
|
||||
@@ -13,15 +13,16 @@ Example:
|
||||
|
||||
Extensions receive an ExtensionContext that provides a controlled API for interacting
|
||||
with the system (e.g., running migrations for tenant schemas).
|
||||
|
||||
This package exports the extension *interfaces* only. Concrete implementations —
|
||||
including the ones bundled under ``hindsight_api.extensions.builtin`` — are
|
||||
imported by path at load time, so importing this package never pulls in an
|
||||
implementation's dependencies. Extensions distributed outside the server live in
|
||||
``hindsight-extensions/`` in the repository.
|
||||
"""
|
||||
|
||||
from hindsight_api.extensions.bank_tables import BankScopedTable
|
||||
from hindsight_api.extensions.base import Extension
|
||||
from hindsight_api.extensions.builtin import (
|
||||
ApiKeyTenantExtension,
|
||||
MemoryDefenseRegexExtension,
|
||||
SupabaseTenantExtension,
|
||||
)
|
||||
from hindsight_api.extensions.context import DefaultExtensionContext, ExtensionContext
|
||||
from hindsight_api.extensions.http import HttpExtension
|
||||
from hindsight_api.extensions.loader import load_extension
|
||||
@@ -120,8 +121,6 @@ __all__ = [
|
||||
"MentalModelRefreshContext",
|
||||
"MentalModelRefreshResult",
|
||||
# Tenant/Auth
|
||||
"ApiKeyTenantExtension",
|
||||
"SupabaseTenantExtension",
|
||||
"AuthenticationError",
|
||||
"RequestContext",
|
||||
"Tenant",
|
||||
@@ -132,7 +131,6 @@ __all__ = [
|
||||
"DefenseDecision",
|
||||
"DefensePolicy",
|
||||
"MemoryDefenseExtension",
|
||||
"MemoryDefenseRegexExtension",
|
||||
"PolicyRule",
|
||||
"apply_redaction",
|
||||
"parse_policy",
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
"""
|
||||
Built-in extension implementations.
|
||||
|
||||
These are ready-to-use implementations of the extension interfaces.
|
||||
They can be used directly or serve as examples for custom implementations.
|
||||
These are the extensions that ship with the server: they have no dependencies
|
||||
beyond the Hindsight core and are useful to any deployment. Everything else —
|
||||
integrations with a specific identity provider, vendor, or deployment style —
|
||||
lives in its own package under ``hindsight-extensions/`` in the repository and
|
||||
is installed alongside the server.
|
||||
|
||||
Available built-in extensions:
|
||||
- ApiKeyTenantExtension: Simple API key validation with public schema
|
||||
- SupabaseTenantExtension: Supabase JWT validation with per-user schema isolation
|
||||
- MemoryDefenseRegexExtension: Regex-based memory defense policies
|
||||
|
||||
Concrete classes are deliberately not re-exported from
|
||||
``hindsight_api.extensions``: extensions are resolved by import path at load
|
||||
time, so re-exporting them would make every extension's imports (and therefore
|
||||
its dependencies) part of the core import graph.
|
||||
|
||||
Example usage:
|
||||
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
|
||||
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.supabase_tenant:SupabaseTenantExtension
|
||||
"""
|
||||
|
||||
from hindsight_api.extensions.builtin.memory_defense_regex import MemoryDefenseRegexExtension
|
||||
from hindsight_api.extensions.builtin.supabase_tenant import SupabaseTenantExtension
|
||||
from hindsight_api.extensions.builtin.tenant import ApiKeyTenantExtension
|
||||
|
||||
__all__ = [
|
||||
"ApiKeyTenantExtension",
|
||||
"MemoryDefenseRegexExtension",
|
||||
"SupabaseTenantExtension",
|
||||
]
|
||||
|
||||
@@ -33,7 +33,6 @@ dependencies = [
|
||||
# Contained to engine/token_encoding.py — see that module for the rationale.
|
||||
"quicktok-v1>=0.4.0",
|
||||
"httpx>=0.27.0",
|
||||
"PyJWT[crypto]>=2.8.0",
|
||||
"fastmcp>=3.2.0", # SSRF/path traversal, OAuth confused deputy, command injection fixes
|
||||
"python-dateutil>=2.8.0",
|
||||
# Two coupled reasons for these floors — keep all six pins moving together:
|
||||
@@ -89,7 +88,7 @@ dependencies = [
|
||||
"cryptography>=50.0.0", # GHSA-g6cj-pr64-35w5: Bleichenbacher oracle in PKCS#7 EnvelopedData decryption (supersedes the >=48.0.1 GHSA-537c-gmf6-5ccf floor). GHSA-537c-gmf6-5ccf: bundled-OpenSSL OOB read fix needs >=48.0.1. Prior <47 cap (47.0.0 SIGILL on ARM64 Docker/Podman, pyca/cryptography#14733) lifted — 47/48/49 verified importing + RSA sign/verify cleanly on linux/arm64 (Docker on Apple Silicon) and native arm64 macOS; upstream issue closed unconfirmed.
|
||||
"filelock>=3.20.1", # TOCTOU race condition fix
|
||||
"authlib>=1.6.9", # Account takeover/JWS header injection vulnerability fix
|
||||
"pyjwt>=2.12.0", # Accepts unknown crit header extensions fix
|
||||
"pyjwt>=2.12.0", # Accepts unknown crit header extensions fix. Transitive only (via mcp) since the Supabase tenant extension moved to hindsight-extensions/supabase-tenant — no core module imports jwt.
|
||||
"orjson>=3.11.6", # Unbounded recursion DoS fix
|
||||
"python-multipart>=0.0.22", # Arbitrary file write via non-default configuration fix
|
||||
"tornado>=6.5.5", # DoS multipart/incomplete cookie validation fix
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""The server must not carry extension implementations into its import graph.
|
||||
|
||||
Extensions are resolved by import path at load time. Re-exporting a concrete
|
||||
implementation from ``hindsight_api.extensions`` would make that implementation's
|
||||
dependencies part of core's import graph, which is what kept the Supabase
|
||||
extension (and its JWT stack) inside every install before it moved to
|
||||
``hindsight-extensions/supabase-tenant``.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import hindsight_api
|
||||
import hindsight_api.extensions as extensions
|
||||
|
||||
_PACKAGE_ROOT = Path(hindsight_api.__file__).parent
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name",
|
||||
["ApiKeyTenantExtension", "MemoryDefenseRegexExtension", "SupabaseTenantExtension"],
|
||||
)
|
||||
def test_concrete_extensions_are_not_re_exported(name: str):
|
||||
assert name not in extensions.__all__
|
||||
assert not hasattr(extensions, name)
|
||||
|
||||
|
||||
def test_builtin_package_exports_only_the_bundled_extensions():
|
||||
from hindsight_api.extensions import builtin
|
||||
|
||||
assert sorted(builtin.__all__) == ["ApiKeyTenantExtension", "MemoryDefenseRegexExtension"]
|
||||
|
||||
|
||||
def test_no_core_module_imports_jwt():
|
||||
"""`jwt` was a direct dependency solely for the Supabase extension."""
|
||||
importers = [
|
||||
path.relative_to(_PACKAGE_ROOT).as_posix()
|
||||
for path in _PACKAGE_ROOT.rglob("*.py")
|
||||
if any(line.startswith(("import jwt", "from jwt ")) for line in path.read_text(encoding="utf-8").splitlines())
|
||||
]
|
||||
assert importers == []
|
||||
@@ -7,7 +7,6 @@ from fastapi import APIRouter
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from hindsight_api.extensions import (
|
||||
ApiKeyTenantExtension,
|
||||
AuthenticationError,
|
||||
BankReadContext,
|
||||
BankReadOperation,
|
||||
@@ -33,6 +32,7 @@ from hindsight_api.extensions import (
|
||||
ValidationResult,
|
||||
load_extension,
|
||||
)
|
||||
from hindsight_api.extensions.builtin.tenant import ApiKeyTenantExtension
|
||||
|
||||
|
||||
class TestExtensionLoader:
|
||||
|
||||
@@ -295,7 +295,7 @@ async def test_mcp_tool_execution_with_different_mcp_and_tenant_tokens(memory):
|
||||
from httpx import ASGITransport
|
||||
|
||||
from hindsight_api.api import create_app
|
||||
from hindsight_api.extensions import ApiKeyTenantExtension
|
||||
from hindsight_api.extensions.builtin.tenant import ApiKeyTenantExtension
|
||||
|
||||
mcp_token = "mcp-secret-token"
|
||||
tenant_key = "tenant-secret-key"
|
||||
@@ -347,7 +347,7 @@ async def test_mcp_rejects_wrong_mcp_token_even_if_matches_tenant_key(memory):
|
||||
from httpx import ASGITransport
|
||||
|
||||
from hindsight_api.api import create_app
|
||||
from hindsight_api.extensions import ApiKeyTenantExtension
|
||||
from hindsight_api.extensions.builtin.tenant import ApiKeyTenantExtension
|
||||
|
||||
mcp_token = "mcp-secret-token"
|
||||
tenant_key = "tenant-secret-key"
|
||||
|
||||
@@ -19,18 +19,13 @@ HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTen
|
||||
HINDSIGHT_API_TENANT_API_KEY=your-secret-key
|
||||
```
|
||||
|
||||
**Built-in: SupabaseTenantExtension**
|
||||
**No longer built in: SupabaseTenantExtension**
|
||||
|
||||
Validates [Supabase](https://supabase.com) JWTs and provides multi-tenant memory isolation. Each authenticated user gets their own PostgreSQL schema (`{prefix}_{user_id}`), ensuring complete data separation. Performs local JWT verification using JWKS for optimal performance (no network call per request).
|
||||
Validates [Supabase](https://supabase.com) JWTs and gives each authenticated user their own PostgreSQL schema. It now lives in the [extensions registry](https://github.com/vectorize-io/hindsight/tree/main/hindsight-extensions/supabase-tenant), which documents its configuration and ships a Dockerfile that builds an image with it.
|
||||
|
||||
```bash
|
||||
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.supabase_tenant:SupabaseTenantExtension
|
||||
HINDSIGHT_API_TENANT_SUPABASE_URL=https://your-project.supabase.co
|
||||
# Optional - only needed for legacy HS256 projects or health check
|
||||
HINDSIGHT_API_TENANT_SUPABASE_SERVICE_KEY=your-service-role-key
|
||||
```
|
||||
|
||||
See the [source code](https://github.com/vectorize-io/hindsight/blob/main/hindsight-api-slim/hindsight_api/extensions/builtin/supabase_tenant.py) for complete configuration options and implementation details.
|
||||
:::warning Breaking change in 0.9.3
|
||||
Up to 0.9.2 this extension was built in, at `hindsight_api.extensions.builtin.supabase_tenant`. That path no longer exists, so an install still pointing at it fails at startup with `ModuleNotFoundError`. Add the extension to your image and set `HINDSIGHT_API_TENANT_EXTENSION=hindsight_ext_supabase_tenant:SupabaseTenantExtension`. All `HINDSIGHT_API_TENANT_*` settings and the schema naming are unchanged.
|
||||
:::
|
||||
|
||||
For other multi-tenant setups with separate schemas per tenant (e.g., custom JWT-based auth), implement a custom `TenantExtension`.
|
||||
|
||||
@@ -314,29 +309,25 @@ class MyMCPExtension(MCPExtension):
|
||||
|
||||
### With Docker
|
||||
|
||||
Mount your extension package as a volume and set the environment variable:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
hindsight-api:
|
||||
image: vectorize/hindsight-api:latest
|
||||
volumes:
|
||||
- ./my_extensions:/app/my_extensions
|
||||
environment:
|
||||
- HINDSIGHT_API_TENANT_EXTENSION=my_extensions.auth:JwtTenantExtension
|
||||
- HINDSIGHT_API_TENANT_JWT_SECRET=${JWT_SECRET}
|
||||
- PYTHONPATH=/app
|
||||
```
|
||||
|
||||
Or build a custom image with your extensions:
|
||||
Extensions are not bundled in the image. Build one on top of Hindsight that installs your extension's dependencies and copies it in. Install into the image's virtualenv explicitly — it was created by `uv sync` and ships no `pip` of its own, so a bare `pip install` lands where the server can't see it:
|
||||
|
||||
```dockerfile
|
||||
FROM vectorize/hindsight-api:latest
|
||||
COPY my_extensions /app/my_extensions
|
||||
ENV PYTHONPATH=/app
|
||||
FROM ghcr.io/vectorize-io/hindsight:latest
|
||||
|
||||
RUN uv pip install --python /app/api/.venv/bin/python --no-cache \
|
||||
'PyJWT[crypto]>=2.12.0' 'httpx>=0.27.0'
|
||||
|
||||
COPY my_extension /app/extensions/my_extension
|
||||
ENV PYTHONPATH=/app/extensions
|
||||
|
||||
# Fail the build, not the first request, if it isn't importable.
|
||||
RUN /app/api/.venv/bin/python -c "import my_extension"
|
||||
```
|
||||
|
||||
Then point the service at that image and pass the extension's variables as environment. Give the API and worker containers the same extension configuration — the worker uses the tenant extension to enumerate schemas for background consolidation.
|
||||
|
||||
See the [extensions registry README](https://github.com/vectorize-io/hindsight/blob/main/hindsight-extensions/README.md#docker-packaging) for the full recipe.
|
||||
|
||||
### Bare Metal
|
||||
|
||||
Install your extension package in the same Python environment as Hindsight:
|
||||
@@ -370,4 +361,6 @@ Custom extensions that solve common use cases are welcome contributions to the H
|
||||
- Metrics exporters (Datadog, New Relic, etc.)
|
||||
- Custom HTTP endpoints for specific platforms
|
||||
|
||||
Consider contributing it to the `hindsight_api.extensions.builtin` package. Open an issue or pull request on [GitHub](https://github.com/vectorize-io/hindsight) to discuss your extension.
|
||||
Add it to the [extensions registry](https://github.com/vectorize-io/hindsight/blob/main/hindsight-extensions/README.md) — either as a directory under `hindsight-extensions/`, or as a registry entry linking to your own repository. That README covers the layout, the development workflow, and Docker packaging.
|
||||
|
||||
Extensions live outside the server so that installing Hindsight does not pull in a vendor's client library, and so changing an extension does not require a Hindsight release. Only extensions that add no dependencies and are useful to any deployment (`ApiKeyTenantExtension`, `MemoryDefenseRegexExtension`) stay in `hindsight_api.extensions.builtin`.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Extension locks are not checked in: they pin the whole hindsight-api-slim
|
||||
# dependency tree through the local `tool.uv.sources` path, so any core
|
||||
# dependency change would leave every extension's lock stale. CI resolves
|
||||
# fresh with `uv sync`.
|
||||
uv.lock
|
||||
@@ -0,0 +1,282 @@
|
||||
# Hindsight Extensions
|
||||
|
||||
Extensions customise the Hindsight API server without forking it: multi-tenancy and
|
||||
authentication, extra HTTP endpoints, extra MCP tools, and hooks around
|
||||
retain/recall/reflect. They are ordinary Python packages that the server imports by
|
||||
path at startup.
|
||||
|
||||
This directory is the **registry**. Each subdirectory is an extension that lives
|
||||
outside the server, so installing Hindsight does not drag in a third-party vendor's
|
||||
client library and changing an extension does not require a Hindsight release.
|
||||
|
||||
Extensions here are not published to PyPI. You ship one by building an image on top of
|
||||
Hindsight that copies the extension in — see [Packaging](#packaging-an-extension).
|
||||
|
||||
## Registry
|
||||
|
||||
| Extension | Slot | What it does |
|
||||
| --- | --- | --- |
|
||||
| [`supabase-tenant`](./supabase-tenant) | `TENANT` | Validates [Supabase](https://supabase.com) Auth JWTs and gives each user their own Postgres schema |
|
||||
|
||||
Extensions maintained outside this repository can be listed here too — open a PR
|
||||
adding a row that links to yours.
|
||||
|
||||
### What stays in the server
|
||||
|
||||
Two extensions ship with `hindsight-api-slim` because they add no dependencies and
|
||||
any deployment may want them:
|
||||
|
||||
- `hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension` — single shared API
|
||||
key, single schema.
|
||||
- `hindsight_api.extensions.builtin.memory_defense_regex:MemoryDefenseRegexExtension`
|
||||
— regex-based memory defense policies.
|
||||
|
||||
Anything that talks to a specific vendor, identity provider, or deployment style
|
||||
belongs here instead.
|
||||
|
||||
---
|
||||
|
||||
## Extension slots
|
||||
|
||||
The server loads at most one extension per slot, each from its own environment
|
||||
variable in `module.path:ClassName` form:
|
||||
|
||||
| Slot | Environment variable | Base class |
|
||||
| --- | --- | --- |
|
||||
| Tenancy / auth | `HINDSIGHT_API_TENANT_EXTENSION` | `TenantExtension` |
|
||||
| HTTP endpoints | `HINDSIGHT_API_HTTP_EXTENSION` | `HttpExtension` |
|
||||
| MCP tools | `HINDSIGHT_API_MCP_EXTENSION` | `MCPExtension` |
|
||||
| Operation hooks | `HINDSIGHT_API_OPERATION_VALIDATOR_EXTENSION` | `OperationValidatorExtension` |
|
||||
| Memory defense | `HINDSIGHT_API_MEMORY_DEFENSE_EXTENSION` | `MemoryDefenseExtension` |
|
||||
|
||||
Every other environment variable sharing the slot's prefix becomes the extension's
|
||||
config, lowercased and with the prefix stripped. For the `TENANT` slot:
|
||||
|
||||
```bash
|
||||
HINDSIGHT_API_TENANT_EXTENSION=hindsight_ext_supabase_tenant:SupabaseTenantExtension
|
||||
HINDSIGHT_API_TENANT_SUPABASE_URL=https://xxx.supabase.co # -> config["supabase_url"]
|
||||
HINDSIGHT_API_TENANT_SCHEMA_PREFIX=user # -> config["schema_prefix"]
|
||||
```
|
||||
|
||||
There is nothing to register: if the class is importable in the server's Python
|
||||
environment and subclasses the slot's base class, it loads.
|
||||
|
||||
---
|
||||
|
||||
## Writing an extension
|
||||
|
||||
```python
|
||||
# hindsight_ext_myauth/extension.py
|
||||
from hindsight_api.extensions.tenant import AuthenticationError, Tenant, TenantContext, TenantExtension
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
|
||||
class MyTenantExtension(TenantExtension):
|
||||
def __init__(self, config: dict[str, str]) -> None:
|
||||
super().__init__(config)
|
||||
self.secret = config.get("secret")
|
||||
if not self.secret:
|
||||
raise ValueError("HINDSIGHT_API_TENANT_SECRET is required")
|
||||
|
||||
async def on_startup(self) -> None:
|
||||
"""Open clients, warm caches. Raise to stop the server booting misconfigured."""
|
||||
|
||||
async def authenticate(self, context: RequestContext) -> TenantContext:
|
||||
if context.api_key != self.secret:
|
||||
raise AuthenticationError("Invalid API key")
|
||||
return TenantContext(schema_name="my_tenant")
|
||||
|
||||
async def list_tenants(self) -> list[Tenant]:
|
||||
"""Schemas the background worker should process."""
|
||||
return [Tenant(schema="my_tenant")]
|
||||
|
||||
async def on_shutdown(self) -> None:
|
||||
"""Close what on_startup opened."""
|
||||
```
|
||||
|
||||
Things worth knowing before you write one:
|
||||
|
||||
- **Validate config in `__init__`.** A misconfigured extension should fail the server's
|
||||
startup, not the first request that hits it.
|
||||
- **`self.context`** is an `ExtensionContext`, the supported API into the server. For
|
||||
tenant extensions the important call is `await self.context.run_migration(schema)`,
|
||||
which provisions a new tenant schema. Cache the schemas you have already migrated —
|
||||
`authenticate` runs on every request.
|
||||
- **`list_tenants()` drives the background worker.** A schema you never return gets no
|
||||
consolidation or maintenance, so returning only schemas seen since the last restart
|
||||
means tenants go stale until they are used again.
|
||||
- **Extensions run in-process, inside the auth boundary.** A `TenantExtension` decides
|
||||
which tenant's data a request can reach; treat schema names derived from user input
|
||||
as untrusted and validate their shape before they reach a schema name.
|
||||
|
||||
The interfaces live in `hindsight-api-slim/hindsight_api/extensions/`; each base class
|
||||
documents the full method set.
|
||||
|
||||
---
|
||||
|
||||
## Packaging an extension
|
||||
|
||||
Layout — one directory per extension, mirroring `supabase-tenant/`:
|
||||
|
||||
```
|
||||
hindsight-extensions/<name>/
|
||||
├── pyproject.toml # test harness only — not a published distribution
|
||||
├── README.md # config reference + how to build an image with it
|
||||
├── Dockerfile # the image that ships it
|
||||
├── hindsight_ext_<name>/
|
||||
│ ├── __init__.py # re-export the class for a short import path
|
||||
│ └── extension.py
|
||||
└── tests/
|
||||
```
|
||||
|
||||
Naming keeps the env var short and unambiguous:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Directory | `hindsight-extensions/supabase-tenant/` |
|
||||
| Import package | `hindsight_ext_supabase_tenant` |
|
||||
| Env value | `hindsight_ext_supabase_tenant:SupabaseTenantExtension` |
|
||||
|
||||
There is no version number, no wheel and no release step. The unit of distribution is
|
||||
the image you build, so the extension is exactly as current as the checkout you built
|
||||
it from.
|
||||
|
||||
### The pyproject is for tests, not packaging
|
||||
|
||||
The `pyproject.toml` exists so `uv run pytest` works. It declares `package = false`,
|
||||
so uv puts the dependencies in a virtualenv and leaves the sources on the path without
|
||||
building anything:
|
||||
|
||||
```toml
|
||||
[project]
|
||||
name = "hindsight-ext-<name>"
|
||||
version = "0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"PyJWT[crypto]>=2.12.0", # whatever your extension imports
|
||||
"httpx>=0.27.0",
|
||||
"hindsight-api-slim", # test-time only: the interfaces you write against
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
|
||||
[tool.uv.sources]
|
||||
hindsight-api-slim = { path = "../../hindsight-api-slim", editable = true }
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
asyncio_mode = "auto"
|
||||
```
|
||||
|
||||
`hindsight-api-slim` belongs here and **only** here. At runtime the server is the host
|
||||
process that imports your extension, not something your extension installs — an
|
||||
extension that pulled the server in as a dependency could move the server version
|
||||
underneath the deployment it was being added to.
|
||||
|
||||
### Develop and test
|
||||
|
||||
From the extension directory:
|
||||
|
||||
```bash
|
||||
uv sync # deps plus the server from ../../hindsight-api-slim
|
||||
uv run pytest tests -v
|
||||
```
|
||||
|
||||
Extension tests are plain unit tests — construct the class with a config dict, mock
|
||||
whatever it talks to, and assert on the `TenantContext` / `ValidationResult` it
|
||||
returns. They do not need a database.
|
||||
|
||||
To run a real server against your extension without building an image:
|
||||
|
||||
```bash
|
||||
cd ../../hindsight-api-slim
|
||||
PYTHONPATH=../hindsight-extensions/myauth \
|
||||
HINDSIGHT_API_TENANT_EXTENSION=hindsight_ext_myauth:MyTenantExtension \
|
||||
HINDSIGHT_API_TENANT_SECRET=dev-secret \
|
||||
uv run hindsight-api
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Docker packaging
|
||||
|
||||
The Hindsight image does not carry extensions. Ship yours by building an image on top
|
||||
of it that installs the extension's dependencies and copies the extension in.
|
||||
|
||||
```dockerfile
|
||||
FROM ghcr.io/vectorize-io/hindsight:latest
|
||||
|
||||
# Install into the server's virtualenv explicitly. It was created by `uv sync`
|
||||
# and ships no `pip` of its own, so a bare `pip install` would land in user
|
||||
# site-packages and be invisible to the running server.
|
||||
RUN uv pip install --python /app/api/.venv/bin/python --no-cache \
|
||||
'PyJWT[crypto]>=2.12.0' \
|
||||
'httpx>=0.27.0'
|
||||
|
||||
# /app/extensions is ours — the image does not use it — so nothing the server
|
||||
# ships can be shadowed by what lands here.
|
||||
COPY hindsight-extensions/supabase-tenant/hindsight_ext_supabase_tenant \
|
||||
/app/extensions/hindsight_ext_supabase_tenant
|
||||
ENV PYTHONPATH=/app/extensions
|
||||
|
||||
# Fail the build, rather than the first authenticated request.
|
||||
RUN /app/api/.venv/bin/python -c "import hindsight_ext_supabase_tenant"
|
||||
```
|
||||
|
||||
That last `import` line is the one worth keeping: without it a packaging mistake ships
|
||||
happily and only surfaces when a request reaches the extension.
|
||||
|
||||
Build from the repository root, so the extension sources are in the build context:
|
||||
|
||||
```bash
|
||||
docker build -f hindsight-extensions/supabase-tenant/Dockerfile -t hindsight-with-supabase .
|
||||
|
||||
docker run -p 8888:8888 \
|
||||
-e HINDSIGHT_API_TENANT_EXTENSION=hindsight_ext_supabase_tenant:SupabaseTenantExtension \
|
||||
-e HINDSIGHT_API_TENANT_SUPABASE_URL=https://xxx.supabase.co \
|
||||
-e HINDSIGHT_API_DATABASE_URL=postgresql://... \
|
||||
hindsight-with-supabase
|
||||
```
|
||||
|
||||
Use `ghcr.io/vectorize-io/hindsight:latest-slim` as the base if you do not need the
|
||||
bundled local embedding/reranking models.
|
||||
|
||||
Same shape in `docker-compose.yml` — build the image and pass the extension's variables
|
||||
as environment:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
hindsight-api:
|
||||
build:
|
||||
context: . # repository root
|
||||
dockerfile: hindsight-extensions/supabase-tenant/Dockerfile
|
||||
environment:
|
||||
HINDSIGHT_API_TENANT_EXTENSION: hindsight_ext_supabase_tenant:SupabaseTenantExtension
|
||||
HINDSIGHT_API_TENANT_SUPABASE_URL: https://xxx.supabase.co
|
||||
```
|
||||
|
||||
A worker deployment loads the same tenant extension as the API, so give both containers
|
||||
the identical extension variables — otherwise the worker cannot enumerate tenant
|
||||
schemas and background consolidation stops for every tenant.
|
||||
|
||||
> Do not install extensions at container start (an entrypoint that runs `pip install`).
|
||||
> That resolves unpinned code over the network into a running server on every restart.
|
||||
|
||||
---
|
||||
|
||||
## Contributing an extension
|
||||
|
||||
Open a PR adding `hindsight-extensions/<name>/` with the layout above:
|
||||
|
||||
- a `README.md` documenting every environment variable it reads,
|
||||
- tests that exercise the extension through its base-class interface,
|
||||
- a `Dockerfile` that builds an image with it,
|
||||
- a row in the registry table above.
|
||||
|
||||
Extensions here are owned by their contributors. If you would rather host yours
|
||||
yourself, add a registry row pointing at your repository and package — no code needs
|
||||
to live in this tree.
|
||||
@@ -0,0 +1,36 @@
|
||||
# A Hindsight image with the Supabase tenant extension in it.
|
||||
#
|
||||
# Extensions are not bundled with the server and are not published to PyPI:
|
||||
# you ship one by copying it into an image built on top of Hindsight.
|
||||
#
|
||||
# Build from the repository root so the extension sources are in context:
|
||||
# docker build -f hindsight-extensions/supabase-tenant/Dockerfile \
|
||||
# -t hindsight-with-supabase .
|
||||
#
|
||||
# Run:
|
||||
# docker run -p 8888:8888 \
|
||||
# -e HINDSIGHT_API_TENANT_EXTENSION=hindsight_ext_supabase_tenant:SupabaseTenantExtension \
|
||||
# -e HINDSIGHT_API_TENANT_SUPABASE_URL=https://xxx.supabase.co \
|
||||
# hindsight-with-supabase
|
||||
#
|
||||
# Override the base with `--build-arg HINDSIGHT_IMAGE=...:latest-slim` if you
|
||||
# don't need the bundled local embedding/reranking models.
|
||||
ARG HINDSIGHT_IMAGE=ghcr.io/vectorize-io/hindsight:latest
|
||||
FROM ${HINDSIGHT_IMAGE}
|
||||
|
||||
# Install into the server's virtualenv explicitly. It was created by `uv sync`
|
||||
# and ships no `pip` of its own, so a bare `pip install` would land in user
|
||||
# site-packages and be invisible to the running server.
|
||||
RUN uv pip install --python /app/api/.venv/bin/python --no-cache \
|
||||
'PyJWT[crypto]>=2.12.0' \
|
||||
'httpx>=0.27.0'
|
||||
|
||||
# Put the extension on the server's import path. /app/extensions is ours — the
|
||||
# image does not use it — so this cannot shadow anything the server ships.
|
||||
COPY hindsight-extensions/supabase-tenant/hindsight_ext_supabase_tenant \
|
||||
/app/extensions/hindsight_ext_supabase_tenant
|
||||
ENV PYTHONPATH=/app/extensions
|
||||
|
||||
# Fail the build, rather than the first authenticated request, if the extension
|
||||
# is not importable from the interpreter the server actually runs.
|
||||
RUN /app/api/.venv/bin/python -c "import hindsight_ext_supabase_tenant"
|
||||
@@ -0,0 +1,84 @@
|
||||
# Supabase tenant extension
|
||||
|
||||
A Hindsight `TenantExtension` that authenticates requests with [Supabase](https://supabase.com)
|
||||
Auth JWTs and gives every user their own PostgreSQL schema, so memories are isolated
|
||||
at the database level.
|
||||
|
||||
- **Local JWT verification** using the project's JWKS public keys — no network call per
|
||||
request. Falls back to `/auth/v1/user` for legacy HS256 projects.
|
||||
- **Schema per user**: a user with id `a1b2…7890` gets the schema `user_a1b2…7890`
|
||||
(hyphens become underscores), migrated on first access and cached afterwards.
|
||||
- **No user management**: your existing Supabase project is the source of identity.
|
||||
|
||||
> This extension shipped inside the Hindsight server up to **0.9.2** as
|
||||
> `hindsight_api.extensions.builtin.supabase_tenant`. It is no longer bundled — see
|
||||
> [Migrating](#migrating-from-the-built-in-extension).
|
||||
|
||||
## Install
|
||||
|
||||
Extensions are not published to PyPI. Build an image with this one in it, from the
|
||||
repository root:
|
||||
|
||||
```bash
|
||||
docker build -f hindsight-extensions/supabase-tenant/Dockerfile -t hindsight-with-supabase .
|
||||
```
|
||||
|
||||
See the [Dockerfile](./Dockerfile) for what it does, and the
|
||||
[packaging guide](../README.md#packaging-an-extension) for the general pattern.
|
||||
|
||||
To run the server outside Docker, put `hindsight_ext_supabase_tenant/` on the
|
||||
`PYTHONPATH` of the environment Hindsight runs in and install `PyJWT[crypto]` and
|
||||
`httpx` there.
|
||||
|
||||
## Configure
|
||||
|
||||
```bash
|
||||
HINDSIGHT_API_TENANT_EXTENSION=hindsight_ext_supabase_tenant:SupabaseTenantExtension
|
||||
HINDSIGHT_API_TENANT_SUPABASE_URL=https://xxx.supabase.co
|
||||
```
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `HINDSIGHT_API_TENANT_SUPABASE_URL` | yes | — | Supabase project URL |
|
||||
| `HINDSIGHT_API_TENANT_SUPABASE_SERVICE_KEY` | only for HS256 projects | — | `service_role` key. Needed when JWKS is unavailable, and used for the startup health check |
|
||||
| `HINDSIGHT_API_TENANT_SCHEMA_PREFIX` | no | `user` | Schema name prefix; must be a valid Postgres identifier |
|
||||
|
||||
If your project uses legacy HS256 signing and no service key is set, the server fails
|
||||
at startup rather than accepting unverifiable tokens.
|
||||
|
||||
Give the API and the worker the **same** variables: the worker calls `list_tenants()`
|
||||
to decide which schemas to consolidate, so a worker without the extension leaves every
|
||||
tenant's background processing stopped.
|
||||
|
||||
## Use
|
||||
|
||||
Clients send their Supabase JWT as a bearer token:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer <supabase_jwt>" \
|
||||
https://your-hindsight-server/v1/default/banks/my-bank/memories/recall
|
||||
```
|
||||
|
||||
## Migrating from the built-in extension
|
||||
|
||||
Add the extension to your image (above), then update the extension path:
|
||||
|
||||
```diff
|
||||
-HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.supabase_tenant:SupabaseTenantExtension
|
||||
+HINDSIGHT_API_TENANT_EXTENSION=hindsight_ext_supabase_tenant:SupabaseTenantExtension
|
||||
```
|
||||
|
||||
Every `HINDSIGHT_API_TENANT_*` setting keeps its name and meaning, the schema naming is
|
||||
unchanged, and existing tenant schemas are picked up as they were before — this is a
|
||||
packaging move, not a behaviour change.
|
||||
|
||||
## Develop
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
uv run pytest tests -v
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT. Originally contributed by [BrighterBalance](https://brighterbalance.app).
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Supabase Auth tenant extension for the Hindsight API server.
|
||||
|
||||
Configure the server to load it with::
|
||||
|
||||
HINDSIGHT_API_TENANT_EXTENSION=hindsight_ext_supabase_tenant:SupabaseTenantExtension
|
||||
"""
|
||||
|
||||
from hindsight_ext_supabase_tenant.extension import SupabaseTenantExtension
|
||||
|
||||
__all__ = ["SupabaseTenantExtension"]
|
||||
+4
-2
@@ -1,6 +1,9 @@
|
||||
"""
|
||||
Supabase Tenant Extension for Hindsight
|
||||
|
||||
Ships separately from the Hindsight server: build an image on top of Hindsight
|
||||
that copies this package in (see the Dockerfile beside it).
|
||||
|
||||
Validates Supabase JWTs and maps authenticated users to isolated memory banks.
|
||||
Each user gets their own PostgreSQL schema based on their Supabase user ID.
|
||||
|
||||
@@ -15,7 +18,6 @@ Features:
|
||||
- Zero User Management: Leverages your existing Supabase Auth setup
|
||||
- Production Ready: Includes health checks, timeouts, key rotation handling,
|
||||
and error handling
|
||||
- Built-in: Ships with Hindsight, no extra installation needed
|
||||
- Legacy Support: Falls back to /auth/v1/user endpoint for HS256 projects
|
||||
|
||||
JWT Verification Strategy:
|
||||
@@ -28,7 +30,7 @@ JWT Verification Strategy:
|
||||
the service_role key to be configured.
|
||||
|
||||
Configuration via environment variables:
|
||||
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.supabase_tenant:SupabaseTenantExtension
|
||||
HINDSIGHT_API_TENANT_EXTENSION=hindsight_ext_supabase_tenant:SupabaseTenantExtension
|
||||
HINDSIGHT_API_TENANT_SUPABASE_URL=https://your-project.supabase.co
|
||||
|
||||
# Optional - only required for legacy HS256 projects or health checks
|
||||
@@ -0,0 +1,35 @@
|
||||
# This file exists to run the tests, nothing else. The extension is not
|
||||
# published to PyPI and is not built into a wheel — it is shipped by copying
|
||||
# hindsight_ext_supabase_tenant/ into a derived image (see Dockerfile).
|
||||
#
|
||||
# `package = false` keeps uv from trying to build or install this directory:
|
||||
# there is no build backend and no distribution here, just sources on the path.
|
||||
[project]
|
||||
name = "hindsight-ext-supabase-tenant"
|
||||
version = "0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
# What the extension itself imports. The runtime versions that matter are
|
||||
# the ones pinned in the Dockerfile; these are here so the tests run
|
||||
# against the same libraries.
|
||||
"PyJWT[crypto]>=2.12.0", # JWKS/RS256 verification; >=2.12 accepts unknown crit header extensions
|
||||
"httpx>=0.27.0",
|
||||
# The server provides the extension interfaces this is written against.
|
||||
# It is a test-time dependency only: at runtime the server is the host
|
||||
# process that imports the extension, not something it installs.
|
||||
"hindsight-api-slim",
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
|
||||
[tool.uv.sources]
|
||||
hindsight-api-slim = { path = "../../hindsight-api-slim", editable = true }
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
@@ -0,0 +1,37 @@
|
||||
"""The import path this extension is documented under must actually resolve.
|
||||
|
||||
``HINDSIGHT_API_TENANT_EXTENSION=hindsight_ext_supabase_tenant:SupabaseTenantExtension``
|
||||
is the value in every README, Dockerfile and migration note. It is resolved by
|
||||
``load_extension`` at server startup, so a missing re-export would only surface
|
||||
as a boot failure in someone's deployment.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from hindsight_api.extensions.loader import load_extension
|
||||
from hindsight_api.extensions.tenant import TenantExtension
|
||||
|
||||
from hindsight_ext_supabase_tenant import SupabaseTenantExtension
|
||||
|
||||
|
||||
def test_class_is_exported_from_the_package_root():
|
||||
from hindsight_ext_supabase_tenant.extension import (
|
||||
SupabaseTenantExtension as from_module,
|
||||
)
|
||||
|
||||
assert SupabaseTenantExtension is from_module
|
||||
|
||||
|
||||
def test_documented_env_value_loads_the_extension():
|
||||
env = {
|
||||
"HINDSIGHT_API_TENANT_EXTENSION": "hindsight_ext_supabase_tenant:SupabaseTenantExtension",
|
||||
"HINDSIGHT_API_TENANT_SUPABASE_URL": "https://xxx.supabase.co",
|
||||
"HINDSIGHT_API_TENANT_SCHEMA_PREFIX": "tenant",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
extension = load_extension("TENANT", TenantExtension)
|
||||
|
||||
assert isinstance(extension, SupabaseTenantExtension)
|
||||
assert extension.supabase_url == "https://xxx.supabase.co"
|
||||
assert extension.schema_prefix == "tenant"
|
||||
+51
-51
@@ -8,7 +8,7 @@ import jwt as pyjwt
|
||||
import pytest
|
||||
from jwt import PyJWK
|
||||
|
||||
from hindsight_api.extensions.builtin.supabase_tenant import (
|
||||
from hindsight_ext_supabase_tenant.extension import (
|
||||
JWKS_CACHE_TTL_SECONDS,
|
||||
JWKS_MIN_REFRESH_INTERVAL_SECONDS,
|
||||
MIN_TOKEN_LENGTH,
|
||||
@@ -166,8 +166,8 @@ class TestSupabaseTenantExtensionStartup:
|
||||
# JWKS fetch returns keys
|
||||
mock_client.get.return_value = _make_mock_response(200, MOCK_JWKS_RESPONSE)
|
||||
|
||||
with patch("hindsight_api.extensions.builtin.supabase_tenant.httpx.AsyncClient", return_value=mock_client):
|
||||
with patch("hindsight_api.extensions.builtin.supabase_tenant.PyJWK"):
|
||||
with patch("hindsight_ext_supabase_tenant.extension.httpx.AsyncClient", return_value=mock_client):
|
||||
with patch("hindsight_ext_supabase_tenant.extension.PyJWK"):
|
||||
await ext.on_startup()
|
||||
|
||||
assert ext._http_client is mock_client
|
||||
@@ -178,8 +178,8 @@ class TestSupabaseTenantExtensionStartup:
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_client.get.return_value = _make_mock_response(200, MOCK_JWKS_RESPONSE)
|
||||
|
||||
with patch("hindsight_api.extensions.builtin.supabase_tenant.httpx.AsyncClient", return_value=mock_client):
|
||||
with patch("hindsight_api.extensions.builtin.supabase_tenant.PyJWK") as mock_pyjwk:
|
||||
with patch("hindsight_ext_supabase_tenant.extension.httpx.AsyncClient", return_value=mock_client):
|
||||
with patch("hindsight_ext_supabase_tenant.extension.PyJWK") as mock_pyjwk:
|
||||
mock_pyjwk.return_value = MagicMock(spec=PyJWK)
|
||||
await ext.on_startup()
|
||||
|
||||
@@ -202,7 +202,7 @@ class TestSupabaseTenantExtensionStartup:
|
||||
|
||||
mock_client.get.side_effect = mock_get
|
||||
|
||||
with patch("hindsight_api.extensions.builtin.supabase_tenant.httpx.AsyncClient", return_value=mock_client):
|
||||
with patch("hindsight_ext_supabase_tenant.extension.httpx.AsyncClient", return_value=mock_client):
|
||||
await ext.on_startup()
|
||||
|
||||
assert ext._use_jwks is False
|
||||
@@ -225,7 +225,7 @@ class TestSupabaseTenantExtensionStartup:
|
||||
|
||||
mock_client.get.side_effect = mock_get
|
||||
|
||||
with patch("hindsight_api.extensions.builtin.supabase_tenant.httpx.AsyncClient", return_value=mock_client):
|
||||
with patch("hindsight_ext_supabase_tenant.extension.httpx.AsyncClient", return_value=mock_client):
|
||||
await ext.on_startup()
|
||||
|
||||
assert ext._use_jwks is False
|
||||
@@ -236,7 +236,7 @@ class TestSupabaseTenantExtensionStartup:
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_client.get.return_value = _make_mock_response(200, {"keys": []})
|
||||
|
||||
with patch("hindsight_api.extensions.builtin.supabase_tenant.httpx.AsyncClient", return_value=mock_client):
|
||||
with patch("hindsight_ext_supabase_tenant.extension.httpx.AsyncClient", return_value=mock_client):
|
||||
with pytest.raises(ValueError, match="HINDSIGHT_API_TENANT_SUPABASE_SERVICE_KEY is required"):
|
||||
await ext.on_startup()
|
||||
|
||||
@@ -246,8 +246,8 @@ class TestSupabaseTenantExtensionStartup:
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_client.get.return_value = _make_mock_response(200, MOCK_JWKS_RESPONSE)
|
||||
|
||||
with patch("hindsight_api.extensions.builtin.supabase_tenant.httpx.AsyncClient", return_value=mock_client):
|
||||
with patch("hindsight_api.extensions.builtin.supabase_tenant.PyJWK"):
|
||||
with patch("hindsight_ext_supabase_tenant.extension.httpx.AsyncClient", return_value=mock_client):
|
||||
with patch("hindsight_ext_supabase_tenant.extension.PyJWK"):
|
||||
await ext.on_startup()
|
||||
|
||||
# Second call should be health check
|
||||
@@ -261,8 +261,8 @@ class TestSupabaseTenantExtensionStartup:
|
||||
mock_client = AsyncMock(spec=httpx.AsyncClient)
|
||||
mock_client.get.return_value = _make_mock_response(200, MOCK_JWKS_RESPONSE)
|
||||
|
||||
with patch("hindsight_api.extensions.builtin.supabase_tenant.httpx.AsyncClient", return_value=mock_client):
|
||||
with patch("hindsight_api.extensions.builtin.supabase_tenant.PyJWK"):
|
||||
with patch("hindsight_ext_supabase_tenant.extension.httpx.AsyncClient", return_value=mock_client):
|
||||
with patch("hindsight_ext_supabase_tenant.extension.PyJWK"):
|
||||
await ext.on_startup()
|
||||
|
||||
# Only one call: JWKS fetch, no health check
|
||||
@@ -281,7 +281,7 @@ class TestJWKSCacheManagement:
|
||||
async def test_get_signing_key_from_cache(self):
|
||||
ext, _ = _setup_jwks_ext()
|
||||
|
||||
with patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header:
|
||||
with patch("hindsight_ext_supabase_tenant.extension.pyjwt.get_unverified_header") as mock_header:
|
||||
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
|
||||
key = await ext._get_signing_key("fake-token")
|
||||
|
||||
@@ -297,8 +297,8 @@ class TestJWKSCacheManagement:
|
||||
mock_client.get.return_value = _make_mock_response(200, MOCK_JWKS_RESPONSE)
|
||||
|
||||
with (
|
||||
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
|
||||
patch("hindsight_api.extensions.builtin.supabase_tenant.PyJWK", return_value=new_key),
|
||||
patch("hindsight_ext_supabase_tenant.extension.pyjwt.get_unverified_header") as mock_header,
|
||||
patch("hindsight_ext_supabase_tenant.extension.PyJWK", return_value=new_key),
|
||||
):
|
||||
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
|
||||
key = await ext._get_signing_key("fake-token")
|
||||
@@ -317,8 +317,8 @@ class TestJWKSCacheManagement:
|
||||
mock_client.get.return_value = _make_mock_response(200, MOCK_JWKS_RESPONSE)
|
||||
|
||||
with (
|
||||
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
|
||||
patch("hindsight_api.extensions.builtin.supabase_tenant.PyJWK", return_value=rotated_key),
|
||||
patch("hindsight_ext_supabase_tenant.extension.pyjwt.get_unverified_header") as mock_header,
|
||||
patch("hindsight_ext_supabase_tenant.extension.PyJWK", return_value=rotated_key),
|
||||
):
|
||||
mock_header.return_value = {"kid": "rotated-key-99", "alg": "RS256"}
|
||||
# The refreshed JWKS won't have "rotated-key-99" either, so this should raise
|
||||
@@ -332,7 +332,7 @@ class TestJWKSCacheManagement:
|
||||
async def test_get_signing_key_missing_kid_header(self):
|
||||
ext, _ = _setup_jwks_ext()
|
||||
|
||||
with patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header:
|
||||
with patch("hindsight_ext_supabase_tenant.extension.pyjwt.get_unverified_header") as mock_header:
|
||||
mock_header.return_value = {"alg": "RS256"} # no kid
|
||||
with pytest.raises(AuthenticationError, match="Token missing key ID"):
|
||||
await ext._get_signing_key("fake-token")
|
||||
@@ -345,7 +345,7 @@ class TestJWKSCacheManagement:
|
||||
|
||||
mock_client.get.side_effect = httpx.ConnectError("Connection refused")
|
||||
|
||||
with patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header:
|
||||
with patch("hindsight_ext_supabase_tenant.extension.pyjwt.get_unverified_header") as mock_header:
|
||||
mock_header.return_value = {"kid": "unknown-key", "alg": "RS256"}
|
||||
with pytest.raises(Exception):
|
||||
await ext._get_signing_key("fake-token")
|
||||
@@ -367,8 +367,8 @@ class TestAuthenticateJWKS:
|
||||
ext._context = mock_context
|
||||
|
||||
with (
|
||||
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
|
||||
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode") as mock_decode,
|
||||
patch("hindsight_ext_supabase_tenant.extension.pyjwt.get_unverified_header") as mock_header,
|
||||
patch("hindsight_ext_supabase_tenant.extension.pyjwt.decode") as mock_decode,
|
||||
):
|
||||
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
|
||||
mock_decode.return_value = {"sub": VALID_UUID, "aud": "authenticated"}
|
||||
@@ -388,8 +388,8 @@ class TestAuthenticateJWKS:
|
||||
ext._context = mock_context
|
||||
|
||||
with (
|
||||
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
|
||||
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode") as mock_decode,
|
||||
patch("hindsight_ext_supabase_tenant.extension.pyjwt.get_unverified_header") as mock_header,
|
||||
patch("hindsight_ext_supabase_tenant.extension.pyjwt.decode") as mock_decode,
|
||||
):
|
||||
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
|
||||
mock_decode.return_value = {"sub": VALID_UUID}
|
||||
@@ -403,9 +403,9 @@ class TestAuthenticateJWKS:
|
||||
ext, _ = _setup_jwks_ext()
|
||||
|
||||
with (
|
||||
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
|
||||
patch("hindsight_ext_supabase_tenant.extension.pyjwt.get_unverified_header") as mock_header,
|
||||
patch(
|
||||
"hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode",
|
||||
"hindsight_ext_supabase_tenant.extension.pyjwt.decode",
|
||||
side_effect=pyjwt.ExpiredSignatureError(),
|
||||
),
|
||||
):
|
||||
@@ -419,9 +419,9 @@ class TestAuthenticateJWKS:
|
||||
ext, _ = _setup_jwks_ext()
|
||||
|
||||
with (
|
||||
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
|
||||
patch("hindsight_ext_supabase_tenant.extension.pyjwt.get_unverified_header") as mock_header,
|
||||
patch(
|
||||
"hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode",
|
||||
"hindsight_ext_supabase_tenant.extension.pyjwt.decode",
|
||||
side_effect=pyjwt.InvalidAudienceError(),
|
||||
),
|
||||
):
|
||||
@@ -435,9 +435,9 @@ class TestAuthenticateJWKS:
|
||||
ext, _ = _setup_jwks_ext()
|
||||
|
||||
with (
|
||||
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
|
||||
patch("hindsight_ext_supabase_tenant.extension.pyjwt.get_unverified_header") as mock_header,
|
||||
patch(
|
||||
"hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode",
|
||||
"hindsight_ext_supabase_tenant.extension.pyjwt.decode",
|
||||
side_effect=pyjwt.InvalidIssuerError(),
|
||||
),
|
||||
):
|
||||
@@ -451,9 +451,9 @@ class TestAuthenticateJWKS:
|
||||
ext, _ = _setup_jwks_ext()
|
||||
|
||||
with (
|
||||
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
|
||||
patch("hindsight_ext_supabase_tenant.extension.pyjwt.get_unverified_header") as mock_header,
|
||||
patch(
|
||||
"hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode",
|
||||
"hindsight_ext_supabase_tenant.extension.pyjwt.decode",
|
||||
side_effect=pyjwt.DecodeError(),
|
||||
),
|
||||
):
|
||||
@@ -467,8 +467,8 @@ class TestAuthenticateJWKS:
|
||||
ext, _ = _setup_jwks_ext()
|
||||
|
||||
with (
|
||||
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
|
||||
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode") as mock_decode,
|
||||
patch("hindsight_ext_supabase_tenant.extension.pyjwt.get_unverified_header") as mock_header,
|
||||
patch("hindsight_ext_supabase_tenant.extension.pyjwt.decode") as mock_decode,
|
||||
):
|
||||
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
|
||||
mock_decode.return_value = {"email": "test@example.com"} # no sub
|
||||
@@ -482,8 +482,8 @@ class TestAuthenticateJWKS:
|
||||
ext, _ = _setup_jwks_ext()
|
||||
|
||||
with (
|
||||
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
|
||||
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode") as mock_decode,
|
||||
patch("hindsight_ext_supabase_tenant.extension.pyjwt.get_unverified_header") as mock_header,
|
||||
patch("hindsight_ext_supabase_tenant.extension.pyjwt.decode") as mock_decode,
|
||||
):
|
||||
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
|
||||
mock_decode.return_value = {"sub": ""}
|
||||
@@ -497,9 +497,9 @@ class TestAuthenticateJWKS:
|
||||
ext, _ = _setup_jwks_ext()
|
||||
|
||||
with (
|
||||
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
|
||||
patch("hindsight_ext_supabase_tenant.extension.pyjwt.get_unverified_header") as mock_header,
|
||||
patch(
|
||||
"hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode",
|
||||
"hindsight_ext_supabase_tenant.extension.pyjwt.decode",
|
||||
side_effect=RuntimeError("unexpected internal error"),
|
||||
),
|
||||
):
|
||||
@@ -636,8 +636,8 @@ class TestAuthenticateCommon:
|
||||
ext, _ = _setup_jwks_ext()
|
||||
|
||||
with (
|
||||
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
|
||||
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode") as mock_decode,
|
||||
patch("hindsight_ext_supabase_tenant.extension.pyjwt.get_unverified_header") as mock_header,
|
||||
patch("hindsight_ext_supabase_tenant.extension.pyjwt.decode") as mock_decode,
|
||||
):
|
||||
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
|
||||
mock_decode.return_value = {"sub": "not-a-uuid"}
|
||||
@@ -651,8 +651,8 @@ class TestAuthenticateCommon:
|
||||
ext, _ = _setup_jwks_ext()
|
||||
|
||||
with (
|
||||
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
|
||||
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode") as mock_decode,
|
||||
patch("hindsight_ext_supabase_tenant.extension.pyjwt.get_unverified_header") as mock_header,
|
||||
patch("hindsight_ext_supabase_tenant.extension.pyjwt.decode") as mock_decode,
|
||||
):
|
||||
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
|
||||
mock_decode.return_value = {"sub": "'; DROP TABLE users;--"}
|
||||
@@ -677,8 +677,8 @@ class TestSupabaseTenantExtensionSchemaManagement:
|
||||
ext._context = mock_context
|
||||
|
||||
with (
|
||||
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
|
||||
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode") as mock_decode,
|
||||
patch("hindsight_ext_supabase_tenant.extension.pyjwt.get_unverified_header") as mock_header,
|
||||
patch("hindsight_ext_supabase_tenant.extension.pyjwt.decode") as mock_decode,
|
||||
):
|
||||
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
|
||||
mock_decode.return_value = {"sub": VALID_UUID}
|
||||
@@ -696,8 +696,8 @@ class TestSupabaseTenantExtensionSchemaManagement:
|
||||
ext._context = mock_context
|
||||
|
||||
with (
|
||||
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
|
||||
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode") as mock_decode,
|
||||
patch("hindsight_ext_supabase_tenant.extension.pyjwt.get_unverified_header") as mock_header,
|
||||
patch("hindsight_ext_supabase_tenant.extension.pyjwt.decode") as mock_decode,
|
||||
):
|
||||
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
|
||||
mock_decode.return_value = {"sub": VALID_UUID}
|
||||
@@ -717,8 +717,8 @@ class TestSupabaseTenantExtensionSchemaManagement:
|
||||
ext._context = mock_context
|
||||
|
||||
with (
|
||||
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
|
||||
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode") as mock_decode,
|
||||
patch("hindsight_ext_supabase_tenant.extension.pyjwt.get_unverified_header") as mock_header,
|
||||
patch("hindsight_ext_supabase_tenant.extension.pyjwt.decode") as mock_decode,
|
||||
):
|
||||
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
|
||||
mock_decode.return_value = {"sub": VALID_UUID}
|
||||
@@ -753,8 +753,8 @@ class TestSupabaseTenantExtensionListTenants:
|
||||
ext._context = mock_context
|
||||
|
||||
with (
|
||||
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.get_unverified_header") as mock_header,
|
||||
patch("hindsight_api.extensions.builtin.supabase_tenant.pyjwt.decode") as mock_decode,
|
||||
patch("hindsight_ext_supabase_tenant.extension.pyjwt.get_unverified_header") as mock_header,
|
||||
patch("hindsight_ext_supabase_tenant.extension.pyjwt.decode") as mock_decode,
|
||||
):
|
||||
mock_header.return_value = {"kid": "test-key-1", "alg": "RS256"}
|
||||
mock_decode.return_value = {"sub": VALID_UUID}
|
||||
@@ -804,7 +804,7 @@ class TestSupabaseTenantExtensionLoader:
|
||||
def test_load_via_extension_loader(self, monkeypatch):
|
||||
monkeypatch.setenv(
|
||||
"HINDSIGHT_API_TENANT_EXTENSION",
|
||||
"hindsight_api.extensions.builtin.supabase_tenant:SupabaseTenantExtension",
|
||||
"hindsight_ext_supabase_tenant.extension:SupabaseTenantExtension",
|
||||
)
|
||||
monkeypatch.setenv("HINDSIGHT_API_TENANT_SUPABASE_URL", "https://test.supabase.co")
|
||||
monkeypatch.setenv("HINDSIGHT_API_TENANT_SUPABASE_SERVICE_KEY", "test-key")
|
||||
@@ -822,7 +822,7 @@ class TestSupabaseTenantExtensionLoader:
|
||||
"""Extension should load without service key — JWKS mode doesn't need it."""
|
||||
monkeypatch.setenv(
|
||||
"HINDSIGHT_API_TENANT_EXTENSION",
|
||||
"hindsight_api.extensions.builtin.supabase_tenant:SupabaseTenantExtension",
|
||||
"hindsight_ext_supabase_tenant.extension:SupabaseTenantExtension",
|
||||
)
|
||||
monkeypatch.setenv("HINDSIGHT_API_TENANT_SUPABASE_URL", "https://test.supabase.co")
|
||||
monkeypatch.delenv("HINDSIGHT_API_TENANT_SUPABASE_SERVICE_KEY", raising=False)
|
||||
@@ -19,18 +19,13 @@ HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTen
|
||||
HINDSIGHT_API_TENANT_API_KEY=your-secret-key
|
||||
```
|
||||
|
||||
**Built-in: SupabaseTenantExtension**
|
||||
**No longer built in: SupabaseTenantExtension**
|
||||
|
||||
Validates [Supabase](https://supabase.com) JWTs and provides multi-tenant memory isolation. Each authenticated user gets their own PostgreSQL schema (`{prefix}_{user_id}`), ensuring complete data separation. Performs local JWT verification using JWKS for optimal performance (no network call per request).
|
||||
Validates [Supabase](https://supabase.com) JWTs and gives each authenticated user their own PostgreSQL schema. It now lives in the [extensions registry](https://github.com/vectorize-io/hindsight/tree/main/hindsight-extensions/supabase-tenant), which documents its configuration and ships a Dockerfile that builds an image with it.
|
||||
|
||||
```bash
|
||||
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.supabase_tenant:SupabaseTenantExtension
|
||||
HINDSIGHT_API_TENANT_SUPABASE_URL=https://your-project.supabase.co
|
||||
# Optional - only needed for legacy HS256 projects or health check
|
||||
HINDSIGHT_API_TENANT_SUPABASE_SERVICE_KEY=your-service-role-key
|
||||
```
|
||||
|
||||
See the [source code](https://github.com/vectorize-io/hindsight/blob/main/hindsight-api-slim/hindsight_api/extensions/builtin/supabase_tenant.py) for complete configuration options and implementation details.
|
||||
:::warning Breaking change in 0.9.3
|
||||
Up to 0.9.2 this extension was built in, at `hindsight_api.extensions.builtin.supabase_tenant`. That path no longer exists, so an install still pointing at it fails at startup with `ModuleNotFoundError`. Add the extension to your image and set `HINDSIGHT_API_TENANT_EXTENSION=hindsight_ext_supabase_tenant:SupabaseTenantExtension`. All `HINDSIGHT_API_TENANT_*` settings and the schema naming are unchanged.
|
||||
:::
|
||||
|
||||
For other multi-tenant setups with separate schemas per tenant (e.g., custom JWT-based auth), implement a custom `TenantExtension`.
|
||||
|
||||
@@ -314,29 +309,25 @@ class MyMCPExtension(MCPExtension):
|
||||
|
||||
### With Docker
|
||||
|
||||
Mount your extension package as a volume and set the environment variable:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
hindsight-api:
|
||||
image: vectorize/hindsight-api:latest
|
||||
volumes:
|
||||
- ./my_extensions:/app/my_extensions
|
||||
environment:
|
||||
- HINDSIGHT_API_TENANT_EXTENSION=my_extensions.auth:JwtTenantExtension
|
||||
- HINDSIGHT_API_TENANT_JWT_SECRET=${JWT_SECRET}
|
||||
- PYTHONPATH=/app
|
||||
```
|
||||
|
||||
Or build a custom image with your extensions:
|
||||
Extensions are not bundled in the image. Build one on top of Hindsight that installs your extension's dependencies and copies it in. Install into the image's virtualenv explicitly — it was created by `uv sync` and ships no `pip` of its own, so a bare `pip install` lands where the server can't see it:
|
||||
|
||||
```dockerfile
|
||||
FROM vectorize/hindsight-api:latest
|
||||
COPY my_extensions /app/my_extensions
|
||||
ENV PYTHONPATH=/app
|
||||
FROM ghcr.io/vectorize-io/hindsight:latest
|
||||
|
||||
RUN uv pip install --python /app/api/.venv/bin/python --no-cache \
|
||||
'PyJWT[crypto]>=2.12.0' 'httpx>=0.27.0'
|
||||
|
||||
COPY my_extension /app/extensions/my_extension
|
||||
ENV PYTHONPATH=/app/extensions
|
||||
|
||||
# Fail the build, not the first request, if it isn't importable.
|
||||
RUN /app/api/.venv/bin/python -c "import my_extension"
|
||||
```
|
||||
|
||||
Then point the service at that image and pass the extension's variables as environment. Give the API and worker containers the same extension configuration — the worker uses the tenant extension to enumerate schemas for background consolidation.
|
||||
|
||||
See the [extensions registry README](https://github.com/vectorize-io/hindsight/blob/main/hindsight-extensions/README.md#docker-packaging) for the full recipe.
|
||||
|
||||
### Bare Metal
|
||||
|
||||
Install your extension package in the same Python environment as Hindsight:
|
||||
@@ -370,4 +361,6 @@ Custom extensions that solve common use cases are welcome contributions to the H
|
||||
- Metrics exporters (Datadog, New Relic, etc.)
|
||||
- Custom HTTP endpoints for specific platforms
|
||||
|
||||
Consider contributing it to the `hindsight_api.extensions.builtin` package. Open an issue or pull request on [GitHub](https://github.com/vectorize-io/hindsight) to discuss your extension.
|
||||
Add it to the [extensions registry](https://github.com/vectorize-io/hindsight/blob/main/hindsight-extensions/README.md) — either as a directory under `hindsight-extensions/`, or as a registry entry linking to your own repository. That README covers the layout, the development workflow, and Docker packaging.
|
||||
|
||||
Extensions live outside the server so that installing Hindsight does not pull in a vendor's client library, and so changing an extension does not require a Hindsight release. Only extensions that add no dependencies and are useful to any deployment (`ApiKeyTenantExtension`, `MemoryDefenseRegexExtension`) stay in `hindsight_api.extensions.builtin`.
|
||||
|
||||
@@ -1727,7 +1727,7 @@ dependencies = [
|
||||
{ name = "pyasn1" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pygments" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "python-multipart" },
|
||||
@@ -1877,7 +1877,6 @@ requires-dist = [
|
||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||
{ name = "pygments", specifier = ">=2.20.0" },
|
||||
{ name = "pyjwt", specifier = ">=2.12.0" },
|
||||
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.8.0" },
|
||||
{ name = "pytest", marker = "extra == 'test'", specifier = ">=7.0.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.21.0" },
|
||||
{ name = "pytest-timeout", marker = "extra == 'test'", specifier = ">=2.4.0" },
|
||||
|
||||
Reference in New Issue
Block a user