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:
+2
-2
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
1–3 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.
|
||||
@@ -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")
|
||||
|
||||
+28
-61
@@ -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")
|
||||
|
||||
+10
-3
@@ -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")
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/"
|
||||
|
||||
Vendored
Vendored
+97
@@ -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}
|
||||
@@ -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()
|
||||
@@ -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():
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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():
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user