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:
Nicolò Boschi
2026-09-01 14:47:37 +02:00
committed by GitHub
parent 833d400f9b
commit ac41cee604
19 changed files with 705 additions and 134 deletions
+51
View File
@@ -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",
]
+1 -2
View File
@@ -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 == []
+1 -1
View File
@@ -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"
+23 -30
View File
@@ -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`.
+5
View File
@@ -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
+282
View File
@@ -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"]
@@ -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"
@@ -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`.
Generated
+1 -2
View File
@@ -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" },