feat: add Alembic database migration system

Implements Alembic for managing token storage database schema versions.
Migrations run automatically on startup with full backward compatibility.

**Changes:**
- Add Alembic dependency (1.14.0+) and SQLAlchemy (auto-installed)
- Create migration infrastructure in alembic/ directory
- Add initial migration (001) capturing current schema
- Modify RefreshTokenStorage.initialize() to run migrations via anyio
- Add CLI commands: db upgrade, current, history, downgrade, migrate
- Add comprehensive migration documentation

**Backward Compatibility:**
- Pre-Alembic databases automatically stamped with revision 001
- No schema changes for existing databases
- Automatic upgrade on first startup after update

**Migration Strategy:**
Three scenarios handled:
1. New database → Run migrations from scratch
2. Pre-Alembic database → Stamp with 001 (no changes)
3. Alembic-managed → Upgrade to latest

**Architecture:**
- Uses anyio.to_thread.run_sync() for structured concurrency
- Alembic env.py runs with anyio.run() in worker thread
- SQLite-friendly migration patterns documented
- No ThreadPoolExecutor needed (anyio handles it)

**CLI Usage:**
```bash
nextcloud-mcp-server db upgrade    # Upgrade to latest
nextcloud-mcp-server db current    # Show version
nextcloud-mcp-server db history    # View changelog
nextcloud-mcp-server db downgrade  # Rollback (with confirmation)
nextcloud-mcp-server db migrate "description"  # Create migration
```

