Merge pull request #798 from cbcoutinho/feat/pluggable-database-backend

feat(storage): pluggable database backend via DATABASE_URL (ADR-026)
This commit is contained in:
Chris Coutinho
2026-05-17 09:34:51 +02:00
committed by GitHub
24 changed files with 2304 additions and 610 deletions
+2 -2
View File
@@ -16,11 +16,11 @@ WORKDIR /app
COPY pyproject.toml uv.lock README.md .
RUN uv sync --locked --no-dev --no-install-project --no-cache
RUN uv sync --locked --no-dev --no-install-project --no-cache --extra postgres
COPY . .
RUN uv sync --locked --no-dev --no-editable --no-cache
RUN uv sync --locked --no-dev --no-editable --no-cache --extra postgres
ENV PYTHONUNBUFFERED=1
ENV VIRTUAL_ENV=/app/.venv
+23
View File
@@ -352,6 +352,29 @@ services:
profiles:
- qdrant
# Centralized Postgres backend for token storage. Used by:
# 1. Integration tests gated on @pytest.mark.postgres — they read
# TEST_DATABASE_URL=postgresql+asyncpg://mcp:mcp@localhost:5433/mcp.
# 2. Manual smoke testing of the HA (multi-replica) deployment story.
# Bring up with: docker compose --profile postgres up -d postgres-test
# See ADR-026 for the pluggable-database-backend design.
postgres-test:
image: docker.io/library/postgres:16-alpine@sha256:16bc17c64a573ef34162af9298258d1aec548232985b33ed7b1eac33ba35c229
restart: unless-stopped
environment:
POSTGRES_USER: mcp
POSTGRES_PASSWORD: mcp
POSTGRES_DB: mcp
ports:
- 127.0.0.1:5433:5432
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mcp -d mcp"]
interval: 5s
timeout: 3s
retries: 10
profiles:
- postgres
volumes:
nextcloud:
db:
+296
View File
@@ -0,0 +1,296 @@
# ADR-026: Pluggable database backend (DATABASE_URL)
## Status
Accepted — 2026-05-16
## Context
`RefreshTokenStorage` (in `nextcloud_mcp_server/auth/storage.py`) holds all
of the MCP server's persistent state: refresh tokens, OAuth client
credentials, OAuth sessions, browser sessions, app passwords, login-flow
sessions, audit logs, and webhook registrations. Until this ADR it was
backed by a single SQLite file, with the path configured by
`TOKEN_STORAGE_DB`.
This works well for single-user deployments but blocks horizontal scaling
in Kubernetes:
- Every pod needs its own PVC (ReadWriteOnce) or a ReadWriteMany volume.
- Tokens stored on pod A are invisible to pod B, so a Service can only
route traffic to one pod at a time.
- Restart / re-deploy cycles either drop the volume (token loss) or
require coordinated PVC handling.
- Backup, encryption-at-rest, and multi-region replication become
per-pod concerns rather than centrally managed DB concerns.
We needed a way for pods to be stateless and share a centralized store
without giving up the zero-config SQLite path that single-user installs and
local development rely on.
## Decision
Introduce a `DATABASE_URL` setting that accepts any SQLAlchemy async URL,
with `sqlite+aiosqlite:///...` remaining the default. The runtime keeps a
single linear migration history and a single `RefreshTokenStorage` class —
the backend is selected purely by the URL.
### Resolution order
`get_database_url()` (in `nextcloud_mcp_server/config.py`) returns:
1. `DATABASE_URL` if set — wins over everything.
2. Otherwise `sqlite+aiosqlite:///{get_token_db_path()}`, so the legacy
`TOKEN_STORAGE_DB` env var and the process-local ephemeral tempfile
fallback both keep working unchanged.
### Why SQLAlchemy Core + async engine, not an ABC with parallel drivers
Two alternatives were considered:
| Option | Why rejected |
|---|---|
| Define a `Storage` ABC with `SQLiteStorage` (aiosqlite) and `PostgresStorage` (asyncpg) implementations | Doubles the surface area — every schema change has to land in two backends, with two sets of migrations, two SQL dialects, two upsert idioms. Diverges over time. |
| Switch to a full SQLAlchemy ORM (declarative models) | Larger refactor; the existing explicit-SQL style is intentional and well-understood by reviewers. |
| **Keep `RefreshTokenStorage` and put SQLAlchemy Core under it** *(chosen)* | One method body per operation, one migration history (Alembic is already SQLAlchemy-based). The URL drives dialect, pool, and DDL. |
A thin compatibility shim (`_DBConn` / `_Cursor` / `_Row` in `storage.py`)
adapts the `async with aiosqlite.connect(...) as db: async with
db.execute(...) as cursor: ...` idiom to SQLAlchemy `AsyncEngine` /
`AsyncConnection`. Existing method bodies needed only their connection
context-manager swapped; `?` placeholders are rewritten to named binds on
the fly. The seven `INSERT OR REPLACE` statements were rewritten as
portable `INSERT ... ON CONFLICT (...) DO UPDATE` (SQLite ≥ 3.24, Postgres
≥ 9.5; we already require SQLite ≥ 3.35 elsewhere).
### Distribution: asyncpg is an optional extra, bundled in Docker
`asyncpg` carries a compiled C extension (~5 MB plus a build toolchain on
source installs) — too heavy a default for the
`pip install nextcloud-mcp-server` audience, the majority of whom run the
SQLite path. It is shipped as a PyPI optional dependency::
pip install 'nextcloud-mcp-server[postgres]'
The published Docker image runs `uv sync --extra postgres` so the
container always has the driver, matching the HA-deployment audience
that exercises the Postgres backend. When `DATABASE_URL=postgresql+asyncpg://...`
is set on a venv without the extra installed, `RefreshTokenStorage`
raises a clear actionable error before the engine is built — operators
see "install with `[postgres]` extra" rather than a generic
`ModuleNotFoundError: No module named 'asyncpg'`.
### Alembic env.py runs the async engine inside a worker thread
`nextcloud_mcp_server/alembic/env.py` uses
`async_engine_from_config(...)` + `anyio.run(run_async_migrations)`, and
the runtime invokes it from `RefreshTokenStorage.initialize()` via
`anyio.to_thread.run_sync(upgrade_database, ...)`. This is intentional:
- Alembic wants a synchronous entry point (`upgrade_database()`), but
`async_engine_from_config` returns an async engine.
- Running `anyio.run()` directly inside an already-running event loop
would deadlock; we have to be on a different thread.
- `to_thread.run_sync` puts the call on a worker thread, which has no
running event loop — `anyio.run()` is then free to spin up its own.
The pattern is non-obvious; this note exists so a future maintainer
doesn't try to "simplify" it back into the main loop.
### Concurrency model and pool sizing
The reviewer's natural reaction to seeing `DATABASE_POOL_SIZE=10` (the
round-2 default) was: *isn't 1 connection enough for an MCP server?*
This subsection records why a small pool is right, why 1 is not the
target default, and what the workload actually looks like.
**asyncpg connection semantics.** Each asyncpg connection is
**single-flight** — only one query can be in flight at a time on a
given connection. SQLAlchemy serializes additional requests in the
pool queue. So the question is never "how many requests does the MCP
server handle" but "how many concurrent storage operations are in
flight at the peak".
**MCP storage workload shape.** Each MCP tool call typically performs
13 storage operations: a token lookup (`get_refresh_token` or
`get_app_password`), maybe an audit-log write, occasionally a session
update. Lookups are sub-millisecond point queries; writes are short.
The hot path is read-mostly. No long-running transactions, no batch
loads.
**Why not 1?** A single-user (homelab) deployment genuinely works on
`pool_size=1, max_overflow=2`. But the default ships for multi-user
OAuth deployments where ≥2 concurrent client requests are normal; on
`pool_size=1` those serialize on a single connection and you measure
a latency cliff. The defaults `pool_size=2, max_overflow=5` (max 7
per pod) cover typical multi-user MCP burst with two-replica
headroom. With 3 k8s replicas the total is 21 connections — well
under managed-Postgres `max_connections=100` (RDS, CNPG default).
**How to tune.** `DATABASE_POOL_SIZE` / `DATABASE_MAX_OVERFLOW` env
vars adjust the per-pod pool live (server restart). The startup
``Postgres engine ready: pool_size=N max_overflow=M (per-pod max K
connections)`` log line surfaces the active sizing so operators can
see the per-replica footprint at a glance. For a fleet of N replicas,
estimate worst-case Postgres connection count as
`N × (pool_size + max_overflow)` and stay comfortably below the
server's `max_connections`.
### Concurrent migrations across pods
When `replicas: N` rolling-update restarts, multiple pods race
`RefreshTokenStorage.initialize()` simultaneously. Alembic's
version-table UPDATE isn't write-locked across connections by
default; without coordination, two pods can both observe
"no `alembic_version` table" and both try to apply migrations from
scratch — the second one crashes with `relation … already exists`.
We serialize this with a session-level Postgres advisory lock
(`SELECT pg_advisory_lock(:lock_id)`) acquired in `_migration_lock()`
and held across BOTH the schema inspection and the migration call.
The lock ID is a stable 64-bit integer derived from
`sha256("nextcloud-mcp-server:migrations")[:8]` so we can't collide
with other apps that happen to share the same Postgres instance.
The second pod blocks at the advisory-lock call until the first pod
finishes; it then re-inspects the schema, sees the now-populated
`alembic_version` table, and takes the no-op upgrade fast path.
SQLite needs no equivalent: file-level locking serializes writes
natively, so the second process waits on the file lock and then
sees the migrated schema. Covered by
`tests/integration/test_storage_postgres.py::test_concurrent_initialize_serialized_by_advisory_lock`.
### TLS for the Postgres backend
Two settings mirror the existing `NEXTCLOUD_VERIFY_SSL` /
`NEXTCLOUD_CA_BUNDLE` pattern: `DATABASE_VERIFY_SSL` and
`DATABASE_CA_BUNDLE`. `get_database_ssl()` (in `nextcloud_mcp_server/config.py`)
returns the value to pass to asyncpg via SQLAlchemy's `connect_args={"ssl": ...}`.
The default is deliberately **less strict than the Nextcloud HTTPS
default**: `DATABASE_VERIFY_SSL` defaults to `None` rather than `True`.
When both env vars are unset we omit the `ssl` kwarg entirely and asyncpg's
default (`prefer`) applies — TLS if the server offers it, no certificate
validation. The reasoning:
- Cluster-internal Postgres (CNPG via a Service, RDS over a private VPC,
PgBouncer sidecar) is the common HA pattern and frequently runs without
TLS or with cert hostnames asyncpg wouldn't match anyway.
- The HTTPS analogy doesn't carry over: the Nextcloud client talks to
*external* hostnames over public networks where verify-full is the
right default. The database client talks to a controlled peer.
- Just-shipped PR #798 had no TLS knobs and worked against cluster-local
Postgres-test; flipping the default to `True` here would break that
flow on upgrade.
Operators in production with a managed Postgres opt in with
`DATABASE_VERIFY_SSL=true`. Homelab operators with a private CA set
`DATABASE_CA_BUNDLE=/path/to/ca.pem` (which implies `verify=true`).
`DATABASE_VERIFY_SSL=false` is the escape hatch for incident response —
it wins over `DATABASE_CA_BUNDLE` so an operator can quickly silence
cert errors without editing the secret store.
### Encryption stays in Python (Fernet), not the DB
The DB only ever sees ciphertext for sensitive columns
(`encrypted_token`, `encrypted_client_secret`, `encrypted_password`,
`encrypted_poll_token`). The Fernet key remains a `TOKEN_ENCRYPTION_KEY`
env var, applied in Python before INSERT and after SELECT. This means:
- Switching backends does not invalidate or re-key existing data.
- Postgres-level features like `pgcrypto` are not required.
- Operators rotating the encryption key still go through the existing
Python path.
### DDL portability
All Alembic migrations were rewritten from raw `op.execute("CREATE TABLE
...")` strings to `op.create_table()` / `op.create_index()` calls with
SQLAlchemy types. Notable choices:
- All `*_at` / expiration / timestamp columns use `sa.BigInteger` —
Postgres `INTEGER` is 32-bit and unix epochs are already past that range.
SQLite treats `BIGINT` and `INTEGER` identically (dynamic typing) so
this is backwards compatible.
- `BLOB` → `sa.LargeBinary` (becomes `BYTEA` on Postgres).
- `BOOLEAN DEFAULT FALSE` → `sa.Boolean, server_default=sa.false()`.
- Existing SQLite deployments are at revision `006` and skip the
rewritten migrations entirely — content rewrites are safe.
### No data migration, no shipped Postgres
Two scope decisions worth recording:
1. **Clean cutover, no SQLite → Postgres data migration tool.** Tokens
are reissued on the next login; webhooks re-register on the next sync
tick. Acceptable because the ephemeral-default already implies this,
and the data being preserved (audit logs, OAuth sessions) is either
short-lived or reconstructible.
2. **Bring-your-own database.** The MCP server consumes a
`DATABASE_URL`; it does not provision Postgres itself. Operators use
CNPG, RDS, the project's existing Helm chart with a sub-chart, etc.
The `postgres-test` service in `docker-compose.yml` exists only for
integration tests and manual HA smoke testing — it is gated on the
`postgres` profile and is not the recommended production pattern.
### CLI changes
The `nextcloud-mcp-server db {upgrade,downgrade,current,history}` commands
gain a `--database-url / -u` flag (env `DATABASE_URL`) alongside the
existing `--database-path / -d` (env `TOKEN_STORAGE_DB`). `-u` wins over
`-d`; both fall back to `get_database_url()`.
## Consequences
### Positive
- MCP server pods become stateless. A Kubernetes Deployment can run with
`replicas: 3` behind a Service, with all pods pointed at the same
Postgres URL — tokens written by pod A are immediately visible to pod B.
- Centralized DB operations (backup, restore, replication, encryption at
rest, monitoring) are handled by the operator's existing Postgres
infrastructure rather than duplicated per-pod.
- No regression for single-user / local-development / docker-compose
installs — the SQLite tempfile path is unchanged and remains the default
when `DATABASE_URL` is unset.
- Test coverage doubles automatically: every test that uses the
`temp_storage` fixture now runs against both SQLite and Postgres when
`TEST_DATABASE_URL` is exported.
### Negative
- One more thing operators have to think about for HA deployments
(Postgres connection string, credentials secret, network policies).
- Adds SQLAlchemy + asyncpg to the runtime dependency set. SQLAlchemy was
already transitively present via Alembic; asyncpg is genuinely new.
- The compatibility shim in `storage.py` is a small piece of bespoke code
that future contributors need to understand. The alternative — rewriting
every method body to SQLAlchemy idioms — was rejected as too risky for
this PR but might be revisited.
### Neutral
- The Alembic migration history was content-rewritten but its revision
graph is unchanged (still `001 → 006`), so existing SQLite deployments
do not re-run anything.
- `TOKEN_STORAGE_DB` still works exactly as before; deployments that
already set it require no changes.
## Related
- [ADR-022 Login Flow v2](ADR-022-deployment-mode-consolidation.md) —
defines the per-user app password storage that this ADR centralizes.
- [ADR-002 Vector sync authentication](ADR-002-vector-sync-authentication.md)
— explains the offline-access tokens that benefit most from HA storage.
## Verification
1. `uv run pytest tests/unit/` — SQLite path unchanged (1012 tests).
2. `docker compose --profile postgres up -d postgres-test` then
`TEST_DATABASE_URL=postgresql+asyncpg://mcp:mcp@localhost:5433/mcp uv run pytest tests/unit/test_app_password_storage.py tests/unit/test_webhook_storage.py`
— every test runs once per backend.
3. Manual end-to-end smoke against `mcp-login-flow` with a Postgres URL
(commands in `/home/chris/.claude/plans/spicy-enchanting-flurry.md` →
Verification).
4. k8s HA validation (after merge in `homelab-argocd`): `replicas: 3`,
confirm session continuity through the Service.
+67
View File
@@ -118,6 +118,73 @@ See [Login Flow v2](login-flow-v2.md) for full setup, scope reference, and troub
---
## Centralized Token Storage (DATABASE_URL, Optional)
By default the MCP server stores tokens / sessions / app passwords in a
local SQLite file (`TOKEN_STORAGE_DB`, falling back to a per-process
tempfile). For HA Kubernetes deployments where you need multiple
stateless pods to share state, point the server at a centralized
database via `DATABASE_URL`.
```env
# Centralized Postgres backend (HA k8s deployments)
DATABASE_URL=postgresql+asyncpg://mcp:secret@postgres.svc.cluster.local:5432/mcp
TOKEN_ENCRYPTION_KEY=<fernet-key>
```
| Variable | Required | Description |
|----------|----------|-------------|
| `DATABASE_URL` | Optional | SQLAlchemy async URL for any supported backend. When set, wins over `TOKEN_STORAGE_DB`. Primary supported targets: `postgresql+asyncpg://...` (recommended for HA) and `sqlite+aiosqlite:///...` (development). |
| `TOKEN_STORAGE_DB` | Optional | Legacy SQLite-only path. Used when `DATABASE_URL` is unset. Falls back to a per-process ephemeral tempfile when both are unset. |
| `DATABASE_VERIFY_SSL` | Optional | TLS verification toggle for the Postgres backend. Unset (default) → asyncpg's `prefer` mode (TLS if offered, no verification — keeps cluster-internal Postgres working). `true` → full cert verification. `false` → silence cert errors (homelab / self-signed). |
| `DATABASE_CA_BUNDLE` | Optional | Path to a PEM file containing a private CA. Implies `DATABASE_VERIFY_SSL=true`. Use this for self-hosted Postgres signed by your homelab CA instead of disabling verification. |
| `DATABASE_POOL_SIZE` | Optional (default `2`) | Per-pod SQLAlchemy connection pool size for the Postgres backend. asyncpg connections are single-flight, so this only needs to cover concurrent storage ops (not concurrent tool calls). See [ADR-026 § Concurrency model and pool sizing](ADR-026-pluggable-database-backend.md). |
| `DATABASE_MAX_OVERFLOW` | Optional (default `5`) | Per-pod burst connections beyond `DATABASE_POOL_SIZE`. Max per-pod = `pool_size + max_overflow` (default 7). Set to `0` for a hard cap. With 3 replicas the default totals 21 connections — well under managed-Postgres `max_connections=100`. |
Operators with very high concurrency (many MCP clients per pod, or
expensive Nextcloud round-trips holding storage locks) should tune these
up; single-user / homelab deployments can drop to `DATABASE_POOL_SIZE=1
DATABASE_MAX_OVERFLOW=2` for the smallest possible footprint. The
server logs the configured sizes at startup so over-allocation is
visible without grepping config.
Homelab example (self-signed Postgres with a private CA):
```env
DATABASE_URL=postgresql+asyncpg://mcp:secret@pg.lan:5432/mcp
DATABASE_CA_BUNDLE=/etc/ssl/certs/homelab-ca.pem
TOKEN_ENCRYPTION_KEY=<fernet-key>
```
Notes:
- **PyPI extra required.** The `asyncpg` driver is an optional extra so
the default `pip install nextcloud-mcp-server` stays lean. Install
with `pip install 'nextcloud-mcp-server[postgres]'` when using a
Postgres URL. The Docker image bundles it by default. When
`DATABASE_URL=postgresql+asyncpg://...` is set without the extra,
the server fails fast with a clear actionable error.
- **Bring-your-own DB.** The MCP server doesn't provision the database;
it just consumes the URL. Use CNPG, RDS, your existing Helm chart's
Postgres sub-chart, etc.
- **Encryption stays in the app.** `TOKEN_ENCRYPTION_KEY` (Fernet) is
applied in Python; the database only ever sees ciphertext for
sensitive columns. You don't need `pgcrypto`.
- **Schema is managed automatically.** On startup the server runs
Alembic migrations against the configured backend. Existing SQLite
deployments are stamped at the current revision and skip re-execution.
- **No data migration tool.** Moving from SQLite to Postgres is a clean
cutover — tokens are reissued on the next login, webhooks
re-register on the next sync tick.
- **Testing a Postgres backend locally:** `docker compose --profile
postgres up -d postgres-test` then export
`DATABASE_URL=postgresql+asyncpg://mcp:mcp@localhost:5433/mcp`.
See [ADR-026 Pluggable database backend](ADR-026-pluggable-database-backend.md)
for the architecture rationale.
---
## SSL/TLS Configuration (Optional)
If your Nextcloud instance uses a self-signed certificate or a private CA (common with reverse proxies like Traefik or Caddy), the MCP server will reject the connection by default. Use these settings to configure certificate verification.
@@ -8,12 +8,19 @@ This migration creates the initial database schema including:
- registered_webhooks: Webhook registration tracking (both OAuth and BasicAuth)
- schema_version: Legacy schema version tracking (deprecated, use alembic_version)
Uses Alembic's portable schema-DDL helpers (``op.create_table`` /
``op.create_index``) with SQLAlchemy types so the DDL is emitted correctly
for both SQLite (BLOB / INTEGER PRIMARY KEY AUTOINCREMENT) and Postgres
(BYTEA / SERIAL). See ADR-026.
Revision ID: 001
Revises:
Create Date: 2025-12-17 22:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
@@ -24,143 +31,111 @@ depends_on = None
def upgrade() -> None:
"""Create initial database schema."""
"""Create initial database schema.
# Refresh tokens table (OAuth mode only, for background jobs)
op.execute(
"""
CREATE TABLE IF NOT EXISTS refresh_tokens (
user_id TEXT PRIMARY KEY,
encrypted_token BLOB NOT NULL,
expires_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
-- ADR-004 Progressive Consent fields
flow_type TEXT DEFAULT 'hybrid',
token_audience TEXT DEFAULT 'nextcloud',
provisioned_at INTEGER,
provisioning_client_id TEXT,
scopes TEXT,
-- Browser session profile cache
user_profile TEXT,
profile_cached_at INTEGER
)
All ``*_at`` / expiration / timestamp columns use :class:`sa.BigInteger`
so Postgres allocates BIGINT (8-byte) and unix epoch values don't
overflow the 32-bit int32 INTEGER range on long-lived sessions. SQLite
treats BIGINT and INTEGER identically (dynamic typing), so this is
backwards compatible.
"""
op.create_table(
"refresh_tokens",
sa.Column("user_id", sa.Text, primary_key=True),
sa.Column("encrypted_token", sa.LargeBinary, nullable=False),
sa.Column("expires_at", sa.BigInteger),
sa.Column("created_at", sa.BigInteger, nullable=False),
sa.Column("updated_at", sa.BigInteger, nullable=False),
# ADR-004 Progressive Consent fields
sa.Column("flow_type", sa.Text, server_default="hybrid"),
sa.Column("token_audience", sa.Text, server_default="nextcloud"),
sa.Column("provisioned_at", sa.BigInteger),
sa.Column("provisioning_client_id", sa.Text),
sa.Column("scopes", sa.Text),
# Browser session profile cache
sa.Column("user_profile", sa.Text),
sa.Column("profile_cached_at", sa.BigInteger),
)
# Audit logs table (both OAuth and BasicAuth modes)
op.execute(
"""
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
event TEXT NOT NULL,
user_id TEXT NOT NULL,
resource_type TEXT,
resource_id TEXT,
auth_method TEXT,
hostname TEXT
op.create_table(
"audit_logs",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("timestamp", sa.BigInteger, nullable=False),
sa.Column("event", sa.Text, nullable=False),
sa.Column("user_id", sa.Text, nullable=False),
sa.Column("resource_type", sa.Text),
sa.Column("resource_id", sa.Text),
sa.Column("auth_method", sa.Text),
sa.Column("hostname", sa.Text),
)
"""
op.create_index("idx_audit_user_timestamp", "audit_logs", ["user_id", "timestamp"])
op.create_table(
"oauth_clients",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=False),
sa.Column("client_id", sa.Text, nullable=False, unique=True),
sa.Column("encrypted_client_secret", sa.LargeBinary, nullable=False),
sa.Column("client_id_issued_at", sa.BigInteger, nullable=False),
sa.Column("client_secret_expires_at", sa.BigInteger, nullable=False),
sa.Column("redirect_uris", sa.Text, nullable=False),
sa.Column("encrypted_registration_access_token", sa.LargeBinary),
sa.Column("registration_client_uri", sa.Text),
sa.Column("created_at", sa.BigInteger, nullable=False),
sa.Column("updated_at", sa.BigInteger, nullable=False),
)
# Index on audit logs for efficient queries
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_audit_user_timestamp
ON audit_logs(user_id, timestamp)
"""
op.create_table(
"oauth_sessions",
sa.Column("session_id", sa.Text, primary_key=True),
sa.Column("client_id", sa.Text),
sa.Column("client_redirect_uri", sa.Text, nullable=False),
sa.Column("state", sa.Text),
sa.Column("code_challenge", sa.Text),
sa.Column("code_challenge_method", sa.Text),
sa.Column("mcp_authorization_code", sa.Text, unique=True),
sa.Column("idp_access_token", sa.Text),
sa.Column("idp_refresh_token", sa.Text),
sa.Column("user_id", sa.Text),
sa.Column("created_at", sa.BigInteger, nullable=False),
sa.Column("expires_at", sa.BigInteger, nullable=False),
# ADR-004 Progressive Consent fields
sa.Column("flow_type", sa.Text, server_default="hybrid"),
sa.Column("requested_scopes", sa.Text),
sa.Column("granted_scopes", sa.Text),
sa.Column("is_provisioning", sa.Boolean, server_default=sa.false()),
)
op.create_index(
"idx_oauth_sessions_mcp_code",
"oauth_sessions",
["mcp_authorization_code"],
)
# OAuth client credentials storage (OAuth mode only)
op.execute(
"""
CREATE TABLE IF NOT EXISTS oauth_clients (
id INTEGER PRIMARY KEY,
client_id TEXT UNIQUE NOT NULL,
encrypted_client_secret BLOB NOT NULL,
client_id_issued_at INTEGER NOT NULL,
client_secret_expires_at INTEGER NOT NULL,
redirect_uris TEXT NOT NULL,
encrypted_registration_access_token BLOB,
registration_client_uri TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"""
# Legacy schema-version table; superseded by alembic_version. Only
# created on SQLite because it exists *purely* to match the
# fingerprint of pre-Alembic SQLite databases that get stamped into
# the migration chain (see ``RefreshTokenStorage.initialize()``).
# Fresh Postgres installs have no pre-Alembic history and don't
# need it. PR #798 round-3 review (#4).
if op.get_bind().dialect.name == "sqlite":
op.create_table(
"schema_version",
sa.Column("version", sa.Integer, primary_key=True, autoincrement=False),
sa.Column("applied_at", sa.Float, nullable=False),
)
# OAuth flow sessions (ADR-004 Progressive Consent)
op.execute(
"""
CREATE TABLE IF NOT EXISTS oauth_sessions (
session_id TEXT PRIMARY KEY,
client_id TEXT,
client_redirect_uri TEXT NOT NULL,
state TEXT,
code_challenge TEXT,
code_challenge_method TEXT,
mcp_authorization_code TEXT UNIQUE,
idp_access_token TEXT,
idp_refresh_token TEXT,
user_id TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
-- ADR-004 Progressive Consent fields
flow_type TEXT DEFAULT 'hybrid',
requested_scopes TEXT,
granted_scopes TEXT,
is_provisioning BOOLEAN DEFAULT FALSE
)
"""
)
# Index for MCP authorization code lookups
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_oauth_sessions_mcp_code
ON oauth_sessions(mcp_authorization_code)
"""
)
# Legacy schema version tracking table
# NOTE: This is deprecated in favor of Alembic's alembic_version table
# Kept for backward compatibility with pre-Alembic databases
op.execute(
"""
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at REAL NOT NULL
)
"""
)
# Registered webhooks tracking (both BasicAuth and OAuth modes)
op.execute(
"""
CREATE TABLE IF NOT EXISTS registered_webhooks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
webhook_id INTEGER NOT NULL UNIQUE,
preset_id TEXT NOT NULL,
created_at REAL NOT NULL
)
"""
)
# Indexes for efficient webhook queries
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_webhooks_preset
ON registered_webhooks(preset_id)
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_webhooks_created
ON registered_webhooks(created_at)
"""
op.create_table(
"registered_webhooks",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("webhook_id", sa.Integer, nullable=False, unique=True),
sa.Column("preset_id", sa.Text, nullable=False),
# BigInteger for consistency with every other *_at column (PR #798
# review): subsecond precision wasn't load-bearing for webhook
# bookkeeping. ``store_webhook()`` casts to ``int(time.time())``.
sa.Column("created_at", sa.BigInteger, nullable=False),
)
op.create_index("idx_webhooks_preset", "registered_webhooks", ["preset_id"])
op.create_index("idx_webhooks_created", "registered_webhooks", ["created_at"])
def downgrade() -> None:
@@ -170,16 +145,15 @@ def downgrade() -> None:
Use with extreme caution.
"""
# Drop indexes first
op.execute("DROP INDEX IF EXISTS idx_webhooks_created")
op.execute("DROP INDEX IF EXISTS idx_webhooks_preset")
op.execute("DROP INDEX IF EXISTS idx_oauth_sessions_mcp_code")
op.execute("DROP INDEX IF EXISTS idx_audit_user_timestamp")
# Drop tables
op.execute("DROP TABLE IF EXISTS registered_webhooks")
op.execute("DROP TABLE IF EXISTS schema_version")
op.execute("DROP TABLE IF EXISTS oauth_sessions")
op.execute("DROP TABLE IF EXISTS oauth_clients")
op.execute("DROP TABLE IF EXISTS audit_logs")
op.execute("DROP TABLE IF EXISTS refresh_tokens")
op.drop_index("idx_webhooks_created", table_name="registered_webhooks")
op.drop_index("idx_webhooks_preset", table_name="registered_webhooks")
op.drop_table("registered_webhooks")
# ``schema_version`` is only created on SQLite (see ``upgrade()``).
if op.get_bind().dialect.name == "sqlite":
op.drop_table("schema_version")
op.drop_index("idx_oauth_sessions_mcp_code", table_name="oauth_sessions")
op.drop_table("oauth_sessions")
op.drop_table("oauth_clients")
op.drop_index("idx_audit_user_timestamp", table_name="audit_logs")
op.drop_table("audit_logs")
op.drop_table("refresh_tokens")
@@ -10,6 +10,8 @@ Create Date: 2026-01-13 12:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
@@ -22,29 +24,19 @@ depends_on = None
def upgrade() -> None:
"""Add app_passwords table for multi-user BasicAuth mode."""
# App passwords table for multi-user BasicAuth background sync
op.execute(
"""
CREATE TABLE IF NOT EXISTS app_passwords (
user_id TEXT PRIMARY KEY,
encrypted_password BLOB NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"""
)
# Index for efficient user lookups
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_app_passwords_updated
ON app_passwords(updated_at)
"""
op.create_table(
"app_passwords",
sa.Column("user_id", sa.Text, primary_key=True),
sa.Column("encrypted_password", sa.LargeBinary, nullable=False),
# BigInteger to keep unix epochs in range on Postgres (see 001).
sa.Column("created_at", sa.BigInteger, nullable=False),
sa.Column("updated_at", sa.BigInteger, nullable=False),
)
op.create_index("idx_app_passwords_updated", "app_passwords", ["updated_at"])
def downgrade() -> None:
"""Drop app_passwords table."""
op.execute("DROP INDEX IF EXISTS idx_app_passwords_updated")
op.execute("DROP TABLE IF EXISTS app_passwords")
op.drop_index("idx_app_passwords_updated", table_name="app_passwords")
op.drop_table("app_passwords")
@@ -12,6 +12,8 @@ Create Date: 2026-02-27 12:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
@@ -24,72 +26,37 @@ depends_on = None
def upgrade() -> None:
"""Add scopes/username to app_passwords and create login_flow_sessions."""
# Add scopes column (nullable JSON array, NULL = all scopes allowed)
op.execute(
"""
ALTER TABLE app_passwords ADD COLUMN scopes TEXT
"""
)
# Nullable scope columns on the existing app_passwords table.
op.add_column("app_passwords", sa.Column("scopes", sa.Text))
op.add_column("app_passwords", sa.Column("username", sa.Text))
# Add username column (Nextcloud loginName from Login Flow v2)
op.execute(
"""
ALTER TABLE app_passwords ADD COLUMN username TEXT
"""
op.create_table(
"login_flow_sessions",
sa.Column("user_id", sa.Text, primary_key=True),
sa.Column("encrypted_poll_token", sa.LargeBinary, nullable=False),
sa.Column("poll_endpoint", sa.Text, nullable=False),
sa.Column("requested_scopes", sa.Text),
# BigInteger to keep unix epochs in range on Postgres (see 001).
sa.Column("created_at", sa.BigInteger, nullable=False),
sa.Column("expires_at", sa.BigInteger, nullable=False),
)
# Login Flow v2 session tracking
op.execute(
"""
CREATE TABLE IF NOT EXISTS login_flow_sessions (
user_id TEXT PRIMARY KEY,
encrypted_poll_token BLOB NOT NULL,
poll_endpoint TEXT NOT NULL,
requested_scopes TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
)
"""
)
# Index for efficient cleanup of expired sessions
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_login_flow_sessions_expires
ON login_flow_sessions(expires_at)
"""
op.create_index(
"idx_login_flow_sessions_expires",
"login_flow_sessions",
["expires_at"],
)
def downgrade() -> None:
"""Drop login_flow_sessions and remove added columns."""
"""Drop login_flow_sessions and remove added columns.
op.execute("DROP INDEX IF EXISTS idx_login_flow_sessions_expires")
op.execute("DROP TABLE IF EXISTS login_flow_sessions")
``batch_alter_table`` handles SQLite's pre-3.35 lack of ``DROP COLUMN``
by recreating the table; on Postgres it issues a native ``DROP COLUMN``.
"""
# SQLite doesn't support DROP COLUMN before 3.35.0
# Recreate app_passwords without the new columns
op.execute(
"""
CREATE TABLE app_passwords_backup (
user_id TEXT PRIMARY KEY,
encrypted_password BLOB NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"""
)
op.execute(
"""
INSERT INTO app_passwords_backup (user_id, encrypted_password, created_at, updated_at)
SELECT user_id, encrypted_password, created_at, updated_at FROM app_passwords
"""
)
op.execute("DROP TABLE app_passwords")
op.execute("ALTER TABLE app_passwords_backup RENAME TO app_passwords")
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_app_passwords_updated
ON app_passwords(updated_at)
"""
)
op.drop_index("idx_login_flow_sessions_expires", table_name="login_flow_sessions")
op.drop_table("login_flow_sessions")
with op.batch_alter_table("app_passwords") as batch_op:
batch_op.drop_column("username")
batch_op.drop_column("scopes")
@@ -10,6 +10,8 @@ Revises: 004
Create Date: 2026-05-02 15:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
revision = "005"
@@ -19,31 +21,19 @@ depends_on = None
def upgrade() -> None:
op.execute(
"""
CREATE TABLE IF NOT EXISTS browser_sessions (
session_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
)
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_browser_sessions_user
ON browser_sessions(user_id)
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_browser_sessions_expires
ON browser_sessions(expires_at)
"""
op.create_table(
"browser_sessions",
sa.Column("session_id", sa.Text, primary_key=True),
sa.Column("user_id", sa.Text, nullable=False),
# BigInteger to keep unix epochs in range on Postgres (see 001).
sa.Column("created_at", sa.BigInteger, nullable=False),
sa.Column("expires_at", sa.BigInteger, nullable=False),
)
op.create_index("idx_browser_sessions_user", "browser_sessions", ["user_id"])
op.create_index("idx_browser_sessions_expires", "browser_sessions", ["expires_at"])
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS idx_browser_sessions_expires")
op.execute("DROP INDEX IF EXISTS idx_browser_sessions_user")
op.execute("DROP TABLE IF EXISTS browser_sessions")
op.drop_index("idx_browser_sessions_expires", table_name="browser_sessions")
op.drop_index("idx_browser_sessions_user", table_name="browser_sessions")
op.drop_table("browser_sessions")
@@ -12,6 +12,8 @@ Revises: 005
Create Date: 2026-05-02 16:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
revision = "006"
@@ -21,9 +23,14 @@ depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE oauth_sessions ADD COLUMN nonce TEXT")
# ``batch_alter_table`` emits a native ``ALTER TABLE ... ADD COLUMN``
# on Postgres and works around SQLite's pre-3.35 limitations by
# recreating the table when needed. Matches the portable-DDL style of
# the rewritten migrations 001-005 (PR #798 review nit).
with op.batch_alter_table("oauth_sessions") as batch_op:
batch_op.add_column(sa.Column("nonce", sa.Text))
def downgrade() -> None:
# SQLite < 3.35 cannot DROP COLUMN; leave the column on downgrade.
pass
with op.batch_alter_table("oauth_sessions") as batch_op:
batch_op.drop_column("nonce")
+15 -1
View File
@@ -599,6 +599,12 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
logger.info("Shutting down BasicAuth session")
if client is not None:
await client.close()
# Dispose the storage engine so pooled asyncpg connections drain
# cleanly on SIGTERM (ADR-026, PR #798 round-4).
try:
await storage.close()
except Exception as e:
logger.warning("Error disposing storage: %s", e)
async def setup_oauth_config():
@@ -1229,7 +1235,15 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
)
finally:
logger.info("Shutting down MCP server")
# RefreshTokenStorage uses context managers, no close() needed
# Dispose the RefreshTokenStorage engine so pooled
# asyncpg connections drain cleanly on SIGTERM instead
# of leaking server-side slots until the Postgres
# idle-timeout fires (ADR-026, PR #798 round-4).
if refresh_token_storage is not None:
try:
await refresh_token_storage.close()
except Exception as e:
logger.warning("Error disposing refresh-token storage: %s", e)
# OAuth client cleanup (if it has a close method)
if oauth_client and hasattr(oauth_client, "close"):
try:
File diff suppressed because it is too large Load Diff
+74 -73
View File
@@ -5,8 +5,8 @@ import click
import uvicorn
from nextcloud_mcp_server.config import (
get_database_url,
get_settings,
get_token_db_path,
is_ephemeral_token_db,
)
from nextcloud_mcp_server.migrations import (
@@ -287,27 +287,67 @@ def db():
pass
def _warn_if_ephemeral(database_path: str) -> None:
if is_ephemeral_token_db(database_path):
def _resolve_db_url(database_url: str | None, database_path: str | None) -> str:
"""Pick the database URL for a CLI subcommand.
Priority: explicit ``--database-url`` > legacy ``--database-path``
(treated as a SQLite file) > :func:`get_database_url` (honors
``DATABASE_URL`` env or falls back to the ephemeral SQLite tempfile).
"""
if database_url:
return database_url
if database_path:
return f"sqlite+aiosqlite:///{database_path}"
return get_database_url()
def _warn_if_ephemeral(database_url: str) -> None:
"""Warn when the resolved URL is the per-process SQLite tempfile."""
if not database_url.startswith(
"sqlite+aiosqlite:///"
) and not database_url.startswith("sqlite:///"):
return
path = database_url.split("///", 1)[1]
if is_ephemeral_token_db(path):
click.echo(
click.style(
f"⚠ Using ephemeral tempfile {database_path}; changes "
"will be lost on exit. Pass --database-path or set "
"TOKEN_STORAGE_DB to operate on a persistent database.",
f"⚠ Using ephemeral tempfile {path}; changes "
"will be lost on exit. Pass --database-url / --database-path "
"or set DATABASE_URL / TOKEN_STORAGE_DB to operate on a "
"persistent database.",
fg="yellow",
),
err=True,
)
@db.command()
@click.option(
def _db_target_options(fn):
"""Attach the shared ``--database-url`` / ``--database-path`` options.
Using a decorator factory rather than ``**kwargs`` dict-expansion so
static type checkers (ty) see ``click.option`` called with literal
keyword arguments, which is the only form it's typed to accept.
"""
fn = click.option(
"--database-path",
"-d",
envvar="TOKEN_STORAGE_DB",
default=None,
help="Path to token storage database (can also use TOKEN_STORAGE_DB env var)",
)
help="SQLite database file path. Equivalent to "
"--database-url sqlite+aiosqlite:///<path>.",
)(fn)
fn = click.option(
"--database-url",
"-u",
envvar="DATABASE_URL",
default=None,
help="SQLAlchemy URL (e.g. postgresql+asyncpg://...). Wins over --database-path.",
)(fn)
return fn
@db.command()
@_db_target_options
@click.option(
"--revision",
"-r",
@@ -315,7 +355,7 @@ def _warn_if_ephemeral(database_path: str) -> None:
show_default=True,
help="Target revision (default: head for latest)",
)
def upgrade(database_path: str | None, revision: str):
def upgrade(database_url: str | None, database_path: str | None, revision: str):
"""Upgrade database to a specific revision.
\b
@@ -323,17 +363,17 @@ def upgrade(database_path: str | None, revision: str):
# Upgrade to latest version
$ nextcloud-mcp-server db upgrade
# Upgrade to specific revision
$ nextcloud-mcp-server db upgrade --revision 001
# Upgrade a Postgres backend
$ nextcloud-mcp-server db upgrade -u postgresql+asyncpg://mcp:mcp@db/mcp
# Use custom database path
# Use custom SQLite path
$ nextcloud-mcp-server db upgrade -d /path/to/tokens.db
"""
database_path = database_path or get_token_db_path()
_warn_if_ephemeral(database_path)
url = _resolve_db_url(database_url, database_path)
_warn_if_ephemeral(url)
try:
click.echo(f"Upgrading database to revision: {revision}")
upgrade_database(database_path, revision)
upgrade_database(url, revision)
click.echo(click.style("✓ Database upgraded successfully", fg="green"))
except Exception as e:
click.echo(click.style(f"✗ Upgrade failed: {e}", fg="red"), err=True)
@@ -341,13 +381,7 @@ def upgrade(database_path: str | None, revision: str):
@db.command()
@click.option(
"--database-path",
"-d",
envvar="TOKEN_STORAGE_DB",
default=None,
help="Path to token storage database",
)
@_db_target_options
@click.option(
"--revision",
"-r",
@@ -358,27 +392,16 @@ def upgrade(database_path: str | None, revision: str):
@click.confirmation_option(
prompt="Are you sure you want to downgrade the database? This may result in data loss."
)
def downgrade(database_path: str | None, revision: str):
def downgrade(database_url: str | None, database_path: str | None, revision: str):
"""Downgrade database to a specific revision.
WARNING: This may result in data loss! Use with caution.
\b
Examples:
# Downgrade by one version
$ nextcloud-mcp-server db downgrade
# Downgrade to specific revision
$ nextcloud-mcp-server db downgrade --revision 001
# Downgrade to base (empty database)
$ nextcloud-mcp-server db downgrade --revision base
"""
database_path = database_path or get_token_db_path()
_warn_if_ephemeral(database_path)
url = _resolve_db_url(database_url, database_path)
_warn_if_ephemeral(url)
try:
click.echo(f"Downgrading database to revision: {revision}")
downgrade_database(database_path, revision)
downgrade_database(url, revision)
click.echo(click.style("✓ Database downgraded successfully", fg="green"))
except Exception as e:
click.echo(click.style(f"✗ Downgrade failed: {e}", fg="red"), err=True)
@@ -386,24 +409,13 @@ def downgrade(database_path: str | None, revision: str):
@db.command()
@click.option(
"--database-path",
"-d",
envvar="TOKEN_STORAGE_DB",
default=None,
help="Path to token storage database",
)
def current(database_path: str | None):
"""Show current database revision.
\b
Example:
$ nextcloud-mcp-server db current
"""
database_path = database_path or get_token_db_path()
_warn_if_ephemeral(database_path)
@_db_target_options
def current(database_url: str | None, database_path: str | None):
"""Show current database revision."""
url = _resolve_db_url(database_url, database_path)
_warn_if_ephemeral(url)
try:
revision = get_current_revision(database_path)
revision = get_current_revision(url)
if revision:
click.echo(f"Current revision: {click.style(revision, fg='cyan')}")
else:
@@ -420,25 +432,14 @@ def current(database_path: str | None):
@db.command()
@click.option(
"--database-path",
"-d",
envvar="TOKEN_STORAGE_DB",
default=None,
help="Path to token storage database",
)
def history(database_path: str | None):
"""Show migration history.
\b
Example:
$ nextcloud-mcp-server db history
"""
database_path = database_path or get_token_db_path()
_warn_if_ephemeral(database_path)
@_db_target_options
def history(database_url: str | None, database_path: str | None):
"""Show migration history."""
url = _resolve_db_url(database_url, database_path)
_warn_if_ephemeral(url)
try:
click.echo("Migration history:")
show_migration_history(database_path)
show_migration_history(url)
except Exception as e:
click.echo(click.style(f"✗ Failed to show history: {e}", fg="red"), err=True)
raise click.ClickException(str(e))
+164
View File
@@ -56,6 +56,25 @@ _DEFAULTS: dict[str, Any] = {
# None = ephemeral per-process tempfile (see get_token_db_path()).
# Set TOKEN_STORAGE_DB to persist tokens across restarts.
"token_storage_db": None,
# Centralized backend (any SQLAlchemy URL). Wins over TOKEN_STORAGE_DB
# when set. Use postgresql+asyncpg://user:pw@host/db for HA k8s
# deployments so pods can be stateless. See ADR-026.
"database_url": None,
# TLS for the Postgres backend (mirror NEXTCLOUD_VERIFY_SSL pattern).
# Default is None — preserve asyncpg's `prefer` mode so cluster-local
# Postgres without TLS works out of the box. Set to True for full
# verification or False to silence cert errors against self-signed
# homelab servers. DATABASE_CA_BUNDLE points at a private-CA PEM.
"database_verify_ssl": None,
"database_ca_bundle": None,
# Postgres connection pool sizing (ADR-026 → "Concurrency model and
# pool sizing"). Per-pod defaults to 2 + 5 overflow = 7 max
# connections. asyncpg connections are single-flight, so the pool
# only needs to cover typical multi-user MCP burst — not every
# potential in-flight tool call. Tune up with DATABASE_POOL_SIZE /
# DATABASE_MAX_OVERFLOW for high-traffic prod fleets.
"database_pool_size": 2,
"database_max_overflow": 5,
# Webhook delivery authentication (ADR-010): when set, registrations
# tell NC to add `Authorization: Bearer <secret>` to webhook deliveries
# and the receiver rejects unauthenticated requests.
@@ -279,6 +298,58 @@ def is_ephemeral_token_db(path: str) -> bool:
return path == _ephemeral_db_path
def get_database_url() -> str:
"""Resolve the SQLAlchemy database URL for token storage.
Priority:
1. ``DATABASE_URL`` if set — any SQLAlchemy URL is accepted; the primary
supported backends are ``postgresql+asyncpg://...`` for HA k8s
deployments and ``sqlite+aiosqlite:///...`` for development.
2. Otherwise build ``sqlite+aiosqlite:///{get_token_db_path()}`` so the
legacy ``TOKEN_STORAGE_DB`` env var and the ephemeral-tempfile
fallback both keep working unchanged.
"""
explicit = _dynaconf.get("DATABASE_URL")
if explicit:
return str(explicit)
return f"sqlite+aiosqlite:///{get_token_db_path()}"
def is_sqlite_url(url: str) -> bool:
"""Return True for SQLite SQLAlchemy URLs (used to gate sqlite-only logic
like file-permission hardening and ``sqlite_master`` legacy lookups).
Recognizes both file-backed (``sqlite+aiosqlite:///path/to/db``) and
in-memory (``sqlite+aiosqlite:///:memory:``) URLs. The caller is
responsible for handling ``:memory:`` as a magic value where a real
filesystem path is expected.
"""
return url.lower().startswith("sqlite")
def mask_db_password(url: str) -> str:
"""Return a logger-safe rendering of a SQLAlchemy URL.
DATABASE_URL routinely carries a password (e.g.
``postgresql+asyncpg://mcp:secret@db/mcp``); logging it raw leaks the
secret to stdout/stderr and any aggregator. SQLAlchemy's
:func:`make_url` + ``render_as_string(hide_password=True)`` substitutes
a fixed ``***`` placeholder while keeping the rest of the URL intact
so operators can still see which host / driver they're hitting.
"""
try:
from sqlalchemy.engine.url import make_url # noqa: PLC0415
return make_url(url).render_as_string(hide_password=True)
except Exception:
# If parsing fails (e.g. an explicit ssl-disable test URL with an
# exotic shape), fall back to a regex that scrubs any
# ``://user:password@`` pattern. Never raise from a logging path.
import re # noqa: PLC0415
return re.sub(r"(://[^:/]+):[^@]*@", r"\1:***@", url)
LOGGING_CONFIG = {
"version": 1,
"disable_existing_loggers": False,
@@ -442,6 +513,22 @@ class Settings:
nextcloud_verify_ssl: bool = True
nextcloud_ca_bundle: str | None = None
# Postgres backend TLS settings (ADR-026). Default verify_ssl is None,
# not True: when DATABASE_URL is unset there's nothing to verify, and
# when it is set we don't want to break cluster-internal Postgres that
# commonly runs without TLS. Operators opt in to verify-full with True
# or supply a private-CA bundle.
database_verify_ssl: bool | None = None
database_ca_bundle: str | None = None
# Postgres connection pool sizing (ADR-026). The asyncpg engine maps
# these to its underlying QueuePool. Per-pod max = pool_size +
# max_overflow. Defaults are intentionally small (2 + 5 = 7) because
# asyncpg connections are single-flight and the typical MCP workload
# is light read-mostly point lookups. Validate >= 1 / >= 0 in
# __post_init__.
database_pool_size: int = 2
database_max_overflow: int = 5
# ADR-005: Token Audience Validation (required for OAuth mode)
nextcloud_mcp_server_url: str | None = None # MCP server URL (used as audience)
nextcloud_resource_uri: str | None = None # Nextcloud resource identifier
@@ -575,6 +662,35 @@ class Settings:
)
logger.info("Using custom CA bundle: %s", self.nextcloud_ca_bundle)
# Validate Postgres backend TLS configuration (ADR-026)
if self.database_verify_ssl is False:
logger.warning(
"DATABASE_VERIFY_SSL is disabled. "
"TLS certificate verification is turned off for the Postgres "
"backend. Only acceptable for homelab / self-signed setups; "
"prefer DATABASE_CA_BUNDLE for production."
)
if self.database_ca_bundle:
if not os.path.isfile(self.database_ca_bundle):
raise ValueError(
f"DATABASE_CA_BUNDLE path does not exist: {self.database_ca_bundle}"
)
logger.info(
"Using custom CA bundle for Postgres backend: %s",
self.database_ca_bundle,
)
# Pool sizing must be sensible — guard against operators accidentally
# setting 0 / negative via env (would deadlock at first request).
if self.database_pool_size < 1:
raise ValueError(
f"DATABASE_POOL_SIZE must be >= 1; got {self.database_pool_size}"
)
if self.database_max_overflow < 0:
raise ValueError(
f"DATABASE_MAX_OVERFLOW must be >= 0; got {self.database_max_overflow}"
)
# Ensure mutual exclusivity
if self.qdrant_url and self.qdrant_location:
raise ValueError(
@@ -930,6 +1046,12 @@ def get_settings() -> Settings:
# Nextcloud SSL/TLS settings
"nextcloud_verify_ssl": "NEXTCLOUD_VERIFY_SSL",
"nextcloud_ca_bundle": "NEXTCLOUD_CA_BUNDLE",
# Postgres backend TLS (ADR-026)
"database_verify_ssl": "DATABASE_VERIFY_SSL",
"database_ca_bundle": "DATABASE_CA_BUNDLE",
# Postgres backend pool sizing (ADR-026)
"database_pool_size": "DATABASE_POOL_SIZE",
"database_max_overflow": "DATABASE_MAX_OVERFLOW",
# ADR-005: Token Audience Validation
"nextcloud_mcp_server_url": "NEXTCLOUD_MCP_SERVER_URL",
"nextcloud_resource_uri": "NEXTCLOUD_RESOURCE_URI",
@@ -1028,3 +1150,45 @@ def get_nextcloud_ssl_verify() -> bool | ssl.SSLContext:
ctx = ssl.create_default_context(cafile=settings.nextcloud_ca_bundle)
return ctx
return True
def get_database_ssl() -> bool | ssl.SSLContext | None:
"""Return the asyncpg ``ssl`` arg for the Postgres backend (ADR-026).
Returns:
- ``None`` when both DATABASE_VERIFY_SSL and DATABASE_CA_BUNDLE are
unset — caller skips passing ``ssl`` so asyncpg keeps its default
(``prefer``). Preserves PR #798 behavior for cluster-local
Postgres without TLS.
- ``False`` if DATABASE_VERIFY_SSL=false (silence cert errors).
- ``ssl.SSLContext`` if DATABASE_CA_BUNDLE is set (custom private
CA, implies verify-full).
- ``True`` if DATABASE_VERIFY_SSL=true and no bundle (verify-full
against system trust store).
DATABASE_VERIFY_SSL=false wins over DATABASE_CA_BUNDLE so an operator
can quickly silence cert errors during incident response without
having to delete the bundle path from their secret store. Matches the
Nextcloud-pattern precedence for symmetry with
:func:`get_nextcloud_ssl_verify`.
"""
settings = get_settings()
if settings.database_verify_ssl is False:
# Operator-explicit opt-out (DATABASE_VERIFY_SSL=false) — semantics
# are documented in the docstring above and ADR-026 TLS section.
# Bare NOSONAR silences any cert-verification-required rule that
# may fire on this branch (defensive; no such rule fires today).
return False # NOSONAR
if settings.database_ca_bundle:
# ``ssl.create_default_context()`` on Python 3.10+ already negotiates
# the strongest available protocol (TLS 1.2+ with secure ciphers);
# we pin Python 3.11+ in pyproject.toml. ``purpose=SERVER_AUTH`` is
# the default but spelt out here so the intent is visible to
# static analysers and human readers alike.
return ssl.create_default_context( # NOSONAR
purpose=ssl.Purpose.SERVER_AUTH,
cafile=settings.database_ca_bundle,
)
if settings.database_verify_ssl is True:
return True
return None
+96 -87
View File
@@ -3,22 +3,72 @@
This module provides helper functions for managing Alembic database migrations
programmatically. It enables automatic migration on application startup and
provides CLI integration.
All helpers accept a SQLAlchemy URL (``sqlite+aiosqlite:///...`` or
``postgresql+asyncpg://...``). When called without an explicit URL they fall
back to :func:`nextcloud_mcp_server.config.get_database_url`.
"""
import logging
import sqlite3
from pathlib import Path
from alembic.config import Config
from sqlalchemy import create_engine, inspect, text
import nextcloud_mcp_server.alembic as alembic_package
from alembic import command
from nextcloud_mcp_server.config import get_token_db_path
from nextcloud_mcp_server.config import get_database_url, mask_db_password
logger = logging.getLogger(__name__)
def get_alembic_config(database_path: str | Path | None = None) -> Config:
def _coerce_url(database_url: str | Path | None) -> str:
"""Accept either a URL string, a Path (legacy SQLite path), or None.
A bare ``Path`` is interpreted as a SQLite database file for backward
compatibility with the prior path-based API.
"""
if database_url is None:
return get_database_url()
if isinstance(database_url, Path):
return f"sqlite+aiosqlite:///{database_url.resolve()}"
return database_url
_KNOWN_ASYNC_DRIVERS = ("aiosqlite", "asyncpg")
def _to_sync_url(database_url: str) -> str:
"""Map an async driver URL to its sync equivalent for blocking inspection.
SQLAlchemy's :func:`inspect` and :func:`create_engine` used below are
synchronous APIs. The runtime uses async drivers (``aiosqlite``,
``asyncpg``) but Alembic and these utility queries don't need them.
Emits a one-shot warning when the URL carries an async-driver
suffix we don't recognize — the sync engine creation downstream
will still fail, but with a clearer hint than SQLAlchemy's generic
"Can't load plugin" error.
"""
out = database_url
for driver in _KNOWN_ASYNC_DRIVERS:
out = out.replace(f"+{driver}", "")
# Detect a leftover ``+<driver>`` token (we know the URL is
# ``scheme[+driver]://...``, so a remaining ``+`` before ``://``
# means an unrecognized async driver). Log once and pass through.
head = out.split("://", 1)[0]
if "+" in head:
unknown = head.split("+", 1)[1]
logger.warning(
"_to_sync_url: unrecognized driver %r in DATABASE_URL; "
"passing through unchanged. Supported async drivers: %s",
unknown,
", ".join(_KNOWN_ASYNC_DRIVERS),
)
return out
def get_alembic_config(database_url: str | Path | None = None) -> Config:
"""
Get Alembic configuration for programmatic use.
@@ -26,145 +76,102 @@ def get_alembic_config(database_path: str | Path | None = None) -> Config:
package location instead of alembic.ini file.
Args:
database_path: Path to SQLite database file. If None, resolves via
config.get_token_db_path() (ephemeral tempfile unless
TOKEN_STORAGE_DB is set).
database_url: SQLAlchemy URL. If None, resolves via
:func:`get_database_url` (DATABASE_URL env var, falling back
to the ephemeral SQLite tempfile under ``TOKEN_STORAGE_DB``).
For backward compatibility a ``Path`` is treated as a SQLite
file path.
Returns:
Alembic Config object configured for the specified database
Alembic Config object configured for the resolved URL.
"""
# Use package location (works in both editable and installed modes)
if alembic_package.__file__ is None:
raise RuntimeError("alembic package __file__ is None")
script_location = Path(alembic_package.__file__).parent
# Create config programmatically (no alembic.ini needed at runtime)
config = Config()
config.set_main_option("script_location", str(script_location))
config.set_main_option("path_separator", "os") # Suppress deprecation warning
config.set_main_option("path_separator", "os")
# Set database URL
if database_path:
db_path = Path(database_path).resolve()
else:
db_path = Path(get_token_db_path()).resolve()
url = f"sqlite+aiosqlite:///{db_path}"
url = _coerce_url(database_url)
config.set_main_option("sqlalchemy.url", url)
logger.debug("Alembic script location: %s", script_location)
logger.debug("Database: %s", db_path)
logger.debug("Database URL: %s", mask_db_password(url))
return config
def upgrade_database(
database_path: str | Path | None = None, revision: str = "head"
database_url: str | Path | None = None, revision: str = "head"
) -> None:
"""
Upgrade database to a specific revision.
Args:
database_path: Path to SQLite database file
revision: Target revision (default: "head" for latest)
"""
config = get_alembic_config(database_path)
"""Upgrade database to a specific revision (default: latest)."""
config = get_alembic_config(database_url)
logger.info("Upgrading database to revision: %s", revision)
command.upgrade(config, revision)
logger.info("Database upgrade completed successfully")
def downgrade_database(
database_path: str | Path | None = None, revision: str = "-1"
database_url: str | Path | None = None, revision: str = "-1"
) -> None:
"""
Downgrade database to a specific revision.
Args:
database_path: Path to SQLite database file
revision: Target revision (default: "-1" for previous version)
"""
config = get_alembic_config(database_path)
"""Downgrade database to a specific revision (default: previous)."""
config = get_alembic_config(database_url)
logger.warning("Downgrading database to revision: %s", revision)
command.downgrade(config, revision)
logger.info("Database downgrade completed successfully")
def get_current_revision(database_path: str | Path | None = None) -> str | None:
def get_current_revision(database_url: str | Path | None = None) -> str | None:
"""
Get the current database revision by directly querying the alembic_version table.
Get the current database revision by reading the ``alembic_version`` table.
Args:
database_path: Path to SQLite database file
Returns:
Current revision ID or None if not versioned
Returns ``None`` when the database does not exist or has no
``alembic_version`` table (i.e. has never been migrated).
"""
url = _to_sync_url(_coerce_url(database_url))
if database_path is None:
database_path = get_token_db_path()
db_path = Path(database_path).resolve()
if not db_path.exists():
logger.debug("Database does not exist: %s", db_path)
if url.startswith("sqlite:///"):
path = url[len("sqlite:///") :]
if path and not Path(path).exists():
logger.debug("Database does not exist: %s", path)
return None
try:
# Query alembic_version table directly
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
# Check if alembic_version table exists
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='alembic_version'"
)
has_table = cursor.fetchone() is not None
if not has_table:
conn.close()
engine = create_engine(url, future=True)
try:
inspector = inspect(engine)
if not inspector.has_table("alembic_version"):
return None
# Get current version
cursor.execute("SELECT version_num FROM alembic_version")
row = cursor.fetchone()
conn.close()
with engine.connect() as conn:
row = conn.execute(
text("SELECT version_num FROM alembic_version")
).fetchone()
return row[0] if row else None
finally:
engine.dispose()
except Exception as e:
logger.error("Failed to get current revision: %s", e)
return None
def stamp_database(
database_path: str | Path | None = None, revision: str = "head"
database_url: str | Path | None = None, revision: str = "head"
) -> None:
"""
Stamp database with a specific revision without running migrations.
This is useful for marking existing databases that were created before
Alembic was introduced. It tells Alembic "this database is at revision X"
without actually running the migration.
Args:
database_path: Path to SQLite database file
revision: Revision to stamp (default: "head" for latest)
Useful for marking pre-Alembic databases as already at a known revision.
"""
config = get_alembic_config(database_path)
config = get_alembic_config(database_url)
logger.info("Stamping database with revision: %s", revision)
command.stamp(config, revision)
logger.info("Database stamped successfully")
def show_migration_history(database_path: str | Path | None = None) -> None:
"""
Display migration history.
Args:
database_path: Path to SQLite database file
"""
config = get_alembic_config(database_path)
def show_migration_history(database_url: str | Path | None = None) -> None:
"""Display migration history."""
config = get_alembic_config(database_url)
command.history(config, verbose=True)
@@ -178,7 +185,9 @@ def create_migration(message: str, autogenerate: bool = False) -> None:
Note:
Since we don't use SQLAlchemy models, autogenerate will be disabled
and migrations must be written manually.
and migrations must be written manually using portable Alembic
operations (``op.create_table``, ``op.add_column`` …) rather than
raw SQL so they work on both SQLite and Postgres.
"""
config = get_alembic_config()
logger.info("Creating new migration: %s", message)
+7
View File
@@ -45,6 +45,7 @@ dependencies = [
"openai>=2.8.1",
"dynaconf>=3.2.13,<4.0",
"mistralai>=2.4.5",
"sqlalchemy[asyncio]>=2.0",
]
classifiers = [
"Development Status :: 4 - Beta",
@@ -78,6 +79,7 @@ markers = [
"keycloak: OAuth tests that utilize keycloak external identity provider",
"login_flow: Login Flow v2 integration tests (ADR-022)",
"multi_user_basic: Multi-user BasicAuth pass-through tests (ADR-020)",
"postgres: Tests requiring the docker-compose postgres-test service (ADR-026)",
]
testpaths = [
"tests",
@@ -128,6 +130,11 @@ dev = [
[project.scripts]
nextcloud-mcp-server = "nextcloud_mcp_server.cli:cli"
[project.optional-dependencies]
postgres = [
"asyncpg>=0.29",
]
[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
View File
+97
View File
@@ -0,0 +1,97 @@
"""Shared pytest fixture for parametrizing storage tests over backends.
Tests for ``RefreshTokenStorage`` are exercised against every backend that is
available in the current environment:
- ``sqlite`` — always available; uses a per-test tempfile.
- ``postgres`` — opt-in. Bring up the test instance with::
docker compose --profile postgres up -d postgres-test
and export the URL so the fixture picks it up::
export TEST_DATABASE_URL=postgresql+asyncpg://mcp:mcp@localhost:5433/mcp
When ``TEST_DATABASE_URL`` is unset (or the host is unreachable), the
Postgres parametrization is skipped automatically so the suite still runs
cleanly without Docker.
Each Postgres test runs against an isolated schema that is dropped and
recreated between tests, mirroring the per-tempfile isolation that the
SQLite path gets for free.
"""
from __future__ import annotations
import os
from typing import Any
import pytest
def _postgres_url() -> str | None:
"""Resolve the Postgres URL for tests, or ``None`` when opted out."""
return os.environ.get("TEST_DATABASE_URL") or None
def _postgres_reachable(url: str) -> bool:
"""Return ``True`` if the configured Postgres accepts TCP connections.
A lightweight socket probe is used rather than a full DB handshake so
we don't have to ship a sync Postgres driver (psycopg2) just for the
test gate — asyncpg only works inside an event loop.
"""
import socket
from urllib.parse import urlparse
try:
parsed = urlparse(url)
host = parsed.hostname or "localhost"
port = parsed.port or 5432
with socket.create_connection((host, port), timeout=1.0):
return True
except OSError:
return False
def _backend_params() -> list[Any]:
"""Build the pytest parametrize list, gating Postgres on availability."""
params: list[Any] = [pytest.param("sqlite", id="sqlite")]
url = _postgres_url()
if url and _postgres_reachable(url):
params.append(pytest.param(url, id="postgres"))
return params
@pytest.fixture(params=_backend_params())
def storage_backend(request):
"""Yield ``{"kind": ..., "url": ..., "reset": <async>}`` per backend.
For ``sqlite`` the test fixture builds its own tempfile path; only the
``kind`` discriminator is used. For ``postgres`` the URL is forwarded
and a ``reset()`` coroutine is provided so test fixtures can wipe the
schema between parametrized runs.
"""
if request.param == "sqlite":
yield {"kind": "sqlite"}
return
url = request.param
async def reset() -> None:
# Use a fresh async engine so we don't fight an async connection
# the test might still be holding open at teardown time. asyncpg
# is the only driver we ship for Postgres, so the reset path stays
# event-loop-only (no psycopg2 dependency required).
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
engine = create_async_engine(url, future=True)
try:
async with engine.begin() as conn:
await conn.execute(text("DROP SCHEMA public CASCADE"))
await conn.execute(text("CREATE SCHEMA public"))
finally:
await engine.dispose()
yield {"kind": "postgres", "url": url, "reset": reset}
+325
View File
@@ -0,0 +1,325 @@
"""End-to-end Postgres backend smoke for RefreshTokenStorage (ADR-026).
Exercises every storage method touched by the SQLAlchemy / asyncpg port
against a fresh Postgres schema. The test is opt-in: it requires the
``postgres-test`` docker-compose service to be running and
``TEST_DATABASE_URL`` to be exported.
Bring up the dependency once::
docker compose --profile postgres up -d postgres-test
export TEST_DATABASE_URL=postgresql+asyncpg://mcp:mcp@localhost:5433/mcp
Then run::
uv run pytest tests/integration/test_storage_postgres.py -v -m postgres
When ``TEST_DATABASE_URL`` is unset (or the service is unreachable) the
test is skipped so the full suite still passes locally without Docker.
"""
from __future__ import annotations
import os
import socket
from urllib.parse import urlparse
import pytest
from cryptography.fernet import Fernet
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
pytestmark = [pytest.mark.integration, pytest.mark.postgres]
def _postgres_url() -> str | None:
return os.environ.get("TEST_DATABASE_URL") or None
def _reachable(url: str) -> bool:
parsed = urlparse(url)
try:
with socket.create_connection(
(parsed.hostname or "localhost", parsed.port or 5432), timeout=1.0
):
return True
except OSError:
return False
@pytest.fixture
def postgres_url() -> str:
url = _postgres_url()
if not url:
pytest.skip(
"TEST_DATABASE_URL not set — run "
"`docker compose --profile postgres up -d postgres-test` and export "
"TEST_DATABASE_URL=postgresql+asyncpg://mcp:mcp@localhost:5433/mcp"
)
if not _reachable(url):
pytest.skip(f"Postgres at {url} is not reachable")
return url
@pytest.fixture
async def reset_schema(postgres_url: str):
"""Drop+recreate the public schema before and after each test."""
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
async def _reset() -> None:
engine = create_async_engine(postgres_url, future=True)
try:
async with engine.begin() as conn:
await conn.execute(text("DROP SCHEMA public CASCADE"))
await conn.execute(text("CREATE SCHEMA public"))
finally:
await engine.dispose()
await _reset()
yield
await _reset()
@pytest.fixture
async def storage(postgres_url: str, reset_schema):
key = Fernet.generate_key()
s = RefreshTokenStorage(database_url=postgres_url, encryption_key=key)
await s.initialize()
yield s
async def test_refresh_token_roundtrip(storage: RefreshTokenStorage):
"""Store + retrieve + upsert + delete a refresh token end-to-end."""
await storage.store_refresh_token(
user_id="alice", refresh_token="rt-1", expires_at=9_999_999_999
)
tok = await storage.get_refresh_token("alice")
assert tok is not None
assert tok["refresh_token"] == "rt-1"
assert tok["expires_at"] == 9_999_999_999
# Upsert preserves user_id, swaps token contents.
await storage.store_refresh_token(
user_id="alice", refresh_token="rt-2", expires_at=9_999_999_999
)
tok = await storage.get_refresh_token("alice")
assert tok is not None and tok["refresh_token"] == "rt-2"
assert await storage.delete_refresh_token("alice") is True
assert await storage.get_refresh_token("alice") is None
async def test_app_password_roundtrip(storage: RefreshTokenStorage):
"""Store + retrieve + replace + delete a scoped app password.
The ``app_password=`` keyword-arg literals are bound to local
variables so the bare ``# NOSONAR`` marker can anchor to the same
physical line as the literal — SonarQube's hard-coded-credential
heuristic ignores the marker otherwise. These are localhost test
fixtures with no production reach.
"""
bob_pw_v1 = "pw-1" # NOSONAR
await storage.store_app_password(user_id="bob", app_password=bob_pw_v1)
assert await storage.get_app_password("bob") == bob_pw_v1
# Replace path exercises the ON CONFLICT DO UPDATE on the singleton row.
bob_pw_v2 = "pw-2" # NOSONAR
await storage.store_app_password(user_id="bob", app_password=bob_pw_v2)
assert await storage.get_app_password("bob") == bob_pw_v2
assert await storage.delete_app_password("bob") is True
assert await storage.get_app_password("bob") is None
async def test_oauth_session_lifecycle(storage: RefreshTokenStorage):
"""Cover the ADR-004 progressive-consent session table."""
await storage.store_oauth_session(
session_id="sess-1",
client_redirect_uri="http://localhost:12345/callback",
mcp_authorization_code="mcp-code-abc",
flow_type="hybrid",
ttl_seconds=600,
)
fetched = await storage.get_oauth_session("sess-1")
assert fetched is not None
assert fetched["mcp_authorization_code"] == "mcp-code-abc"
by_code = await storage.get_oauth_session_by_mcp_code("mcp-code-abc")
assert by_code is not None and by_code["session_id"] == "sess-1"
async def test_webhook_tracking(storage: RefreshTokenStorage):
"""Tracks webhook ↔ preset mappings via ON CONFLICT upserts."""
await storage.store_webhook(webhook_id=101, preset_id="notes_sync")
await storage.store_webhook(webhook_id=202, preset_id="notes_sync")
await storage.store_webhook(webhook_id=303, preset_id="calendar_sync")
assert sorted(await storage.get_webhooks_by_preset("notes_sync")) == [101, 202]
assert await storage.get_webhooks_by_preset("calendar_sync") == [303]
# Re-storing the same webhook_id is a no-op upsert.
await storage.store_webhook(webhook_id=101, preset_id="notes_sync")
assert sorted(await storage.get_webhooks_by_preset("notes_sync")) == [101, 202]
assert await storage.delete_webhook(webhook_id=101) is True
assert await storage.get_webhooks_by_preset("notes_sync") == [202]
async def test_audit_log_capture(storage: RefreshTokenStorage):
"""Audit events from upstream methods land in audit_logs."""
carol_pw = "x" # NOSONAR
await storage.store_app_password(user_id="carol", app_password=carol_pw)
logs = await storage.get_audit_logs(user_id="carol", limit=10)
assert any(entry["event"] == "store_app_password" for entry in logs)
async def test_cleanup_expired_roundtrip(storage: RefreshTokenStorage):
"""``cleanup_expired_*`` paths rely on DELETE rowcount across dialects.
Regression guard for the bot review on PR #798 — the original
integration tests didn't exercise these methods, which historically
have been a source of dialect-portability bugs.
"""
# Insert one fresh + one expired refresh token.
await storage.store_refresh_token(
user_id="fresh-user", refresh_token="fresh", expires_at=9_999_999_999
)
await storage.store_refresh_token(
user_id="expired-user", refresh_token="stale", expires_at=1
)
# Insert one fresh + one expired OAuth session.
await storage.store_oauth_session(
session_id="sess-fresh",
client_redirect_uri="http://localhost/cb",
mcp_authorization_code="code-fresh",
ttl_seconds=600,
)
await storage.store_oauth_session(
session_id="sess-stale",
client_redirect_uri="http://localhost/cb",
mcp_authorization_code="code-stale",
ttl_seconds=-3600, # expires_at = now - 1h
)
# Insert one fresh + one expired browser session.
await storage.create_browser_session(
session_id="bs-fresh", user_id="alice", ttl_seconds=600
)
await storage.create_browser_session(
session_id="bs-stale", user_id="alice", ttl_seconds=-3600
)
tokens_deleted = await storage.cleanup_expired_tokens()
sessions_deleted = await storage.cleanup_expired_sessions()
browser_deleted = await storage.cleanup_expired_browser_sessions()
assert tokens_deleted == 1, f"expected 1 expired token, got {tokens_deleted}"
assert sessions_deleted == 1, (
f"expected 1 expired oauth session, got {sessions_deleted}"
)
assert browser_deleted == 1, (
f"expected 1 expired browser session, got {browser_deleted}"
)
# Fresh rows survived.
assert await storage.get_refresh_token("fresh-user") is not None
assert await storage.get_refresh_token("expired-user") is None
assert await storage.get_oauth_session("sess-fresh") is not None
assert await storage.get_oauth_session("sess-stale") is None
async def test_browser_session_delete_returning(storage: RefreshTokenStorage):
"""Exercise the ``DELETE … RETURNING user_id`` path on Postgres.
``delete_browser_session`` is the only RETURNING clause in the
storage layer and the most dialect-sensitive SQL in this PR — it
needed SQLite ≥ 3.35 specifically because of RETURNING. Bot review
on PR #798 round 2 flagged that the existing cleanup test didn't
actually exercise this path. Asserts both the present and absent
cases so the asyncpg result-handling for RETURNING is covered.
"""
await storage.create_browser_session(
session_id="bs-returning", user_id="alice", ttl_seconds=600
)
assert await storage.get_browser_session_user("bs-returning") == "alice"
assert await storage.delete_browser_session("bs-returning") is True
assert await storage.get_browser_session_user("bs-returning") is None
# Deleting a nonexistent session returns False (RETURNING yields no
# row → rowcount path).
assert await storage.delete_browser_session("never-existed") is False
async def test_close_disposes_engine(postgres_url: str, reset_schema):
"""``close()`` releases pooled asyncpg connections and is idempotent.
PR #798 round-4 review (bot #4): the engine wasn't being disposed on
shutdown, leaking server-side connection slots until the Postgres
idle-in-transaction timeout fired. This test confirms ``close()``
nulls the engine, leaves the storage in a non-initialized state,
and a second ``close()`` call is a no-op rather than an exception.
"""
s = RefreshTokenStorage(
database_url=postgres_url, encryption_key=Fernet.generate_key()
)
await s.initialize()
assert s.engine is not None
assert s._initialized is True
await s.close()
assert s.engine is None
assert s._initialized is False
# Idempotent — second close is a no-op, no AttributeError.
await s.close()
assert s.engine is None
async def test_concurrent_initialize_serialized_by_advisory_lock(
postgres_url: str, reset_schema
):
"""Concurrent pod startup must serialize on pg_advisory_lock.
PR #798 round-4 review (bot #3): without a migration lock, two
pods racing the rolling-update can both detect ``has_alembic=False``
and both run ``upgrade_database(URL, "head")``; the second crashes
with "relation already exists". This test spawns three concurrent
``RefreshTokenStorage.initialize()`` calls against a fresh schema
and asserts all of them complete successfully (the advisory lock
serializes them; the second/third observe ``has_alembic=True``
after the first commits and take the upgrade fast-path).
"""
import anyio
async def init_one() -> None:
s = RefreshTokenStorage(
database_url=postgres_url, encryption_key=Fernet.generate_key()
)
try:
await s.initialize()
finally:
await s.close()
# No exception = serialization worked. Without the lock, this
# raised ``relation "refresh_tokens" already exists`` on the second
# task in CI runs prior to this fix.
async with anyio.create_task_group() as tg:
for _ in range(3):
tg.start_soon(init_one)
# Verify the schema actually landed once, not three times: the
# alembic_version table should exist with one row at the head revision.
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
engine = create_async_engine(postgres_url, future=True)
try:
async with engine.connect() as conn:
result = await conn.execute(text("SELECT count(*) FROM alembic_version"))
(count,) = result.fetchone()
assert count == 1, f"expected 1 alembic_version row, got {count}"
finally:
await engine.dispose()
+5
View File
@@ -2,6 +2,11 @@
import pytest
# Re-export the parametrized storage backend fixture so it's auto-discovered
# by every unit test that names it as a parameter, without each test module
# having to import it explicitly.
from tests.fixtures.storage_backend import storage_backend # noqa: F401
@pytest.fixture(autouse=True)
def _reload_dynaconf_after_test():
+25 -2
View File
@@ -3,6 +3,12 @@ Unit tests for App Password Storage functionality.
Tests the app password methods in RefreshTokenStorage for multi-user
BasicAuth mode background sync.
These tests are parametrized over both supported backends so the storage
layer is exercised against SQLite (default, always runs) and Postgres (gated
on ``TEST_DATABASE_URL``; bring up ``docker compose --profile postgres up
-d postgres-test`` and export ``TEST_DATABASE_URL=postgresql+asyncpg://mcp:mcp@localhost:5433/mcp``
to opt in).
"""
import tempfile
@@ -23,8 +29,14 @@ def encryption_key():
@pytest.fixture
async def temp_storage(encryption_key):
"""Create temporary storage instance with encryption for testing."""
async def temp_storage(encryption_key, storage_backend):
"""Create a storage instance backed by either SQLite or Postgres.
The ``storage_backend`` fixture is parametrized by pytest, so every test
that uses ``temp_storage`` runs once per backend that is available in
the current environment.
"""
if storage_backend["kind"] == "sqlite":
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test_app_passwords.db"
storage = RefreshTokenStorage(
@@ -32,6 +44,17 @@ async def temp_storage(encryption_key):
)
await storage.initialize()
yield storage
else:
storage = RefreshTokenStorage(
database_url=storage_backend["url"], encryption_key=encryption_key
)
await storage.initialize()
try:
yield storage
finally:
# Each test gets an isolated schema; tear it down so the next
# parametrized run starts clean.
await storage_backend["reset"]()
async def test_store_app_password(temp_storage):
+93 -1
View File
@@ -1,4 +1,16 @@
"""Tests for SSL/TLS configuration (NEXTCLOUD_VERIFY_SSL, NEXTCLOUD_CA_BUNDLE)."""
"""Tests for SSL/TLS configuration.
Covers two parallel patterns:
- ``NEXTCLOUD_VERIFY_SSL`` / ``NEXTCLOUD_CA_BUNDLE`` for the httpx
client talking to Nextcloud.
- ``DATABASE_VERIFY_SSL`` / ``DATABASE_CA_BUNDLE`` for the asyncpg
driver talking to a centralized Postgres backend (ADR-026).
The DB-side helper has a different default (``None`` instead of
``True``) because asyncpg's default ``prefer`` is the right back-compat
posture for cluster-internal Postgres — see ``get_database_ssl()``.
"""
import logging
import os
@@ -12,6 +24,7 @@ import pytest
from nextcloud_mcp_server.config import (
Settings,
_reload_config,
get_database_ssl,
get_nextcloud_ssl_verify,
get_settings,
)
@@ -185,3 +198,82 @@ class TestHTTPClientFactory:
):
client = nextcloud_httpx_client(timeout=5.0, follow_redirects=True)
assert isinstance(client, httpx.AsyncClient)
class TestDatabaseSSLSettings:
"""Test DATABASE_VERIFY_SSL / DATABASE_CA_BUNDLE fields on Settings (ADR-026)."""
def test_defaults(self):
"""Default is None / None — preserves PR #798's asyncpg ``prefer``."""
settings = Settings()
assert settings.database_verify_ssl is None
assert settings.database_ca_bundle is None
def test_verify_false_logs_warning(self, caplog):
caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config")
Settings(database_verify_ssl=False)
assert "DATABASE_VERIFY_SSL is disabled" in caplog.text
def test_ca_bundle_nonexistent_path_raises(self):
with pytest.raises(ValueError, match="DATABASE_CA_BUNDLE path does not exist"):
Settings(database_ca_bundle="/nonexistent/path/ca.pem")
def test_ca_bundle_existing_path_logs_info(self, caplog, tmp_path):
ca_file = tmp_path / "ca.pem"
ca_file.write_text(
"-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n"
)
caplog.set_level(logging.INFO, logger="nextcloud_mcp_server.config")
Settings(database_ca_bundle=str(ca_file))
assert "custom CA bundle for Postgres backend" in caplog.text
class TestGetDatabaseSSL:
"""Test the get_database_ssl() helper (ADR-026)."""
def test_both_unset_returns_none(self):
"""The asyncpg-default opt-out path — no `ssl` kwarg passed."""
with patch(
"nextcloud_mcp_server.config.get_settings",
return_value=Settings(),
):
assert get_database_ssl() is None
def test_verify_true_returns_true(self):
with patch(
"nextcloud_mcp_server.config.get_settings",
return_value=Settings(database_verify_ssl=True),
):
assert get_database_ssl() is True
def test_verify_false_returns_false(self):
with patch(
"nextcloud_mcp_server.config.get_settings",
return_value=Settings(database_verify_ssl=False),
):
assert get_database_ssl() is False
def test_ca_bundle_returns_ssl_context(self):
ca_bundle = certifi.where()
with patch(
"nextcloud_mcp_server.config.get_settings",
return_value=Settings(database_ca_bundle=ca_bundle),
):
result = get_database_ssl()
assert isinstance(result, ssl.SSLContext)
assert result.cert_store_stats()["x509_ca"] > 0
def test_verify_false_wins_over_ca_bundle(self, tmp_path):
"""False is the explicit-opt-out and must override a stale bundle path."""
ca_file = tmp_path / "ca.pem"
ca_file.write_text(
"-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n"
)
with patch(
"nextcloud_mcp_server.config.get_settings",
return_value=Settings(
database_verify_ssl=False,
database_ca_bundle=str(ca_file),
),
):
assert get_database_ssl() is False
+85
View File
@@ -0,0 +1,85 @@
"""Unit tests guarding against DB-credential leakage to logs (PR #798 round 2).
The reviewer of PR #798 flagged that ``self.database_url`` was being logged
verbatim in ``RefreshTokenStorage.initialize()``, exposing any password
embedded in a Postgres URL to stdout/stderr and any log aggregator. These
tests pin the masking down so a future contributor can't silently
reintroduce the leak by adding a new ``logger.info("... %s", database_url)``.
"""
from __future__ import annotations
import logging
import pytest
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.config import mask_db_password
pytestmark = pytest.mark.unit
# Synthetic leak-detection sentinel — embedded into test-only URLs so we
# can grep ``caplog`` and prove the masking path never emits the literal
# password substring. Not a real credential.
SENTINEL_PASSWORD_FRAGMENT = "uniqueSecretSentinel123" # NOSONAR
def test_mask_db_password_postgres():
"""Postgres URL passwords are replaced with the SQLAlchemy ``***`` token."""
url = (
f"postgresql+asyncpg://mcp:{SENTINEL_PASSWORD_FRAGMENT}@db.example.com:5432/mcp"
)
masked = mask_db_password(url)
assert SENTINEL_PASSWORD_FRAGMENT not in masked
assert "mcp" in masked # username preserved
assert "db.example.com" in masked # host preserved
def test_mask_db_password_sqlite_passthrough():
"""SQLite URLs have no credentials; the function must not corrupt them."""
url = "sqlite+aiosqlite:////tmp/test-tokens.db"
masked = mask_db_password(url)
assert masked == url
def test_mask_db_password_handles_unparseable_url():
"""Malformed URLs fall back to a regex scrub instead of raising.
A logging path that can raise is worse than a logging path that emits a
less-pretty masked value — never let credentials leak just because the
URL shape was unexpected.
"""
url = f"weird-scheme://user:{SENTINEL_PASSWORD_FRAGMENT}@host/db?ssl=disable"
masked = mask_db_password(url)
assert SENTINEL_PASSWORD_FRAGMENT not in masked
async def test_storage_init_does_not_log_password(caplog):
"""Construct + initialize against a Postgres-shaped URL with a password
in the URL and confirm the secret is absent from every captured log."""
# Use a sqlite URL with a fake password-shaped path — we don't need a
# real Postgres up to verify the masking logic, only that no log line
# ever interpolates the raw URL. A sqlite URL doesn't carry a password
# so we test masking by directly invoking the masked log path with a
# constructed Postgres URL via mask_db_password itself.
caplog.set_level(logging.DEBUG, logger="nextcloud_mcp_server.auth.storage")
caplog.set_level(logging.DEBUG, logger="nextcloud_mcp_server.migrations")
import tempfile
from pathlib import Path
with tempfile.TemporaryDirectory() as tmp:
db_path = Path(tmp) / "tokens.db"
storage = RefreshTokenStorage(db_path=str(db_path), encryption_key=None)
await storage.initialize()
# Sanity: the sqlite path was logged at least once.
assert any("token storage" in rec.message.lower() for rec in caplog.records)
# The sentinel should never appear (sqlite URL has no password to leak,
# but if a future change reformatted DATABASE_URL into the message it
# would). Stay paranoid.
for rec in caplog.records:
assert SENTINEL_PASSWORD_FRAGMENT not in rec.getMessage(), (
f"Credential sentinel leaked into log: {rec.getMessage()!r}"
)
+21 -5
View File
@@ -3,6 +3,9 @@ Unit tests for Webhook Storage functionality.
Tests the webhook tracking methods in RefreshTokenStorage without
requiring real database connections or network calls.
Runs against both SQLite and Postgres backends — see the docstring on
``tests.fixtures.storage_backend`` for opt-in instructions.
"""
import tempfile
@@ -17,14 +20,23 @@ pytestmark = pytest.mark.unit
@pytest.fixture
async def temp_storage():
"""Create temporary storage instance for testing."""
async def temp_storage(storage_backend):
"""Create a storage instance backed by either SQLite or Postgres."""
if storage_backend["kind"] == "sqlite":
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test_webhooks.db"
# No encryption key needed for webhook tracking
storage = RefreshTokenStorage(db_path=str(db_path), encryption_key=None)
await storage.initialize()
yield storage
else:
storage = RefreshTokenStorage(
database_url=storage_backend["url"], encryption_key=None
)
await storage.initialize()
try:
yield storage
finally:
await storage_backend["reset"]()
async def test_store_webhook(temp_storage):
@@ -144,7 +156,7 @@ async def test_clear_preset_webhooks_nonexistent(temp_storage):
async def test_webhook_timestamps(temp_storage):
"""Test that webhook timestamps are properly stored."""
"""Test that webhook timestamps are properly stored as int epochs."""
start_time = time.time()
await temp_storage.store_webhook(webhook_id=123, preset_id="notes_sync")
end_time = time.time()
@@ -152,8 +164,12 @@ async def test_webhook_timestamps(temp_storage):
webhooks = await temp_storage.list_all_webhooks()
assert len(webhooks) == 1
# ``created_at`` is now an integer (PR #798 round 2 — consistency with
# other *_at columns). Allow +1s slack for the second boundary the
# ``int()`` truncation can fall on.
created_at = webhooks[0]["created_at"]
assert start_time <= created_at <= end_time
assert isinstance(created_at, int)
assert int(start_time) <= created_at <= int(end_time) + 1
async def test_storage_without_encryption_key():
Generated
+62
View File
@@ -226,6 +226,54 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" },
]
[[package]]
name = "asyncpg"
version = "0.31.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/17/cc02bc49bc350623d050fa139e34ea512cd6e020562f2a7312a7bcae4bc9/asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d", size = 643159, upload-time = "2025-11-24T23:25:36.443Z" },
{ url = "https://files.pythonhosted.org/packages/a4/62/4ded7d400a7b651adf06f49ea8f73100cca07c6df012119594d1e3447aa6/asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab", size = 638157, upload-time = "2025-11-24T23:25:37.89Z" },
{ url = "https://files.pythonhosted.org/packages/d6/5b/4179538a9a72166a0bf60ad783b1ef16efb7960e4d7b9afe9f77a5551680/asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c", size = 2918051, upload-time = "2025-11-24T23:25:39.461Z" },
{ url = "https://files.pythonhosted.org/packages/e6/35/c27719ae0536c5b6e61e4701391ffe435ef59539e9360959240d6e47c8c8/asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109", size = 2972640, upload-time = "2025-11-24T23:25:41.512Z" },
{ url = "https://files.pythonhosted.org/packages/43/f4/01ebb9207f29e645a64699b9ce0eefeff8e7a33494e1d29bb53736f7766b/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da", size = 2851050, upload-time = "2025-11-24T23:25:43.153Z" },
{ url = "https://files.pythonhosted.org/packages/3e/f4/03ff1426acc87be0f4e8d40fa2bff5c3952bef0080062af9efc2212e3be8/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9", size = 2962574, upload-time = "2025-11-24T23:25:44.942Z" },
{ url = "https://files.pythonhosted.org/packages/c7/39/cc788dfca3d4060f9d93e67be396ceec458dfc429e26139059e58c2c244d/asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24", size = 521076, upload-time = "2025-11-24T23:25:46.486Z" },
{ url = "https://files.pythonhosted.org/packages/28/fc/735af5384c029eb7f1ca60ccb8fa95521dbdaeef788edf4cecfc604c3cab/asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047", size = 584980, upload-time = "2025-11-24T23:25:47.938Z" },
{ url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" },
{ url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" },
{ url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" },
{ url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" },
{ url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" },
{ url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" },
{ url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" },
{ url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" },
{ url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" },
{ url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" },
{ url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" },
{ url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" },
{ url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" },
{ url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" },
{ url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" },
{ url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" },
{ url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" },
{ url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" },
{ url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" },
{ url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" },
{ url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" },
{ url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" },
{ url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" },
{ url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" },
{ url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" },
{ url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" },
{ url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" },
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
]
[[package]]
name = "attrs"
version = "25.4.0"
@@ -2159,9 +2207,15 @@ dependencies = [
{ name = "pythonvcard4" },
{ name = "qdrant-client" },
{ name = "recurring-ical-events" },
{ name = "sqlalchemy", extra = ["asyncio"] },
{ name = "starlette" },
]
[package.optional-dependencies]
postgres = [
{ name = "asyncpg" },
]
[package.dev-dependencies]
dev = [
{ name = "commitizen" },
@@ -2184,6 +2238,7 @@ requires-dist = [
{ name = "aiosqlite", specifier = ">=0.20.0" },
{ name = "alembic", specifier = ">=1.14.0" },
{ name = "anthropic", specifier = ">=0.42.0" },
{ name = "asyncpg", marker = "extra == 'postgres'", specifier = ">=0.29" },
{ name = "authlib", specifier = ">=1.6.5" },
{ name = "boto3", specifier = ">=1.35.0" },
{ name = "caldav", specifier = ">=3.0.1,<4.0" },
@@ -2214,8 +2269,10 @@ requires-dist = [
{ name = "pythonvcard4", specifier = ">=0.2.0" },
{ name = "qdrant-client", specifier = ">=1.17.0" },
{ name = "recurring-ical-events", specifier = ">=3.8.0,<4.0" },
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
{ name = "starlette", specifier = "<1.0" },
]
provides-extras = ["postgres"]
[package.metadata.requires-dev]
dev = [
@@ -3944,6 +4001,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672, upload-time = "2025-12-09T21:54:52.608Z" },
]
[package.optional-dependencies]
asyncio = [
{ name = "greenlet" },
]
[[package]]
name = "sse-starlette"
version = "3.0.3"