**Testing:**
- All 13 webhook storage tests pass
- New/pre-Alembic database scenarios validated
- anyio integration tested

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2025-12-17 23:19:32 +01:00
parent a4a34e46a8
commit 3fa376905c
17 changed files with 1436 additions and 152 deletions
+4
View File
@@ -12,6 +12,10 @@ RUN apt update && apt install --no-install-recommends --no-install-suggests -y \
WORKDIR /app
COPY pyproject.toml uv.lock README.md .
RUN uv sync --locked --no-dev --no-install-project --no-cache
COPY . .
RUN uv sync --locked --no-dev --no-editable --no-cache
+90
View File
@@ -0,0 +1,90 @@
# Alembic configuration file for nextcloud-mcp-server
[alembic]
# Path to migration scripts
script_location = alembic
# Template used to generate migration file names
# Default: %%(rev)s_%%(slug)s
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
# Timezone for migration timestamps
# Default: utc
timezone = utc
# Max length of characters to apply to the "slug" field
# Default: 40
# truncate_slug_length = 40
# Set to 'true' to run the environment during the 'revision' command
# Default: false
# revision_environment = false
# Set to 'true' to allow .pyc and .pyo files without a source .py file
# Default: false
# sourceless = false
# Version location specification
# Supports single or multiple directories
version_locations = alembic/versions
# Path separator for version locations (required to suppress deprecation warning)
# Use os (for cross-platform compatibility)
path_separator = os
# Set to 'true' to search source files recursively in each "version_locations" directory
# Default: false
# recursive_version_locations = false
# Output encoding used when revision files are written
# Default: utf-8
# output_encoding = utf-8
# Database URL - can be overridden by:
# 1. Passing -x database_url=... to alembic commands
# 2. Setting in environment via get_database_url() in env.py
# Default: sqlite:///app/data/tokens.db
sqlalchemy.url = sqlite+aiosqlite:////app/data/tokens.db
[post_write_hooks]
# Post-write hooks allow you to run scripts after generating migration files
# Example: format migrations with ruff
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = format REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+71
View File
@@ -0,0 +1,71 @@
Database Migrations for nextcloud-mcp-server
============================================
This directory contains Alembic database migrations for the token storage database.
Structure
---------
- env.py: Alembic environment configuration
- script.py.mako: Template for generating new migration files
- versions/: Directory containing migration scripts
Usage
-----
Migrations are managed via the CLI:
# Upgrade database to latest version
uv run nextcloud-mcp-server db upgrade
# Show current database version
uv run nextcloud-mcp-server db current
# Show migration history
uv run nextcloud-mcp-server db history
# Create a new migration (developers only)
uv run nextcloud-mcp-server db migrate "description of changes"
# Downgrade database by one version (emergency use only)
uv run nextcloud-mcp-server db downgrade
Direct Alembic Usage
--------------------
You can also use Alembic commands directly:
# Specify database URL via -x flag
uv run alembic -x database_url=sqlite+aiosqlite:////path/to/tokens.db upgrade head
# Or set in alembic.ini and run
uv run alembic upgrade head
uv run alembic current
uv run alembic history
Writing Migrations
------------------
Since we don't use SQLAlchemy models, migrations are written with raw SQL:
def upgrade() -> None:
op.execute("""
ALTER TABLE refresh_tokens
ADD COLUMN new_field TEXT
""")
def downgrade() -> None:
# SQLite doesn't support DROP COLUMN, use table recreation
op.execute("""
CREATE TABLE refresh_tokens_new AS
SELECT user_id, encrypted_token, ... FROM refresh_tokens
""")
op.execute("DROP TABLE refresh_tokens")
op.execute("ALTER TABLE refresh_tokens_new RENAME TO refresh_tokens")
Migration File Naming
---------------------
Format: YYYYMMDD_HHMM_<revision>_<slug>.py
Example: 20251217_2200_001_initial_schema.py
Notes
-----
- Migrations run automatically when RefreshTokenStorage.initialize() is called
- Existing databases are automatically stamped with the initial version
- SQLite has limited ALTER TABLE support - complex changes require table recreation
+128
View File
@@ -0,0 +1,128 @@
"""Alembic environment configuration for nextcloud-mcp-server.
This module configures how Alembic runs database migrations for the
token storage database. It supports both online and offline migration modes.
Uses anyio for async operations, consistent with the project's async patterns.
"""
import logging
from pathlib import Path
import anyio
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# Configure logging
logger = logging.getLogger("alembic.env")
# This is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# We don't use SQLAlchemy models, so target_metadata is None
# Migrations will be written manually using op.execute() for raw SQL
target_metadata = None
def get_database_url() -> str:
"""
Get the database URL from Alembic config or environment.
The URL can be set in alembic.ini or passed via -x database_url=...
when running Alembic commands.
Returns:
Database URL (SQLite URL format)
"""
# Check if URL is passed via -x database_url=...
url = context.get_x_argument(as_dictionary=True).get("database_url")
if not url:
# Fall back to alembic.ini configuration
url = config.get_main_option("sqlalchemy.url")
if not url:
# Default to /app/data/tokens.db for Docker deployments
db_path = Path("/app/data/tokens.db")
url = f"sqlite+aiosqlite:///{db_path}"
logger.warning(
f"No database URL configured, using default: {url}. "
"Set sqlalchemy.url in alembic.ini or pass -x database_url=..."
)
return url
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL and not an Engine,
though an Engine is acceptable here as well. By skipping the
Engine creation we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
This mode is useful for generating SQL scripts without database access.
"""
url = get_database_url()
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
"""Execute migrations within a database connection."""
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""Run migrations in 'online' mode with async support.
In this scenario we create an async Engine and associate
a connection with the context.
"""
# Get database URL and update config
url = get_database_url()
config.set_main_option("sqlalchemy.url", url)
# Create async engine
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool, # Don't pool connections for migrations
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
This function is called from storage.py's initialize() method via
anyio.to_thread.run_sync(), so it always runs in a worker thread
with its own event loop. We can safely use anyio.run() here.
"""
anyio.run(run_async_migrations)
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+26
View File
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
"""Apply migration changes to upgrade the database schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Revert migration changes to downgrade the database schema."""
${downgrades if downgrades else "pass"}
@@ -0,0 +1,185 @@
"""Initial schema for token storage database
This migration creates the initial database schema including:
- refresh_tokens: OAuth refresh tokens and user profiles
- audit_logs: Audit trail for security events
- oauth_clients: OAuth client credentials (DCR)
- oauth_sessions: OAuth flow session state (ADR-004 Progressive Consent)
- registered_webhooks: Webhook registration tracking (both OAuth and BasicAuth)
- schema_version: Legacy schema version tracking (deprecated, use alembic_version)
Revision ID: 001
Revises:
Create Date: 2025-12-17 22:00:00.000000
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
"""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
)
"""
)
# 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
)
"""
)
# Index on audit logs for efficient queries
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_audit_user_timestamp
ON audit_logs(user_id, timestamp)
"""
)
# 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
)
"""
)
# 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)
"""
)
def downgrade() -> None:
"""Drop all tables and indexes.
WARNING: This will destroy all data in the database!
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")
+301
View File
@@ -0,0 +1,301 @@
# Database Migrations
This document describes the database migration system for nextcloud-mcp-server's token storage database.
## Overview
The token storage database uses [Alembic](https://alembic.sqlalchemy.org/) for schema versioning and migrations. Alembic provides:
- **Version Control**: Track schema changes in Git
- **Rollback Support**: Safely downgrade schema if needed
- **Audit Trail**: Migration files serve as schema changelog
- **Automated Upgrades**: Database schema updates automatically on startup
## Architecture
### Migration Strategy
The system handles three scenarios:
1. **New Database**: Runs migrations from scratch to create all tables
2. **Pre-Alembic Database**: Stamps existing database with initial revision (no changes)
3. **Alembic-Managed Database**: Upgrades to latest version automatically
### Directory Structure
```
nextcloud-mcp-server/
├── alembic/ # Alembic migrations
│ ├── versions/ # Migration scripts
│ │ └── 20251217_2200_001_initial_schema.py
│ ├── env.py # Alembic environment
│ ├── script.py.mako # Migration template
│ └── README # Migration usage guide
├── alembic.ini # Alembic configuration
└── nextcloud_mcp_server/
├── auth/storage.py # Uses migrations on init
└── migrations.py # Migration utilities
```
## Usage
### Automatic Migration on Startup
Migrations run automatically when the server starts:
```bash
uv run nextcloud-mcp-server
```
The `RefreshTokenStorage.initialize()` method:
1. Checks if database is Alembic-managed
2. Stamps pre-Alembic databases with initial revision
3. Upgrades to latest version
### Manual Migration Commands
```bash
# Show current database version
uv run nextcloud-mcp-server db current
# Upgrade database to latest version
uv run nextcloud-mcp-server db upgrade
# Show migration history
uv run nextcloud-mcp-server db history
# Downgrade by one version (emergency use only)
uv run nextcloud-mcp-server db downgrade
# Specify custom database path
uv run nextcloud-mcp-server db current -d /path/to/tokens.db
```
### Environment Variables
- `TOKEN_STORAGE_DB`: Path to database file (default: `/app/data/tokens.db`)
## Creating Migrations (Developers)
### Step 1: Create Migration File
```bash
uv run nextcloud-mcp-server db migrate "add user preferences table"
```
This creates a new migration file in `alembic/versions/` with empty `upgrade()` and `downgrade()` functions.
### Step 2: Write Migration SQL
Since we don't use SQLAlchemy models, write raw SQL:
```python
def upgrade() -> None:
"""Add user preferences table."""
op.execute("""
CREATE TABLE user_preferences (
user_id TEXT PRIMARY KEY,
theme TEXT DEFAULT 'light',
language TEXT DEFAULT 'en',
created_at INTEGER NOT NULL
)
""")
op.execute("""
CREATE INDEX idx_user_preferences_user_id
ON user_preferences(user_id)
""")
def downgrade() -> None:
"""Remove user preferences table."""
op.execute("DROP INDEX IF EXISTS idx_user_preferences_user_id")
op.execute("DROP TABLE IF EXISTS user_preferences")
```
### Step 3: Test Migration
```bash
# Test upgrade
uv run nextcloud-mcp-server db upgrade -d /tmp/test.db
# Verify schema
sqlite3 /tmp/test.db ".schema"
# Test downgrade
uv run nextcloud-mcp-server db downgrade -d /tmp/test.db
# Verify removal
sqlite3 /tmp/test.db ".schema"
```
### Step 4: Commit Migration
```bash
git add alembic/versions/YYYYMMDD_HHMM_XXX_description.py
git commit -m "feat: add user preferences table migration"
```
## SQLite Limitations
SQLite has limited `ALTER TABLE` support:
### Supported Operations
- ✅ Add columns: `ALTER TABLE table ADD COLUMN ...`
- ✅ Rename table: `ALTER TABLE old RENAME TO new`
- ✅ Rename column: `ALTER TABLE table RENAME COLUMN old TO new` (SQLite 3.25+)
### Unsupported Operations (Requires Table Recreation)
- ❌ Drop column
- ❌ Change column type
- ❌ Add constraints to existing columns
### Table Recreation Pattern
For complex schema changes:
```python
def upgrade() -> None:
# Create new table with desired schema
op.execute("""
CREATE TABLE refresh_tokens_new (
user_id TEXT PRIMARY KEY,
encrypted_token BLOB NOT NULL,
new_field TEXT, -- New column
expires_at INTEGER,
created_at INTEGER NOT NULL
)
""")
# Copy data from old table
op.execute("""
INSERT INTO refresh_tokens_new
(user_id, encrypted_token, expires_at, created_at)
SELECT user_id, encrypted_token, expires_at, created_at
FROM refresh_tokens
""")
# Drop old table and rename new table
op.execute("DROP TABLE refresh_tokens")
op.execute("ALTER TABLE refresh_tokens_new RENAME TO refresh_tokens")
# Recreate indexes
op.execute("CREATE INDEX idx_user_id ON refresh_tokens(user_id)")
```
## Best Practices
### Naming Conventions
- **Migrations**: `YYYYMMDD_HHMM_XXX_description.py`
- **Revision IDs**: Sequential numbers (`001`, `002`, `003`)
- **Descriptions**: Imperative mood ("add table", "remove column")
### Migration Guidelines
1. **Test Thoroughly**: Test both upgrade and downgrade paths
2. **Preserve Data**: Ensure data migration logic is correct
3. **Document Changes**: Add comments explaining complex operations
4. **Small Changes**: One logical change per migration
5. **No Breaking Changes**: Maintain backward compatibility when possible
### Downgrade Considerations
- **Data Loss**: Downgrade may lose data (dropped columns, tables)
- **Confirmation**: Downgrade command requires explicit confirmation
- **Testing**: Always test downgrade path before deploying
- **Emergency Only**: Use downgrades only for critical rollbacks
## Backward Compatibility
### Pre-Alembic Databases
Existing databases created before Alembic integration are automatically detected and stamped with revision `001`:
1. Server detects no `alembic_version` table
2. Checks if `refresh_tokens` table exists
3. If yes, stamps database with `001` (no schema changes)
4. Future updates use normal migration path
### Migration Path
```
Pre-Alembic DB → Stamp(001) → Upgrade(002) → Upgrade(003) → ...
New DB → Migrate(001) → Upgrade(002) → Upgrade(003) → ...
```
## Troubleshooting
### Migration Fails
```bash
# Check current state
uv run nextcloud-mcp-server db current -d /path/to/tokens.db
# View migration history
uv run nextcloud-mcp-server db history -d /path/to/tokens.db
# Manually inspect database
sqlite3 /path/to/tokens.db ".schema"
```
### Reset to Initial State
**WARNING: This destroys all data!**
```bash
# Downgrade to base (empty database)
uv run nextcloud-mcp-server db downgrade -d /path/to/tokens.db --revision base
# Upgrade to latest
uv run nextcloud-mcp-server db upgrade -d /path/to/tokens.db
```
### Corrupted Migration State
If `alembic_version` table is corrupted:
```bash
# Manually fix via SQL
sqlite3 /path/to/tokens.db
> DELETE FROM alembic_version;
> INSERT INTO alembic_version (version_num) VALUES ('001');
> .quit
# Verify and upgrade
uv run nextcloud-mcp-server db current -d /path/to/tokens.db
uv run nextcloud-mcp-server db upgrade -d /path/to/tokens.db
```
## CI/CD Integration
### Pre-Deployment
```bash
# Run migrations in test environment
export TOKEN_STORAGE_DB=/app/data/tokens.db
uv run nextcloud-mcp-server db upgrade
# Verify current version
uv run nextcloud-mcp-server db current
```
### Docker Deployment
Migrations run automatically on container startup via `RefreshTokenStorage.initialize()`.
### Rollback Plan
1. Stop application
2. Backup database: `cp tokens.db tokens.db.backup`
3. Downgrade: `uv run nextcloud-mcp-server db downgrade --revision XXX`
4. Deploy previous application version
5. Restart application
## References
- [Alembic Documentation](https://alembic.sqlalchemy.org/)
- [SQLite ALTER TABLE Limitations](https://www.sqlite.org/lang_altertable.html)
- [ADR-004: Progressive Consent](./ADR-004-progressive-consent.md) (migration 001)
+6
View File
@@ -873,6 +873,12 @@ async def unified_search(request: Request) -> JSONResponse:
result_data["chunk_index"] = result.chunk_index
result_data["total_chunks"] = result.total_chunks
# Add chunk offsets for modal navigation
if result.chunk_start_offset is not None:
result_data["chunk_start_offset"] = result.chunk_start_offset
if result.chunk_end_offset is not None:
result_data["chunk_end_offset"] = result.chunk_end_offset
formatted_results.append(result_data)
response_data: dict[str, Any] = {
@@ -376,16 +376,35 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
user_id = f"user-{secrets.token_hex(8)}"
username = "unknown"
# Calculate refresh token expiration from token response
refresh_expires_in = token_data.get("refresh_expires_in")
refresh_expires_at = None
if refresh_expires_in:
import time
refresh_expires_at = int(time.time()) + refresh_expires_in
logger.info(
f"Refresh token expires in {refresh_expires_in}s (at timestamp {refresh_expires_at})"
)
# Extract granted scopes
granted_scopes = (
token_data.get("scope", "").split() if token_data.get("scope") else None
)
# Store refresh token (for background jobs ONLY)
if refresh_token:
logger.info(f"Storing refresh token for user_id: {user_id}")
logger.info(f" State parameter (provisioning_client_id): {state[:16]}...")
logger.info(f" Granted scopes: {granted_scopes}")
logger.info(f" Expires at: {refresh_expires_at}")
await storage.store_refresh_token(
user_id=user_id,
refresh_token=refresh_token,
expires_at=None,
expires_at=refresh_expires_at,
flow_type="browser", # Browser-based login flow
provisioning_client_id=state, # Store state for unified session lookup
scopes=granted_scopes,
)
logger.info(f"✓ Refresh token stored successfully for user_id: {user_id}")
logger.info(
+12 -1
View File
@@ -517,12 +517,23 @@ async def oauth_callback_nextcloud(request: Request):
token_data.get("scope", "").split() if token_data.get("scope") else None
)
# Calculate refresh token expiration from token response
refresh_expires_in = token_data.get("refresh_expires_in")
refresh_expires_at = None
if refresh_expires_in:
import time
refresh_expires_at = int(time.time()) + refresh_expires_in
logger.info(f" refresh_expires_in: {refresh_expires_in}s")
logger.info(f" refresh_expires_at: {refresh_expires_at}")
logger.info("Storing refresh token:")
logger.info(f" user_id: {user_id}")
logger.info(" flow_type: flow2")
logger.info(" token_audience: nextcloud")
logger.info(f" provisioning_client_id: {state[:16]}...")
logger.info(f" scopes: {granted_scopes}")
logger.info(f" expires_at: {refresh_expires_at}")
await storage.store_refresh_token(
user_id=user_id,
@@ -531,7 +542,7 @@ async def oauth_callback_nextcloud(request: Request):
token_audience="nextcloud",
provisioning_client_id=state, # Store which client initiated provisioning
scopes=granted_scopes,
expires_at=None, # Refresh tokens typically don't expire
expires_at=refresh_expires_at,
)
logger.info(f"✓ Stored Flow 2 master refresh token for user {user_id}")
logger.info("=" * 60)
+53 -124
View File
@@ -117,7 +117,14 @@ class RefreshTokenStorage:
return cls(db_path=db_path, encryption_key=encryption_key)
async def initialize(self) -> None:
"""Initialize database schema"""
"""
Initialize database schema using Alembic migrations.
This method handles three scenarios:
1. New database: Run migrations from scratch
2. Pre-Alembic database: Stamp with initial revision (no changes)
3. Alembic-managed database: Upgrade to latest version
"""
if self._initialized:
return
@@ -125,137 +132,59 @@ class RefreshTokenStorage:
db_dir = Path(self.db_path).parent
db_dir.mkdir(parents=True, exist_ok=True)
# Set restrictive permissions on database file
# Set restrictive permissions on database file if it exists
if Path(self.db_path).exists():
os.chmod(self.db_path, 0o600)
# Check database state and run appropriate migration strategy
async with aiosqlite.connect(self.db_path) as db:
await db.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', -- 'hybrid', 'flow1', 'flow2'
token_audience TEXT DEFAULT 'nextcloud', -- 'mcp-server' or 'nextcloud'
provisioned_at INTEGER, -- When Flow 2 was completed
provisioning_client_id TEXT, -- Which MCP client initiated Flow 1
scopes TEXT, -- JSON array of granted scopes
-- Browser session profile cache
user_profile TEXT, -- JSON cache of IdP user profile (for browser UI only)
profile_cached_at INTEGER -- When profile was last cached
# Check if database is managed by Alembic
cursor = await db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='alembic_version'"
)
has_alembic = await cursor.fetchone() is not None
if not has_alembic:
# Check if this is a pre-Alembic database with existing schema
cursor = await db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='refresh_tokens'"
)
"""
)
has_schema = await cursor.fetchone() is not None
await db.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
if has_schema:
logger.info(
f"Detected pre-Alembic database at {self.db_path}, "
"stamping with initial revision"
)
else:
logger.info(
f"Initializing new database at {self.db_path} with migrations"
)
# Run migrations in a worker thread using anyio.to_thread
# This allows Alembic to run its own async operations in a separate context
from anyio import to_thread
from nextcloud_mcp_server.migrations import stamp_database, upgrade_database
if not has_alembic:
if has_schema:
# Stamp existing database without running migrations
await to_thread.run_sync(stamp_database, self.db_path, "001")
logger.info(
"Pre-Alembic database stamped successfully. "
"Future schema changes will use migrations."
)
"""
)
else:
# New database - run migrations
await to_thread.run_sync(upgrade_database, self.db_path, "head")
logger.info("Database initialized with migrations")
else:
# Alembic-managed database - upgrade to latest
await to_thread.run_sync(upgrade_database, self.db_path, "head")
logger.info("Database upgraded to latest version")
# Create index on audit logs for efficient queries
await db.execute(
"CREATE INDEX IF NOT EXISTS idx_audit_user_timestamp "
"ON audit_logs(user_id, timestamp)"
)
# OAuth client credentials storage
await db.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
)
"""
)
# OAuth flow sessions (ADR-004 Progressive Consent)
await db.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', -- 'hybrid', 'flow1', 'flow2'
requested_scopes TEXT, -- JSON array of requested scopes
granted_scopes TEXT, -- JSON array of granted scopes
is_provisioning BOOLEAN DEFAULT FALSE -- True if this is a Flow 2 provisioning session
)
"""
)
# Create index for MCP authorization code lookups
await db.execute(
"CREATE INDEX IF NOT EXISTS idx_oauth_sessions_mcp_code "
"ON oauth_sessions(mcp_authorization_code)"
)
# Schema version tracking
await db.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)
await db.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
)
"""
)
# Create indexes for efficient webhook queries
await db.execute(
"CREATE INDEX IF NOT EXISTS idx_webhooks_preset "
"ON registered_webhooks(preset_id)"
)
await db.execute(
"CREATE INDEX IF NOT EXISTS idx_webhooks_created "
"ON registered_webhooks(created_at)"
)
await db.commit()
# Set restrictive permissions after creation
# Set restrictive permissions after initialization
os.chmod(self.db_path, 0o600)
self._initialized = True
+191 -1
View File
@@ -253,5 +253,195 @@ def run(
)
@click.group()
def db():
"""Database migration management commands."""
pass
@db.command()
@click.option(
"--database-path",
"-d",
envvar="TOKEN_STORAGE_DB",
default="/app/data/tokens.db",
show_default=True,
help="Path to token storage database (can also use TOKEN_STORAGE_DB env var)",
)
@click.option(
"--revision",
"-r",
default="head",
show_default=True,
help="Target revision (default: head for latest)",
)
def upgrade(database_path: str, revision: str):
"""Upgrade database to a specific revision.
\b
Examples:
# Upgrade to latest version
$ nextcloud-mcp-server db upgrade
# Upgrade to specific revision
$ nextcloud-mcp-server db upgrade --revision 001
# Use custom database path
$ nextcloud-mcp-server db upgrade -d /path/to/tokens.db
"""
from nextcloud_mcp_server.migrations import upgrade_database
try:
click.echo(f"Upgrading database to revision: {revision}")
upgrade_database(database_path, 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)
raise click.ClickException(str(e))
@db.command()
@click.option(
"--database-path",
"-d",
envvar="TOKEN_STORAGE_DB",
default="/app/data/tokens.db",
show_default=True,
help="Path to token storage database",
)
@click.option(
"--revision",
"-r",
default="-1",
show_default=True,
help="Target revision (default: -1 for previous version)",
)
@click.confirmation_option(
prompt="Are you sure you want to downgrade the database? This may result in data loss."
)
def downgrade(database_path: str, 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
"""
from nextcloud_mcp_server.migrations import downgrade_database
try:
click.echo(f"Downgrading database to revision: {revision}")
downgrade_database(database_path, 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)
raise click.ClickException(str(e))
@db.command()
@click.option(
"--database-path",
"-d",
envvar="TOKEN_STORAGE_DB",
default="/app/data/tokens.db",
show_default=True,
help="Path to token storage database",
)
def current(database_path: str):
"""Show current database revision.
\b
Example:
$ nextcloud-mcp-server db current
"""
from nextcloud_mcp_server.migrations import get_current_revision
try:
revision = get_current_revision(database_path)
if revision:
click.echo(f"Current revision: {click.style(revision, fg='cyan')}")
else:
click.echo(
click.style(
"Database is not versioned (no alembic_version table)", fg="yellow"
)
)
except Exception as e:
click.echo(
click.style(f"✗ Failed to get current revision: {e}", fg="red"), err=True
)
raise click.ClickException(str(e))
@db.command()
@click.option(
"--database-path",
"-d",
envvar="TOKEN_STORAGE_DB",
default="/app/data/tokens.db",
show_default=True,
help="Path to token storage database",
)
def history(database_path: str):
"""Show migration history.
\b
Example:
$ nextcloud-mcp-server db history
"""
from nextcloud_mcp_server.migrations import show_migration_history
try:
click.echo("Migration history:")
show_migration_history(database_path)
except Exception as e:
click.echo(click.style(f"✗ Failed to show history: {e}", fg="red"), err=True)
raise click.ClickException(str(e))
@db.command()
@click.argument("message")
def migrate(message: str):
"""Create a new migration script (developers only).
The MESSAGE argument describes the changes in this migration.
\b
Examples:
$ nextcloud-mcp-server db migrate "add user preferences table"
$ nextcloud-mcp-server db migrate "add index on refresh_tokens.user_id"
Note: You must manually edit the generated migration file to add SQL statements.
"""
from nextcloud_mcp_server.migrations import create_migration
try:
click.echo(f"Creating new migration: {message}")
create_migration(message)
click.echo(click.style("✓ Migration created successfully", fg="green"))
click.echo(
"Edit the migration file in alembic/versions/ to add upgrade/downgrade SQL."
)
except Exception as e:
click.echo(
click.style(f"✗ Failed to create migration: {e}", fg="red"), err=True
)
raise click.ClickException(str(e))
# Create CLI group with subcommands
cli = click.Group()
cli.add_command(run)
cli.add_command(db)
if __name__ == "__main__":
run()
cli()
+186
View File
@@ -0,0 +1,186 @@
"""Database migration utilities for nextcloud-mcp-server.
This module provides helper functions for managing Alembic database migrations
programmatically. It enables automatic migration on application startup and
provides CLI integration.
"""
import logging
from pathlib import Path
from alembic.config import Config
from alembic import command
logger = logging.getLogger(__name__)
def get_alembic_config(database_path: str | Path | None = None) -> Config:
"""
Get Alembic configuration for programmatic use.
Args:
database_path: Path to SQLite database file. If None, uses default
from alembic.ini (/app/data/tokens.db)
Returns:
Alembic Config object configured for the specified database
"""
# Get path to alembic.ini (in project root)
project_root = Path(__file__).parent.parent
alembic_ini_path = project_root / "alembic.ini"
if not alembic_ini_path.exists():
raise FileNotFoundError(
f"alembic.ini not found at {alembic_ini_path}. "
"Ensure Alembic is properly initialized."
)
# Create Alembic config
config = Config(str(alembic_ini_path))
# Override database URL if provided
if database_path:
db_path = Path(database_path).resolve()
# Use sqlite+aiosqlite:// for async support
url = f"sqlite+aiosqlite:///{db_path}"
config.set_main_option("sqlalchemy.url", url)
logger.debug(f"Alembic configured with database: {db_path}")
return config
def upgrade_database(
database_path: 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)
logger.info(f"Upgrading database to revision: {revision}")
command.upgrade(config, revision)
logger.info("Database upgrade completed successfully")
def downgrade_database(
database_path: 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)
logger.warning(f"Downgrading database to revision: {revision}")
command.downgrade(config, revision)
logger.info("Database downgrade completed successfully")
def get_current_revision(database_path: str | Path | None = None) -> str | None:
"""
Get the current database revision by directly querying the alembic_version table.
Args:
database_path: Path to SQLite database file
Returns:
Current revision ID or None if not versioned
"""
import sqlite3
if database_path is None:
database_path = "/app/data/tokens.db"
db_path = Path(database_path).resolve()
if not db_path.exists():
logger.debug(f"Database does not exist: {db_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()
return None
# Get current version
cursor.execute("SELECT version_num FROM alembic_version")
row = cursor.fetchone()
conn.close()
return row[0] if row else None
except Exception as e:
logger.error(f"Failed to get current revision: {e}")
return None
def stamp_database(
database_path: 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)
"""
config = get_alembic_config(database_path)
logger.info(f"Stamping database with revision: {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)
command.history(config, verbose=True)
def create_migration(message: str, autogenerate: bool = False) -> None:
"""
Create a new migration script.
Args:
message: Description of the migration
autogenerate: Whether to attempt auto-generation (requires SQLAlchemy models)
Note:
Since we don't use SQLAlchemy models, autogenerate will be disabled
and migrations must be written manually.
"""
config = get_alembic_config()
logger.info(f"Creating new migration: {message}")
if autogenerate:
logger.warning(
"Auto-generation is not supported (no SQLAlchemy models). "
"Migration will be created with empty upgrade/downgrade functions."
)
command.revision(config, message=message, autogenerate=False)
logger.info("Migration created successfully. Edit the file to add SQL statements.")
+2 -1
View File
@@ -20,6 +20,7 @@ dependencies = [
"caldav",
"pyjwt[crypto]>=2.8.0",
"aiosqlite>=0.20.0", # Async SQLite for refresh token storage
"alembic>=1.14.0", # Database migrations
"authlib>=1.6.5",
"qdrant-client>=1.7.0",
"fastembed>=0.7.3", # BM25 sparse vector embeddings for hybrid search
@@ -128,7 +129,7 @@ dev = [
]
[project.scripts]
nextcloud-mcp-server = "nextcloud_mcp_server.cli:run"
nextcloud-mcp-server = "nextcloud_mcp_server.cli:cli"
smithery-main = "nextcloud_mcp_server.smithery_main:main"
[[tool.uv.index]]
+36 -24
View File
@@ -223,39 +223,51 @@ class SemanticSearchProvider implements IProvider {
}
/**
* Build URL to navigate to the original document.
* Build URL to navigate to Astrolabe with chunk viewer.
*
* URL formats match App.vue's getDocumentUrl() implementation for consistency.
* Links to Astrolabe app with query parameters that trigger the chunk modal,
* allowing users to preview the chunk before navigating to the full document.
*/
private function buildResourceUrl(array $result): string {
// Build base URL to Astrolabe app
$baseUrl = $this->urlGenerator->linkToRoute(Application::APP_ID . '.page.index');
// Extract chunk parameters
$docType = $result['doc_type'] ?? 'unknown';
$id = $result['id'] ?? null;
$path = $result['path'] ?? null;
$chunkStart = $result['chunk_start_offset'] ?? null;
$chunkEnd = $result['chunk_end_offset'] ?? null;
return match ($docType) {
'note' => $id
? $this->urlGenerator->linkToRoute('notes.page.index') . '/#/note/' . $id
: $this->urlGenerator->linkToRoute('notes.page.index'),
// If we have chunk information, build URL with parameters
if ($id !== null && $chunkStart !== null && $chunkEnd !== null) {
$params = [
'doc_type' => $docType,
'doc_id' => $id,
'chunk_start' => $chunkStart,
'chunk_end' => $chunkEnd,
];
'file' => $id
? $this->urlGenerator->linkToRouteAbsolute('files.view.index') . 'files/' . $id . '?dir=/&editing=false&openfile=true'
: $this->urlGenerator->linkToRouteAbsolute('files.view.index'),
// Add optional metadata
if (isset($result['title'])) {
$params['title'] = $result['title'];
}
if (isset($result['path'])) {
$params['path'] = $result['path'];
}
if (isset($result['page_number'])) {
$params['page_number'] = $result['page_number'];
}
if (isset($result['board_id'])) {
$params['board_id'] = $result['board_id'];
}
'deck_card' => isset($result['board_id']) && $id
? $this->urlGenerator->linkToRoute('deck.page.index')
. "board/{$result['board_id']}/card/{$id}"
: $this->urlGenerator->linkToRoute('deck.page.index'),
// Encode parameters for URL
$queryString = http_build_query($params);
return $baseUrl . '?' . $queryString;
}
'calendar', 'calendar_event' => $this->urlGenerator->linkToRoute('calendar.view.index'),
'news_item' => $id
? $this->urlGenerator->linkToRoute('news.page.index') . 'item/' . $id
: $this->urlGenerator->linkToRoute('news.page.index'),
'contact' => $this->urlGenerator->linkToRoute('contacts.page.index'),
default => $this->urlGenerator->linkToRoute(Application::APP_ID . '.page.index'),
};
// Fallback to base URL if no chunk information
return $baseUrl;
}
/**
+55
View File
@@ -501,6 +501,10 @@ export default {
return this.results.filter(r => (r.score || 0) >= threshold)
},
},
mounted() {
// Check for URL parameters to open chunk viewer
this.handleUrlParameters()
},
beforeDestroy() {
// Clean up Plotly event handlers to prevent memory leaks
const plotDiv = document.getElementById('viz-plot')
@@ -509,6 +513,51 @@ export default {
}
},
methods: {
handleUrlParameters() {
// Parse URL parameters
const urlParams = new URLSearchParams(window.location.search)
const docType = urlParams.get('doc_type')
const docId = urlParams.get('doc_id')
const chunkStart = urlParams.get('chunk_start')
const chunkEnd = urlParams.get('chunk_end')
// If we have chunk parameters, open the viewer
if (docType && docId && chunkStart !== null && chunkEnd !== null) {
// Construct a minimal result object
const result = {
doc_type: docType,
id: parseInt(docId, 10),
chunk_start_offset: parseInt(chunkStart, 10),
chunk_end_offset: parseInt(chunkEnd, 10),
title: urlParams.get('title') || this.t('astrolabe', 'Chunk Viewer'),
metadata: {},
}
// Add optional metadata
const path = urlParams.get('path')
if (path) {
result.metadata.path = path
}
const pageNumber = urlParams.get('page_number')
if (pageNumber) {
result.page_number = parseInt(pageNumber, 10)
}
const boardId = urlParams.get('board_id')
if (boardId) {
result.metadata.board_id = boardId
}
// Open the chunk viewer
this.$nextTick(() => {
this.viewChunk(result)
})
// Clear URL parameters to avoid reopening on navigation
const newUrl = window.location.pathname
window.history.replaceState({}, '', newUrl)
}
},
toggleDocType(docTypeId, checked) {
if (checked && !this.selectedDocTypes.includes(docTypeId)) {
this.selectedDocTypes.push(docTypeId)
@@ -616,6 +665,12 @@ export default {
case 'note':
return generateUrl(`/apps/notes/#/note/${id}`)
case 'file':
// For PDFs with page numbers, use the PDF viewer with page anchor
if (result.page_number && metadata.path) {
const pageParam = `#page=${result.page_number}`
return generateUrl(`/apps/files_pdfviewer/?file=${encodeURIComponent(metadata.path)}${pageParam}`)
}
// For other files, use the standard file viewer
if (id) {
return generateUrl(`/apps/files/files/${id}?dir=/&editing=false&openfile=true`)
}
Generated
+70
View File
@@ -144,6 +144,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792, upload-time = "2025-02-03T07:30:13.6Z" },
]
[[package]]
name = "alembic"
version = "1.17.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/02/a6/74c8cadc2882977d80ad756a13857857dbcf9bd405bc80b662eb10651282/alembic-1.17.2.tar.gz", hash = "sha256:bbe9751705c5e0f14877f02d46c53d10885e377e3d90eda810a016f9baa19e8e", size = 1988064, upload-time = "2025-11-14T20:35:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/88/6237e97e3385b57b5f1528647addea5cc03d4d65d5979ab24327d41fb00d/alembic-1.17.2-py3-none-any.whl", hash = "sha256:f483dd1fe93f6c5d49217055e4d15b905b425b6af906746abb35b69c1996c4e6", size = 248554, upload-time = "2025-11-14T20:35:05.699Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
@@ -1558,6 +1572,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" },
]
[[package]]
name = "mako"
version = "1.3.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
@@ -1966,6 +1992,7 @@ version = "0.52.1"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },
{ name = "alembic" },
{ name = "anthropic" },
{ name = "authlib" },
{ name = "boto3" },
@@ -2016,6 +2043,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "aiosqlite", specifier = ">=0.20.0" },
{ name = "alembic", specifier = ">=1.14.0" },
{ name = "anthropic", specifier = ">=0.42.0" },
{ name = "authlib", specifier = ">=1.6.5" },
{ name = "boto3", specifier = ">=1.35.0" },
@@ -3617,6 +3645,48 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.45"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/be/f9/5e4491e5ccf42f5d9cfc663741d261b3e6e1683ae7812114e7636409fcc6/sqlalchemy-2.0.45.tar.gz", hash = "sha256:1632a4bda8d2d25703fdad6363058d882541bdaaee0e5e3ddfa0cd3229efce88", size = 9869912, upload-time = "2025-12-09T21:05:16.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/1c/769552a9d840065137272ebe86ffbb0bc92b0f1e0a68ee5266a225f8cd7b/sqlalchemy-2.0.45-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e90a344c644a4fa871eb01809c32096487928bd2038bf10f3e4515cb688cc56", size = 2153860, upload-time = "2025-12-10T20:03:23.843Z" },
{ url = "https://files.pythonhosted.org/packages/f3/f8/9be54ff620e5b796ca7b44670ef58bc678095d51b0e89d6e3102ea468216/sqlalchemy-2.0.45-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8c8b41b97fba5f62349aa285654230296829672fc9939cd7f35aab246d1c08b", size = 3309379, upload-time = "2025-12-09T22:06:07.461Z" },
{ url = "https://files.pythonhosted.org/packages/f6/2b/60ce3ee7a5ae172bfcd419ce23259bb874d2cddd44f67c5df3760a1e22f9/sqlalchemy-2.0.45-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12c694ed6468333a090d2f60950e4250b928f457e4962389553d6ba5fe9951ac", size = 3309948, upload-time = "2025-12-09T22:09:57.643Z" },
{ url = "https://files.pythonhosted.org/packages/a3/42/bac8d393f5db550e4e466d03d16daaafd2bad1f74e48c12673fb499a7fc1/sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f7d27a1d977a1cfef38a0e2e1ca86f09c4212666ce34e6ae542f3ed0a33bc606", size = 3261239, upload-time = "2025-12-09T22:06:08.879Z" },
{ url = "https://files.pythonhosted.org/packages/6f/12/43dc70a0528c59842b04ea1c1ed176f072a9b383190eb015384dd102fb19/sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d62e47f5d8a50099b17e2bfc1b0c7d7ecd8ba6b46b1507b58cc4f05eefc3bb1c", size = 3284065, upload-time = "2025-12-09T22:09:59.454Z" },
{ url = "https://files.pythonhosted.org/packages/cf/9c/563049cf761d9a2ec7bc489f7879e9d94e7b590496bea5bbee9ed7b4cc32/sqlalchemy-2.0.45-cp311-cp311-win32.whl", hash = "sha256:3c5f76216e7b85770d5bb5130ddd11ee89f4d52b11783674a662c7dd57018177", size = 2113480, upload-time = "2025-12-09T21:29:57.03Z" },
{ url = "https://files.pythonhosted.org/packages/bc/fa/09d0a11fe9f15c7fa5c7f0dd26be3d235b0c0cbf2f9544f43bc42efc8a24/sqlalchemy-2.0.45-cp311-cp311-win_amd64.whl", hash = "sha256:a15b98adb7f277316f2c276c090259129ee4afca783495e212048daf846654b2", size = 2138407, upload-time = "2025-12-09T21:29:58.556Z" },
{ url = "https://files.pythonhosted.org/packages/2d/c7/1900b56ce19bff1c26f39a4ce427faec7716c81ac792bfac8b6a9f3dca93/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3ee2aac15169fb0d45822983631466d60b762085bc4535cd39e66bea362df5f", size = 3333760, upload-time = "2025-12-09T22:11:02.66Z" },
{ url = "https://files.pythonhosted.org/packages/0a/93/3be94d96bb442d0d9a60e55a6bb6e0958dd3457751c6f8502e56ef95fed0/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba547ac0b361ab4f1608afbc8432db669bd0819b3e12e29fb5fa9529a8bba81d", size = 3348268, upload-time = "2025-12-09T22:13:49.054Z" },
{ url = "https://files.pythonhosted.org/packages/48/4b/f88ded696e61513595e4a9778f9d3f2bf7332cce4eb0c7cedaabddd6687b/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215f0528b914e5c75ef2559f69dca86878a3beeb0c1be7279d77f18e8d180ed4", size = 3278144, upload-time = "2025-12-09T22:11:04.14Z" },
{ url = "https://files.pythonhosted.org/packages/ed/6a/310ecb5657221f3e1bd5288ed83aa554923fb5da48d760a9f7622afeb065/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:107029bf4f43d076d4011f1afb74f7c3e2ea029ec82eb23d8527d5e909e97aa6", size = 3313907, upload-time = "2025-12-09T22:13:50.598Z" },
{ url = "https://files.pythonhosted.org/packages/5c/39/69c0b4051079addd57c84a5bfb34920d87456dd4c90cf7ee0df6efafc8ff/sqlalchemy-2.0.45-cp312-cp312-win32.whl", hash = "sha256:0c9f6ada57b58420a2c0277ff853abe40b9e9449f8d7d231763c6bc30f5c4953", size = 2112182, upload-time = "2025-12-09T21:39:30.824Z" },
{ url = "https://files.pythonhosted.org/packages/f7/4e/510db49dd89fc3a6e994bee51848c94c48c4a00dc905e8d0133c251f41a7/sqlalchemy-2.0.45-cp312-cp312-win_amd64.whl", hash = "sha256:8defe5737c6d2179c7997242d6473587c3beb52e557f5ef0187277009f73e5e1", size = 2139200, upload-time = "2025-12-09T21:39:32.321Z" },
{ url = "https://files.pythonhosted.org/packages/6a/c8/7cc5221b47a54edc72a0140a1efa56e0a2730eefa4058d7ed0b4c4357ff8/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe187fc31a54d7fd90352f34e8c008cf3ad5d064d08fedd3de2e8df83eb4a1cf", size = 3277082, upload-time = "2025-12-09T22:11:06.167Z" },
{ url = "https://files.pythonhosted.org/packages/0e/50/80a8d080ac7d3d321e5e5d420c9a522b0aa770ec7013ea91f9a8b7d36e4a/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:672c45cae53ba88e0dad74b9027dddd09ef6f441e927786b05bec75d949fbb2e", size = 3293131, upload-time = "2025-12-09T22:13:52.626Z" },
{ url = "https://files.pythonhosted.org/packages/da/4c/13dab31266fc9904f7609a5dc308a2432a066141d65b857760c3bef97e69/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:470daea2c1ce73910f08caf10575676a37159a6d16c4da33d0033546bddebc9b", size = 3225389, upload-time = "2025-12-09T22:11:08.093Z" },
{ url = "https://files.pythonhosted.org/packages/74/04/891b5c2e9f83589de202e7abaf24cd4e4fa59e1837d64d528829ad6cc107/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9c6378449e0940476577047150fd09e242529b761dc887c9808a9a937fe990c8", size = 3266054, upload-time = "2025-12-09T22:13:54.262Z" },
{ url = "https://files.pythonhosted.org/packages/f1/24/fc59e7f71b0948cdd4cff7a286210e86b0443ef1d18a23b0d83b87e4b1f7/sqlalchemy-2.0.45-cp313-cp313-win32.whl", hash = "sha256:4b6bec67ca45bc166c8729910bd2a87f1c0407ee955df110d78948f5b5827e8a", size = 2110299, upload-time = "2025-12-09T21:39:33.486Z" },
{ url = "https://files.pythonhosted.org/packages/c0/c5/d17113020b2d43073412aeca09b60d2009442420372123b8d49cc253f8b8/sqlalchemy-2.0.45-cp313-cp313-win_amd64.whl", hash = "sha256:afbf47dc4de31fa38fd491f3705cac5307d21d4bb828a4f020ee59af412744ee", size = 2136264, upload-time = "2025-12-09T21:39:36.801Z" },
{ url = "https://files.pythonhosted.org/packages/3d/8d/bb40a5d10e7a5f2195f235c0b2f2c79b0bf6e8f00c0c223130a4fbd2db09/sqlalchemy-2.0.45-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83d7009f40ce619d483d26ac1b757dfe3167b39921379a8bd1b596cf02dab4a6", size = 3521998, upload-time = "2025-12-09T22:13:28.622Z" },
{ url = "https://files.pythonhosted.org/packages/75/a5/346128b0464886f036c039ea287b7332a410aa2d3fb0bb5d404cb8861635/sqlalchemy-2.0.45-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d8a2ca754e5415cde2b656c27900b19d50ba076aa05ce66e2207623d3fe41f5a", size = 3473434, upload-time = "2025-12-09T22:13:30.188Z" },
{ url = "https://files.pythonhosted.org/packages/cc/64/4e1913772646b060b025d3fc52ce91a58967fe58957df32b455de5a12b4f/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f46ec744e7f51275582e6a24326e10c49fbdd3fc99103e01376841213028774", size = 3272404, upload-time = "2025-12-09T22:11:09.662Z" },
{ url = "https://files.pythonhosted.org/packages/b3/27/caf606ee924282fe4747ee4fd454b335a72a6e018f97eab5ff7f28199e16/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:883c600c345123c033c2f6caca18def08f1f7f4c3ebeb591a63b6fceffc95cce", size = 3277057, upload-time = "2025-12-09T22:13:56.213Z" },
{ url = "https://files.pythonhosted.org/packages/85/d0/3d64218c9724e91f3d1574d12eb7ff8f19f937643815d8daf792046d88ab/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c0b74aa79e2deade948fe8593654c8ef4228c44ba862bb7c9585c8e0db90f33", size = 3222279, upload-time = "2025-12-09T22:11:11.1Z" },
{ url = "https://files.pythonhosted.org/packages/24/10/dd7688a81c5bc7690c2a3764d55a238c524cd1a5a19487928844cb247695/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a420169cef179d4c9064365f42d779f1e5895ad26ca0c8b4c0233920973db74", size = 3244508, upload-time = "2025-12-09T22:13:57.932Z" },
{ url = "https://files.pythonhosted.org/packages/aa/41/db75756ca49f777e029968d9c9fee338c7907c563267740c6d310a8e3f60/sqlalchemy-2.0.45-cp314-cp314-win32.whl", hash = "sha256:e50dcb81a5dfe4b7b4a4aa8f338116d127cb209559124f3694c70d6cd072b68f", size = 2113204, upload-time = "2025-12-09T21:39:38.365Z" },
{ url = "https://files.pythonhosted.org/packages/89/a2/0e1590e9adb292b1d576dbcf67ff7df8cf55e56e78d2c927686d01080f4b/sqlalchemy-2.0.45-cp314-cp314-win_amd64.whl", hash = "sha256:4748601c8ea959e37e03d13dcda4a44837afcd1b21338e637f7c935b8da06177", size = 2138785, upload-time = "2025-12-09T21:39:39.503Z" },
{ url = "https://files.pythonhosted.org/packages/42/39/f05f0ed54d451156bbed0e23eb0516bcad7cbb9f18b3bf219c786371b3f0/sqlalchemy-2.0.45-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd337d3526ec5298f67d6a30bbbe4ed7e5e68862f0bf6dd21d289f8d37b7d60b", size = 3522029, upload-time = "2025-12-09T22:13:32.09Z" },
{ url = "https://files.pythonhosted.org/packages/54/0f/d15398b98b65c2bce288d5ee3f7d0a81f77ab89d9456994d5c7cc8b2a9db/sqlalchemy-2.0.45-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9a62b446b7d86a3909abbcd1cd3cc550a832f99c2bc37c5b22e1925438b9367b", size = 3475142, upload-time = "2025-12-09T22:13:33.739Z" },
{ 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]]
name = "sse-starlette"
version = "3.0.3"