Commit Graph
100 Commits
Author SHA1 Message Date
Chris CoutinhoandClaude Opus 4.8 e4d81d47d9 feat: harmonize MCP tool + userinfo page to documents/chunks model
Extend the documents-vs-chunks split to the remaining status surfaces so all
three report consistently (Deck #195):

- nc_get_vector_sync_status MCP tool + VectorSyncStatusResponse: add
  indexed_documents (distinct) and indexed_chunks; keep indexed_count as a
  deprecated alias of indexed_chunks. Reuses count_indexed.
- userinfo HTML page (/app/vector-sync/status): show Indexed Documents AND
  Indexed Chunks rows; switch its count to count_indexed (which also excludes
  placeholder points — the old raw count included them).
- /api/v1/vector-sync/status: restore indexed_count as a deprecated alias of
  indexed_chunks so existing consumers (integration tests, pre-#115 UI) keep
  working; the change is now purely additive for indexed_count.

Tests: VectorSyncStatusResponse documents/chunks/alias + zeroed defaults.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 20:28:44 +02:00
Chris CoutinhoandClaude Opus 4.8 8c9f97d6c4 docs: clarify postgres-mode None + test exact=True default (review #850)
- Note at both metrics-task call sites that receive_stream is None in postgres
  mode (get_ingest_pending falls back to procrastinate counts).
- Add test_default_is_exact_true covering the status-endpoint count path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:47:29 +02:00
Chris CoutinhoandClaude Opus 4.8 fc3e0f28f6 fix: add metrics-interval validator + type/test gaps (review #850)
- Add Validator("VECTOR_SYNC_METRICS_REFRESH_INTERVAL", gte=1) so a 0/negative
  value can't turn the publish loop into a busy-spin.
- Annotate count_indexed's qdrant_client param as AsyncQdrantClient.
- Add tests: exact kwarg is forwarded to qdrant count, and the placeholder
  filter matches False (excludes placeholders) with chunk_index pinned to 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:39:46 +02:00
Chris CoutinhoandClaude Opus 4.8 fbe70ecd9c feat: backend-agnostic vector-sync gauges (pending/documents/chunks)
The only queue metric, mcp_vector_sync_queue_size, was updated inline by the
single-user consumer (processor_task) but never by the multi-user consumer
(oauth_processor_task). On multi-user tenants (e.g. blackbox-demo, 5 users) the
gauge read 0 for 24h while the live anyio buffer held ~2214 pending documents
(shown by /api/v1/vector-sync/status). The "indexed" figure was also a chunk
count (16039 points ≈ 480 docs) mislabelled as documents.

Publish a consumer-independent snapshot from a periodic task
(vector/metrics_publisher.vector_sync_metrics_task), spawned in BOTH lifespan
task groups (single-user and multi-user) and every queue backend:
- mcp_vector_sync_pending_documents — outstanding work via
  ingest_status.get_ingest_pending() (anyio buffer depth or procrastinate
  todo+doing); also keeps the legacy queue_size gauge meaningful on all paths.
- mcp_vector_sync_indexed_documents — distinct documents, counted exactly and
  cheaply via the one chunk_index=0 point per document (no facet).
- mcp_vector_sync_indexed_chunks — total non-placeholder points.

The /api/v1/vector-sync/status endpoint now returns indexed_documents (distinct
docs) AND indexed_chunks separately, so documents and chunks are no longer
conflated. The publisher uses approximate Qdrant counts (every-N-seconds gauge);
the on-demand endpoint counts exactly. New knob:
VECTOR_SYNC_METRICS_REFRESH_INTERVAL (default 20s). Fail-safe: a metrics refresh
never disturbs ingest.

BREAKING CHANGE: /api/v1/vector-sync/status field `indexed_documents` now holds
the distinct-document count (was the chunk count); the chunk count moved to the
new `indexed_chunks` field. The Astrolabe UI + the nc_get_vector_sync_status MCP
tool / userinfo page are harmonized in a follow-up (Deck #195).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:30:10 +02:00
Chris CoutinhoandClaude Opus 4.8 9452057570 fix(webdav): harden offset/key truthiness and escape SEARCH mime type
Optional review hardening on #849 (non-blocking nits from the approve):

- `_build_search_xml`: emit `<d:firstresult>` on `offset is not None` rather
  than truthiness, so a future explicit offset=0 isn't silently dropped.
- `_key`: key on `file_id is not None` so a (hypothetical) file_id of 0 isn't
  treated as absent and mis-keyed onto path.
- `_type_search_args`: XML-escape the MIME type before interpolating it into
  the SEARCH literal (defense-in-depth for any future user-supplied value),
  with a unit test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:19:30 +02:00
Chris CoutinhoandClaude Opus 4.8 eaa898e6eb fix(webdav): await fallback, guard dedup key, split paging for complexity
Address review on #849:

- Critical: add missing `await` on the exception-path fallback in
  search_files_all -- it returned a coroutine instead of the result list.
  Add unit tests for both the offset-page-raises (fallback) and
  offset-zero-raises (propagate) paths, which previously had no coverage.
- Guard `_key` dedup against items missing both file_id and path (fall back
  to id(item)) so they can't collapse under a shared None key and drop rows.
- Document the offset-ignored discard-and-refetch decision.
- Split the offset paging into `_search_offset_paged` (returns None to signal
  fallback) and share the truncation warning via `_warn_if_truncated`,
  cutting cognitive complexity below the threshold (SonarCloud S3776).
- Make the test side_effect helpers synchronous (SonarCloud S7503).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:44:37 +02:00
Chris CoutinhoandClaude Opus 4.8 31eb2d4e74 docs: explain pure-claimer eviction path in scanner (review #848)
Add a comment at the deletion-tracking scroll noting that a user who
gained access to a shared file via the tenant-wide dedup path (without
indexing it) is absent from the user_id-filtered indexed_file_ids, so the
grace-period sweep never enqueues a delete for them — their stale
acl_principals entry is reclaimed lazily by verify-on-read eviction.
Addresses review nit #3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:24:41 +02:00
Chris CoutinhoandClaude Opus 4.8 01cb7cf08c fix: paginate tagged-folder SEARCH so the scanner discovers all files
The vector-sync scanner expanded a tagged folder into its PDF descendants via
`WebdavClient.find_by_type(scope=dir)` with no result limit. A WebDAV SEARCH
with no `<d:nresults>` returns only Nextcloud's default page (~100 on the
affected instance), so large tagged folders were silently truncated and most
documents were never queued for indexing (e.g. a 220-file folder yielded 100).

Add `search_files_all`, which pages the SEARCH to completion. It uses
`<d:firstresult>` offset paging where supported and, because Nextcloud 31
ignores offset (verified against a live instance), detects the repeated page
and falls back to a single bounded fetch with an explicit large `<d:nresults>`.
`find_all_by_type` wraps this and is now used for tagged-folder expansion;
`find_by_type` is unchanged for the interactive MCP tools.

Crossing `WEBDAV_SEARCH_MAX_RESULTS` logs a warning and increments the new
`astrolabe_document_scan_truncated_total` metric, so a coverage cap can never
again hide files silently.

Scope: this fixes discovery only. Cross-user double-processing of identical
shared files (point-ID collisions) is tracked separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:07:28 +02:00
Chris CoutinhoandClaude Opus 4.8 c3758a0bdf fix: only merge prior acl_principals for files (review #848)
existing_principals() ran for every doc type when seeding acl_principals.
note/news_item/deck_card IDs are per-user (not globally unique) and chunk
point IDs are user-agnostic, so on an ID collision the merge would pull in
another user's principal and cross-surface their content via the
acl_principals search branch. It was also N wasted tenant-wide scrolls on
initial sync for those types. Gate the prior-principal merge on
doc_type == "file" (the only type with cross-user dedup + globally-unique
fileid); other types seed with the indexer only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 13:02:43 +02:00
Chris CoutinhoandClaude Opus 4.8 1c93e7286d feat: dedup shared-file parsing/embedding across users in vector sync
A file shared across many users — directly, or via a group folder shared
to a group — was parsed and embedded once per user. Chunk point IDs are
user-agnostic (uuid5(tenant_id, doc_id=fileid, chunk_index)), but the
per-user freshness gate filtered Qdrant by user_id, so two readers
ping-ponged: each overwrote the other's points and each kept seeing "not
indexed for me", reprocessing every scan. Production telemetry (note
386945, finding #5) measured identical docs re-processed every few hours
at 7-13s each, with PDF parse ~62% of per-doc cost.

Layer 1 — tenant-wide dedup:
- Thread the scanner's tag-REPORT etag into the file DocumentTask and the
  chunk payload; index `etag` as a KEYWORD field.
- vector/sharing_state.find_indexed_content scrolls tenant-wide (no
  user_id filter) for a non-placeholder point matching
  (doc_id, doc_type, etag), gated on embedding_identity in Python so a
  model switch correctly forces a re-embed.
- Scanner skips enqueue and the processor skips fetch/parse/embed when a
  match exists (cross-worker race-guard before WebDAV read). Dedup is
  fail-safe: a Qdrant error degrades to "process normally".

Layer 2 — observed-access ACL (no admin / GroupFolders API needed):
- Each point carries `acl_principals` = the set of user:<uid> whose
  scanner has observed (hence can read) the file. The per-user tag REPORT
  is the access oracle; group membership/GroupFolders enumeration is
  admin-only and unavailable in multi-user modes.
- build_ownership_filter ORs MatchAny(acl_principals, ["user:<me>"]) so a
  deduplicated shared/group-folder point surfaces to every reader;
  verify-on-read (_verify_files) remains the precise ACL gate.
- Deletion/eviction become "release one user": drop the principal and
  delete the points only when the set empties, so one user untagging a
  shared file doesn't evict it for the others. Legacy points without the
  field keep the original per-user delete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 12:55:17 +02:00
Chris CoutinhoandClaude Opus 4.8 ad211ee2da fix: make procrastinate ingest queue opt-in (default to in-process anyio)
An unset INGEST_QUEUE auto-derived "postgres" whenever DATABASE_URL was
PostgreSQL, silently starting the procrastinate ingest worker (schema
migration, reclaim cron, deferred jobs) on every Postgres-backed tenant —
even though none had opted into the api/worker split. Observed on
tenant-blackbox-demo (:0.98.0): ~600 "Deferred 1 job" log lines / 24h.

Resolve an unset INGEST_QUEUE to "memory" (the in-process anyio queue)
regardless of the database backend. procrastinate is now strictly opt-in
via an explicit INGEST_QUEUE=postgres; the existing guard still rejects
postgres against a SQLite DATABASE_URL. Docs + unit test updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:59:24 +02:00
Chris Coutinho 1e615c2bf1 build: Bump astrolabe submodule 2026-06-04 00:32:41 +02:00
Chris CoutinhoandClaude Opus 4.8 5affbbcaa6 fix: initialize document processors in the ingest worker (PR #836 round-5)
🟡 The `worker` command never called initialize_document_processors(), so a
worker pod with ENABLE_UNSTRUCTURED/TESSERACT/CUSTOM configured silently ran
PyMuPDF-only (only the import-time-registered processor). The always-on API pod
registers them in its lifespan; the worker has its own startup path, so call
initialize_document_processors() there too (before run_worker_async).

🟢 Drop the unused get_database_url monkeypatch in the Postgres integration
fixture (build_app_for_url passes the URL explicitly; only the ssl lookup needs
pinning).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:52:33 +02:00
Chris CoutinhoandClaude Opus 4.8 704a537847 chore: round-4 polish + standardize on module-level loggers
Round-4 review (non-blocking) items:
- get_procrastinate_conninfo: warn on an empty connect_timeout= value (it falls
  back to the 10s default); preserve an explicit connect_timeout=0.
- Document the _doc_queueing_lock user_id invariant (NC rejects ':' in usernames).
- docs/configuration.md: note that `db downgrade` leaves procrastinate's tables
  in place and how to drop them on a full teardown.
- reclaim_stalled_ingest_jobs: debug heartbeat log when nothing is stalled.
- Drop the redundant list() wrap in the integration stalled-jobs assertion.

Logging pattern: define a module-level `logger = logging.getLogger(__name__)`
and use it instead of function-local or inline getLogger(__name__) calls
(config.py, config_validators.py, tests/.../test_scope_authorization.py). The
test file's dev-only `scripts.*` import gets a ty: ignore since it resolves via
sys.path at runtime, not as an installed package.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:44:59 +02:00
Chris CoutinhoandClaude Opus 4.8 b10ce15032 fix: address PR #836 round-3 review (lock-key invariant, single open)
🟡 Document the _doc_queueing_lock ":" delimiter invariant (user_id and the
   controlled doc_type enum are colon-free, so the key is collision-safe; a
   future doc_type with ":" must not be added).
🟡 API pod no longer opens the procrastinate connector twice on startup: add
   ProcrastinateTaskProducer.ensure_schema() (applies the schema on the
   already-open pool) and have both lifespan branches build the producer then
   ensure_schema — one open/close cycle, matching the worker. build_producer now
   returns the concrete producer type.
🟢 Document in ports.py that a long-lived-connection producer may optionally
   provide drain() (lifespan probes via getattr).
🟢 Add a unit test that a non-credential pipeline error propagates (for
   procrastinate's RetryStrategy) and still closes the client via finally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:32:16 +02:00
Chris CoutinhoandClaude Opus 4.8 820b98dac1 fix: address PR #836 round-2 review (connect/timeout/observability)
🟡 Document why ProcrastinateTaskProducer.connect() uses `await app.open_async()`
   (AwaitableContext: await opens a long-lived pool, closed by drain()) and add a
   connect()/drain() lifecycle unit test (InMemoryConnector) asserting the pool is
   opened by connect and closed by drain — previously untested.
🟡 get_procrastinate_conninfo: forward connect_timeout from DATABASE_URL or
   default 10s so an unreachable DB can't hang worker/API startup indefinitely;
   warn only on other dropped query params. + tests.
🟢 INGEST_DELETE_SUCCEEDED_JOBS (default true) makes the worker's succeeded-job
   deletion configurable for audit retention.
🟢 Worker startup logs via logger.info (structured/OTel) instead of click.echo.
🟢 INGEST_STALLED_JOB_SECONDS (default 300) makes the crash-reclaim threshold
   tunable for slow embedding backends; reclaim reads it per-run.

The broad `except` in _apply_ingest_queue_schema_open is kept deliberately:
procrastinate wraps psycopg errors, so narrowing to psycopg.errors.* would miss
the wrapped DDL-conflict and turn a benign concurrent-apply race into a failure;
the presence re-check re-raises genuine errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:21:50 +02:00
Chris Coutinho aa2f01cd4b build: Bump astrolabe submodule 2026-06-03 14:59:59 +02:00
Chris CoutinhoandClaude Opus 4.8 9c0c6a0c50 fix(search): cap path_prefixes server-side; unify Iterable typing
Round 3 review follow-ups:
- Enforce the folder cap (MAX_PATH_PREFIXES=20) inside normalize_path_prefixes
  so the REST/viz endpoints are bounded too, not just the MCP tool's Field
  and the PHP client. Single server-side enforcement point; the MCP tool's
  Field(max_length=...) now references the same constant.
- Widen the SearchAlgorithm ABC and both concrete implementations'
  path_prefixes param to Iterable[str] | None, matching the widening of
  build_base_filter_conditions from the prior round.
- Add a normalize_path_prefixes cap test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 13:18:52 +02:00
Chris CoutinhoandClaude Opus 4.8 ea108140ab fix(search): cap path_prefixes at the MCP tool; widen path filter tests
Round 2 review follow-ups:
- Add Field(max_length=20) to the nc_semantic_search path_prefixes param so
  an LLM client can't build an unbounded OR-filter (mirrors the cap the
  Astrolabe PHP controller applies on the UI path).
- Note in normalize_path_prefixes that the two-pass collect-then-strip is
  deliberate (the `if path_prefix:` guard is truthy for whitespace-only
  input; the strip pass is what drops it).
- Tests: exercise build_base_filter_conditions with 3 folders (guards the
  list comprehension) and parametrize the no-path case over None, empty
  list, and blank-only inputs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 13:10:14 +02:00
Chris CoutinhoandClaude Opus 4.8 cd243ed6c3 fix(search): address review feedback on multi-folder path filter
- visualization.py: drop the CSV string-split branch. The Astrolabe PHP
  client sends path_prefixes as a JSON array, so only a list is accepted;
  any other shape is ignored rather than comma-split (which would corrupt
  folder names containing commas).
- viz_routes.py: split the path_prefixes query param on newline (a comma
  is a valid POSIX path char; a newline is not) and pass None instead of
  [""] when the param is absent.
- access_filter.py: widen build_base_filter_conditions' path_prefixes to
  Iterable[str] for consistency with normalize_path_prefixes.
- ADR-027: document the newline delimiter (frontend/viz route) and JSON
  array (PHP->MCP body), and the PHP-side cap on list width.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 13:03:42 +02:00
Chris CoutinhoandClaude Opus 4.8 de6c4b360d feat(search): support multiple folders in the semantic-search path filter
Extend the ADR-027 Phase 2 path filter from a single path_prefix to a
list of folders. The new normalize_path_prefixes() helper is the single
source of truth for trimming, dropping blanks, and de-duplicating, and
folds the legacy single path_prefix into the list for backward
compatibility.

build_base_filter_conditions() adds one MatchText to the must clause for
a single folder (unchanged shape) and OR-s multiple folders via a nested
Filter(should=[...]) so a file under any selected folder matches while
still AND-ing against the ACL/doc_type/date conditions.

path_prefixes is threaded through every search surface: the
nc_semantic_search MCP tool, the visualization API (JSON body), and the
viz route (CSV query param). The Astrolabe frontend folder picker that
produces these lists ships in a companion astrolabe PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 12:51:37 +02:00
Chris CoutinhoandClaude Opus 4.8 cfdef3c2c5 fix: address PR #836 review — forward task_producer to MCP contexts + cleanups
🔴 nc_get_vector_sync_status reported pending=0 for INGEST_QUEUE=postgres: the
AppContext/OAuthAppContext per-session yields snapshotted the stream fields but
never forwarded task_producer, so lifespan_ctx.task_producer was always None.
Convert task_producer to a @property that reads _vector_sync_state live (like
eviction_task_group), removing the snapshot field so the yields can't drop it.
Add a regression test pinning the contract on both contexts.

🟡 Remove the unused _RECLAIM_TASK_NAME constant.
🟡 get_procrastinate_conninfo: warn + document that DATABASE_URL query params
   (application_name, connect_timeout, …) are dropped.
🟡 worker: open the procrastinate App once — apply_ingest_queue_schema gains
   manage_connection=False so the worker reuses its own open connector instead
   of a redundant open/close before run_worker_async.

🟢 Clarify the apply-schema broad-except comment (non-race errors re-raise) and
   document the deliberate Any typing in ingest_status.get_ingest_pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 12:40:50 +02:00
Chris CoutinhoandClaude Opus 4.8 63e073c224 fix(ci): install procrastinate in the dev group so ty + unit tests resolve it
CI runs `uv run --frozen ty check -- nextcloud_mcp_server` and `uv run pytest -m
unit`, which install the default + dev groups but not the `[postgres]` optional
extra. vector/queue/procrastinate.py imports procrastinate at module scope (the
task registration needs App/Blueprint), so without it installed ty fails on
unresolved imports and the procrastinate unit tests fail to collect.

Add procrastinate + psycopg to the dev group (kept in the [postgres] extra for
production opt-in) so dev/CI always type-check and test against them, while
SQLite/personal installs stay free of the Postgres deps. Matches the repo's
optional-DB-driver philosophy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 04:25:30 +02:00
Chris CoutinhoandClaude Opus 4.8 3407e3cf64 chore: run ty on tests/ and make the new ingest tests pass it
Stop excluding tests/ from the ty-check pre-commit hook so touched test files
are type-checked. Fix the new ingest tests under the now-active check:
- cast duck-typed JobContext / App test doubles to their declared types;
- narrow the gated Postgres fixture's str | None URL (pytest.skip isn't modelled
  as NoReturn by ty).

Pre-existing type issues in untouched test modules are unaffected (the hook
checks only changed files); they'll be cleaned as those files are next touched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 04:20:15 +02:00
Chris CoutinhoandClaude Opus 4.8 21b7922bac feat: replace NATS ingest with procrastinate Postgres queue (#183)
Re-architect document ingest from the shared NATS-glued document-processor to a
per-tenant, in-process model owned by nextcloud-mcp-server (Deck #183). The MCP
server now owns both sides of ingest:

- Producer (api role): the scanner defers one job per changed document into the
  app's Postgres via procrastinate (queueing_lock dedup; no execution lock, so a
  crashed worker can't deadlock a doc — Qdrant upserts are idempotent).
- Consumer (worker role): `nextcloud-mcp-server worker` drains the queue and runs
  the existing process_document pipeline; a periodic task reclaims jobs orphaned
  in `doing` by a crash.

INGEST_QUEUE selects the transport (auto: postgres when DATABASE_URL is Postgres,
else the in-process anyio queue for SQLite/dev). procrastinate manages its own
tables (applied on a fresh DB at startup and by `db upgrade`). The vector-sync
status surface reads job counts from Postgres in postgres mode. procrastinate +
psycopg3 ship in the [postgres] extra; the app's own engine still uses asyncpg
(driver unification is a follow-up handled in the rendered Helm chart).

NATS JetStream, the Postgres-queue stub, the bus status subscriber, and nats-py
are removed.

BREAKING CHANGE: the external-NATS-ingest env vars are removed
(INGEST_MODE, STATUS_BACKEND, INGEST_BUS_URL, INGEST_BUS_NUM_REPLICAS,
FACT_EVENT_EMITTER). Use INGEST_QUEUE (memory|postgres) and the `worker`
command instead. TENANT_ID is retained (no longer NATS-subject-charset-validated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 04:11:11 +02:00
Chris CoutinhoandClaude Opus 4.8 72a698dfe2 ci: serialize bump-version and gate releases on actual version bump
Prevent concurrent version bumps and spurious releases in the release
pipeline:

- Add a workflow-level concurrency group (cancel-in-progress: false) so
  only one bump-version run executes at a time. Concurrent runs have
  previously raced to bump the version and push tags, causing release
  failures. Subsequent pushes now queue instead of cancelling an
  in-flight bump/release.

- Make commitizen the single source of truth for whether a release is
  warranted. The previous grep heuristic counted commits matching
  feat|fix|docs|refactor|perf|test|build|ci|chore, but commitizen only
  bumps for feat/fix/breaking changes. A CI- or docs-only push therefore
  set bumped=true and fired release+docker against the old, already
  released tag. Now we compare the latest tag before/after running
  bump-mcp.sh and only set bumped=true (and emit the new tag) when it
  actually changes, so release/docker exit early on non-release pushes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 02:19:26 +02:00
Chris Coutinho aa9f094424 build: Bump astrolabe submodule 2026-06-03 02:02:09 +02:00
Chris CoutinhoandClaude Opus 4.8 b779627fa6 fix(observability): address third review round
Remaining items from the PR #831 Claude review:

- processor span symmetry: add "vector_sync.total_chars" to the sparse
  embedding span (already on the dense span) and drop the redundant
  "embedding.batch_size" attribute from both spans — it always equalled
  vector_sync.chunk_count and would mislead once batching is split.
- metrics: document the deliberate "throughput counts only on full success"
  contract in record_document_parse (partial extractions flagged
  success=False are counted as a parse-error but never inflate
  pages/chars/bytes throughput).
- config: extract _detect_base_provider() -> (family, model) as the single
  source of truth for the provider-detection priority chain, shared by
  get_embedding_model_name() and get_embedding_provider_family(). Preserves
  the intentional gateway asymmetry (only the family method short-circuits).
- base.py: Optional[...] -> PEP 604 `... | None`; drop now-unused import.

Behavior unchanged (get_embedding_* outputs covered by test_config.py).

Refs Deck #175, PR #831.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 02:01:28 +02:00
Chris CoutinhoandClaude Opus 4.8 ab128bef5b feat(search): ADR-027 Phase 2 — file-path filter
Add a path_prefix filter to semantic search, honoured on both the MCP tool and
the dense-only visualization/API paths through the shared filter contract.

- build_base_filter_conditions: append FieldCondition(file_path,
  MatchText(path_prefix)) when set. file_path is only on doc_type == "file"
  points, so a non-empty path_prefix implicitly restricts to files.
- Promote path_prefix to an explicit keyword param on the SearchAlgorithm ABC
  and both algorithms; thread it through nc_semantic_search (blank ⇒ no filter),
  the /api/v1 search endpoints, and the viz route.
- Add a file_path TEXT payload index to _PAYLOAD_INDEX_FIELDS (no content
  re-index; idempotent startup migration). MatchText tokenizes on server Qdrant
  and matches by substring on local/embedded qdrant-client — both serve folder
  scoping.
- Update ADR-027 (Phase 2 implemented; readiness table; semantics note). Tests.

Refs ADR-027 Phase 2. Deck #177.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:51:12 +02:00
Chris CoutinhoandClaude Opus 4.8 c2c8dc1a08 feat(search): ADR-027 Phase 1 — modified-date range filter
Add a modified_after/modified_before date-range filter to semantic search,
honoured on both the MCP tool path (BM25HybridSearchAlgorithm) and the
dense-only visualization/API path (SemanticSearchAlgorithm) through one shared
contract.

- Promote modified_after/modified_before to explicit keyword params on the
  SearchAlgorithm ABC and both concrete algorithms; factor the shared
  placeholder+ownership+doc_type+date filter into
  access_filter.build_base_filter_conditions so new filters land in one place.
- nc_semantic_search: accept RFC 3339 / ISO 8601 (or Unix seconds) bounds via
  utils.validation.parse_modified_timestamp; Annotated/Field constraints on the
  numeric args; explicit McpError guard for after > before. Thread the parsed
  bounds through the cross-app and per-doc_type dispatch.
- /api/v1 search endpoints + viz route parse the same formats and 400 on bad or
  inverted ranges.
- Add a modified_at INTEGER payload index to _PAYLOAD_INDEX_FIELDS; the
  idempotent _ensure_payload_indexes() startup path migrates existing
  collections with no content re-index.
- Update ADR-027 to resolve the review feedback (validation placement, shared
  algorithm contract, deferral of nc_semantic_search_answer, payload index,
  RFC-3339-at-the-boundary rationale). Add unit tests.

Refs ADR-027. Deck #177.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:35:20 +02:00
Chris CoutinhoandClaude Opus 4.8 b736bf199b perf(search): skip exclusion lookup on empty tag set; fix semaphore comment
Address the two important findings from the claude bot's latest re-review:

- _verify_files: skip get_excluded_file_paths entirely when the tag REPORT
  returns no files. An empty `tagged` yields an empty `tagged_ids` regardless
  of exclusions, so the lookup's 2xlen(EXCLUDED_TAGS) WebDAV fan-out is wasted
  work in the common "this tag matched nothing" case. The per-result loop still
  runs, so malformed doc_ids are still kept (fail-open) — pinned by a new test
  (test_verify_files_empty_tag_set_skips_exclusion_lookup), which also asserts
  the exclusion lookup is never awaited.
- Rewrite the semaphore comment: it claimed "the slot bounds them", but the slot
  only caps concurrent *searches* — get_excluded_file_paths internally spawns a
  task group issuing 2xlen(EXCLUDED_TAGS) concurrent WebDAV calls, so live
  Nextcloud connections can exceed VERIFICATION_CONCURRENCY. Comment now says so
  and points at configuration.md.

The third 🟡 (sequential dir expansion in find_files_by_tag) is pre-existing and
flagged by the reviewer as a follow-up, not part of this PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 00:23:55 +02:00
Chris CoutinhoandClaude Opus 4.8 7033c64393 fix(search): address PR #834 re-review (403/404 coverage + docs)
Resolve the blocking + important findings from the claude bot's re-review:

- test (blocking): pin the file verifier's fail-open contract for definitive
  403/404 on the tag REPORT, not just transient 503/429. A disabled systemtags
  endpoint commonly 403s; unlike the per-access verifiers (where 403/404 = drop),
  the batch file verifier must keep all results since the whole set hinges on one
  REPORT. Adds _http_error(403)/_http_error(404) to
  test_verify_files_tag_fetch_failure_keeps_all and documents the asymmetry.
- docs (important): migration caveat — if vector-index was created as
  user_visible=False (manual occ tag:add, or pre-release), an owner's tag won't
  surface in a recipient's REPORT and shared-file results are silently dropped
  after upgrade. Note that the MCP server's get_or_create_tag defaults to
  user_visible=True, and how to verify/fix an existing tag.
- docs (important): note the file verifier's latency scales with both the
  Depth:infinity folder expansion and the EXCLUDED_TAGS lookup (~2 WebDAV calls
  per excluded tag, fanned out under one slot); suggest lowering
  VERIFICATION_CONCURRENCY for large excluded-tag lists / deeply tagged trees.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:52:16 +02:00
Chris CoutinhoandClaude Opus 4.8 8cae7d1708 fix(search): address PR #834 review findings
Resolve the latest PR-review comment on the verify-on-read tag-gate work:

- tests: stringify note IDs in test_verify_on_read.py so SearchResult.id
  matches production (scanner stringifies all IDs on write) — helper and the
  keeps/deleted/mixed/dedupe assertions (blocking).
- tests: make the unshared-file negative control a PDF so the drop is
  unambiguously "unshared", not a mime_type_filter mismatch.
- config: add Validator("VECTOR_SYNC_PDF_TAG", len_min=1) — an empty tag name
  would make find_files_by_tag("") misbehave in the verifier and scanner.
- verification: correct the _verify_files comment — two batch fetches (tag
  REPORT + EXCLUDED_TAGS lookup) are held under one semaphore slot; the
  pure-Python intersection runs outside it.
- tests: de-duplicate the minimal-PDF constant into a shared PDF_BYTES in
  tests/integration/conftest.py, imported by both integration modules.

Verified: ruff/format/ty/unit all green; the two integration modules
(10 tests) pass against a local Nextcloud (app-only, no MCP profile needed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:09:46 +02:00
Chris CoutinhoandClaude Opus 4.8 d4dbf01b0a fix(search): gate verify-on-read file results on vector-index tag membership
Verify-on-read only checked file *accessibility* (file_accessible_by_id),
never tag membership, so a file removed from the `vector-index` tag (but
still readable) kept surfacing in semantic search, and stale points only
got evicted when they happened to rank in a search's top-K.

Rework `_verify_files` to gate on current `vector-index` tag membership via
a single batch `find_files_by_tag(tag, mime_type_filter="application/pdf")`
REPORT per search (plus a one-shot EXCLUDED_TAGS lookup for exclusion-wins
parity) — exactly what the scanner indexes. A file is kept iff it is in that
set, so untagged / deleted / excluded files drop out immediately and the
existing eviction wiring reclaims their Qdrant points. The gate is strict
for all file results, own and shared. Mirrors the batch-fetch-and-intersect
shape of `_verify_news_items` (one semaphore slot, fail-open on fetch error,
malformed-id keep).

- Promote the tag name to a `vector_sync_pdf_tag` Settings field (dynaconf
  env mapping VECTOR_SYNC_PDF_TAG) used by both scanner and verifier;
  drop the scanner's direct os.getenv.
- Expose `find_files_by_tag` on NextcloudClientProtocol.
- Rewrite the file-verifier unit tests (tagged/untagged/deleted/excluded/
  fail-open/non-numeric); update the ACL + verify-on-read integration tests
  to seed tagged PDFs.
- Amend ADR-019 and the configuration.md verify-on-read latency budget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:14:44 +02:00
Chris CoutinhoandClaude Opus 4.8 f6ab04b2d9 docs: add ADR-027 for rich search filters
Add ADR-027 describing rich, chip-style filters for Astrolabe semantic
search (modified-date range, doc type, path, tags). Generalises the
existing doc_type filter contract (tool param -> search() kwarg ->
Qdrant FieldCondition applied pre-fusion and pre-verify-on-read) and
phases the rollout by payload readiness: date range ships now, path and
tags defer behind a payload index / re-index.

Tracking: Astrolabe Cloud POC Deck card #177

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:12:59 +02:00
Chris CoutinhoandClaude Opus 4.8 68c9e20636 fix(observability): address second review round
- Failed deletes no longer bump astrolabe_documents_indexed_total: the outer
  except in process_document now gates doc_type on operation != "delete", so a
  delete error is counted as processed-error but not as an indexing event.
  Added test_failed_delete_is_processed_but_not_indexed.
- registry parse span: pass record_exception=True explicitly (matches
  instrument_tool) and add a structured logger.warning on the parse-error path
  (processor/tier/byte_size/duration_ms) for a Loki-aggregatable failed-parse
  signal.
- test_error_does_not_increment_throughput: snapshot-before/delta pattern
  instead of absolute 0.0 (counters are global singletons).
- config: document the deliberate gateway asymmetry between
  get_embedding_model_name() (no gateway branch) and
  get_embedding_provider_family() (short-circuits on gateway).
- Cleanup in touched scope: narrow `except (HTTPStatusError, Exception)` to
  `except Exception` (drop now-unused import); convert registry signatures from
  Optional[...] to `... | None`.

Refs Deck #175, PR #831.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 21:09:50 +02:00
Chris CoutinhoandClaude Opus 4.8 2e49e442f4 fix(observability): address PR review + SonarCloud findings
Reviewer findings:
- Fix double-count of exhausted-retry failures: the inner final-retry branch
  and the outer except both recorded a processing error. Consolidate to the
  outer handler (single call site); inner branch keeps only the Qdrant-upsert
  error metric. Regression test added.
- Deletes are no longer counted as indexing events: the delete success path
  drops doc_type so astrolabe_documents_indexed_total is not inflated.
  Regression test added.
- Reuse the already-resolved `settings` in _index_document instead of a second
  get_settings() call.
- Use explicit `> 0` guards in record_document_parse / record_embedding instead
  of truthiness checks.

SonarCloud:
- S1244 (BUG): replace float `==` equality in metric tests with pytest.approx.
- S5332 (hotspot): use https in the gateway-URL test fixture.
- S1192: extract the repeated "vector_sync.chunk_count" span-attribute literal
  into a module constant.

Review nit: move the duplicated `_sample` test helper into a shared
`metric_sample` fixture in tests/unit/conftest.py.

Refs Deck #175, PR #831.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 18:15:50 +02:00
Chris CoutinhoandClaude Opus 4.8 5d205fcaab feat(observability): astrolabe_* metrics + traces for the document pipeline
Make per-tier bottlenecks in the document-processing pipeline
(scan -> fetch -> parse -> chunk -> embed -> Qdrant upsert) visible via
metrics, traces, and structured logs. Today the document_processors layer
emits only a logger.info line: no metric, no span, and page counts live only
inside a log string. The single processing-duration histogram is unlabeled and
whole-document, so it cannot isolate parse vs embed vs upsert.

New astrolabe_* metric family (distinct from the mcp_* protocol metrics):
- astrolabe_document_parse_{duration_seconds,total} + pages/chars/bytes counters
  recorded at the ProcessorRegistry.process() boundary (covers all current and
  future processors uniformly)
- astrolabe_document_escalation_total (dormant; tiered-pipeline readiness)
- astrolabe_embedding_{duration_seconds,requests_total,chunks_total,chars_total}
- astrolabe_document_chunks_total, astrolabe_documents_indexed_total{source,status}

Tracing: new document_processor.parse child span + enriched embed/chunk span
attributes (provider/model/batch_size/chunk_count). Structured logs gain a
consistent field vocabulary (doc_id, doc_type, processor, tier, pages, chars,
byte_size, chunks, duration_ms, status) so Loki can aggregate without regex.

Tier-readiness: processor/tier are labels from day one and a tier property is
added to DocumentProcessor, so adding docling/OCR/LLM tiers later is additive
(new label values, never new metrics). Tenant comes from the kube namespace
label; mime_type/model are span attributes only (cardinality). Existing
mcp_vector_sync_*/mcp_qdrant_* are left untouched.

Refs Deck #175 (superset of #173 Phase 2). Dashboard/recording-rules follow-up
tracked on #175 for homelab-argocd.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:46:20 +02:00
Chris CoutinhoandClaude Opus 4.8 d2da195172 refactor(deck): address PR #826 review feedback
- Modernize the new models (DeckCardSummary, DeckCommentSummary,
  StackOverview, BoardOverviewResponse + the loosened unions) to PEP 604
  syntax (list[...] / X | None), per CLAUDE.md.
- Make status="done" exclude archived cards so open/done/archived partition
  the board with no overlap (a done+archived card is reported only as
  "archived"); document the semantics in docstrings and docs/deck.md, add a
  partition unit test.
- deck_get_archived_stacks: pass through label/assigned_to filters (status
  stays archived-only by definition); note the limitation in the docstring.
- Rename _validate_description_max_length → _validate_positive_length (now a
  generic positive-length guard).
- Soften deck_get_board_overview docstring: it views board state and omits
  the ACL/user/label-management fields deck_get_board exposes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:40:46 +02:00
Chris CoutinhoandClaude Opus 4.8 4f170d32cb fix(embedding): normalize gateway base_url to the /v1 base path
EMBEDDING_GATEWAY_URL is configured as a bare origin (scheme://host:port) —
the deployment's Service URL. GatewayProvider now appends the gateway's /v1
base path before handing the URL to the OpenAI SDK, so both embed posts
({base}/embeddings) and dimension discovery ({base}/models) land under /v1.
Idempotent: a URL already ending in /v1 is left unchanged.

This lets EMBEDDING_GATEWAY_URL stay a bare domain (matching the gitops
Service URLs) instead of requiring a hand-appended /v1.

Also align the `embedding_gateway_model` field default with _DEFAULTS
("mistral/mistral-embed"). The gateway catalog is provider-namespaced, and
_detect_dimension matches `entry.id == embedding_model`; the stale
un-namespaced default would silently miss the catalog entry and leave the
dimension unresolved (re-triggering the external-mode startup crash).

Tests: bare / trailing-slash / idempotent normalization + a bare-origin
discovery test asserting /v1/models. 16 gateway-provider tests pass;
providers + vector suites green (129 total); ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:39:23 +02:00
Chris CoutinhoandClaude Opus 4.8 b11103064c feat(deck): compact card/comment retrieval (summaries, filters, board overview)
Deck read tools returned too many tokens to be usable as boards grow — even
deck_get_stacks(description_max_length=1) exceeded the MCP token limit because
every card was fully serialized in list views.

- Add compact projection models (DeckCardSummary, DeckCommentSummary,
  StackOverview, BoardOverviewResponse) and a uniform detail="summary"|"full"
  knob (summary default) on deck_get_cards / get_stacks / get_stack /
  get_archived_stacks.
- Add pre-serialization filtering: status (open/done/archived/all), label,
  assigned_to.
- Add deck_get_board_overview: board title + label legend + stacks with
  compact card rows + counts in a single call.
- Compact comments: detail / message_max_length / newest-first order on
  deck_get_card_comments.
- Docs + unit/integration tests.

BREAKING CHANGE: deck list tools now default to detail="summary" and
status="open". The include_archived_cards parameter is replaced by status
(use status="all" to include archived cards); pass detail="full" to restore
the previous per-card shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 16:17:46 +02:00
Chris CoutinhoandClaude Opus 4.8 be360edab0 fix(api): distinguish Nextcloud 5xx from auth failure; tighten body parse (#824)
Address PR #825 review round 2:
- _validate_nextcloud_credentials now only maps OCS HTTP 401/403 to a 401
  "invalid credential"; any other non-200 (5xx, 503 maintenance mode) surfaces
  as 502 "Nextcloud returned a server error" so ops don't chase a phantom bad
  password when Nextcloud is actually down.
- The client-facing 401 message is now a parameter, so delete_app_password keeps
  its "Invalid credentials" wording without unwrapping/rebuilding the helper's
  JSONResponse.
- Body parsing catches (ValueError, UnicodeDecodeError) instead of bare
  Exception, and guards body.get behind isinstance(body, dict) — no longer
  swallows RuntimeError/AttributeError or a non-object JSON body.
- Add a unit test asserting 500/503 -> 502.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 15:15:48 +02:00
Chris CoutinhoandClaude Opus 4.8 3c55d03ecf fix(api): block cross-user delete and address review feedback (#824)
Review of PR #825 surfaced an auth bypass introduced by adding loginName
support to delete_app_password: with the OCS-resolved UID discarded, a user
could authenticate as their own loginName (via the request body) while
targeting another user's path and delete the victim's stored app password.
Add the same UID-mismatch guard provisioning already has, so the
authenticated account must own the path UID (403 otherwise).

Also:
- integration test: build the BasicAuth header via base64 instead of
  httpx.BasicAuth._auth_header (private attribute); mark the throwaway test
  credential NOSONAR(S2068).
- unit tests: cover the httpx.RequestError -> 502 branch, the standard OCS v2
  success shape (meta.statuscode 200), and the cross-user delete 403 guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 13:04:42 +02:00
Chris CoutinhoandClaude Opus 4.8 a11a4e709c fix(api): return 401 not 500 on failed app-password OCS validation (#824)
provision_app_password validated credentials against OCS v1
(/ocs/v1.php/cloud/user), which always returns HTTP 200 — even on auth
failure, where the real status lives in ocs.meta.statuscode (997) and
ocs.data comes back as an empty list []. The status_code != 200 guard
therefore never fired, execution fell through to [].get("id"), and the
resulting AttributeError escaped as an unhandled 500. This blocked
background vector indexing for any user whose supplied loginName didn't
resolve (e.g. display name "Admin" vs loginName "admin").

Extract a shared _validate_nextcloud_credentials helper that:
- queries OCS v2 (/ocs/v2.php), which maps the OCS status onto the HTTP
  status, so a failed credential is a real 401;
- parses the payload defensively (isinstance guards) so a non-dict
  ocs.data can never raise;
- returns a clean 502 for an unreachable Nextcloud or a non-JSON body.

delete_app_password shared the same v1.php dead-guard bug, which made its
credential check a no-op (any valid-format password passed) — an auth
bypass on deletion. Route it through the same helper and accept the
loginName from the request body (mirroring provisioning) so OIDC users
whose UID differs from their loginName are not regressed.

Adds unit regression tests for the OCS failure payload, non-dict data,
and non-JSON response, plus a login-flow integration test that provisions
with capitalized ("Admin") and spaced ("Test User") loginNames and asserts
a 401 rather than a 500.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 12:56:44 +02:00
Chris Coutinho d84a95842f feat(embedding): gateway provider discovers dimension via GET /v1/models
External-mode tenant pods CrashLoop at startup: Qdrant collection init calls
get_dimension() before any embed(), but GatewayProvider only learns its
dimension lazily after the first embed, and the gateway model isn't an OpenAI
model so the base class can't know it statically.

- Add GatewayProvider._detect_dimension() — the async startup hook the
  vector-sync bootstrap already invokes (vector/qdrant_client.py:
  hasattr(provider, "_detect_dimension")) for Ollama. It GETs the gateway's
  GET /v1/models and sets _dimension from the entry whose id matches the
  configured model. Best-effort: any failure (old gateway, model absent,
  network) leaves _dimension unset so the inherited lazy detect-on-first-embed
  still applies — never fatal. Presents the M2M bearer when configured.
- Switch the default embedding_gateway_model to the gateway's provider-
  namespaced id "mistral/mistral-embed" (the gateway routes on the "/"-prefix
  and sends "mistral-embed" upstream); collapse a duplicated config field.

Pairs with astrolabe-cloud-website#229 (gateway /v1/models, namespaced ids).

Tests: discovery sets dim w/o embed, sends bearer, non-fatal on
404/absent/error, skips when already known.
2026-05-31 23:43:19 +02:00
Chris CoutinhoandClaude Opus 4.8 cd2145df09 fix(vector): make NATS status subscriber resilient at startup
Follow-up to PR #814 review.

NatsStatusSubscriber.run() called task_status.started() *after* the fallible
pull_subscribe, so a NATS broker that wasn't ready when the MCP server started
would crash the lifespan instead of retrying. Bus status is a non-critical
observability path, so:

- signal started() before the first subscribe (semantics: "loop is running",
  not "subscription succeeded");
- retry a failed subscribe with backoff instead of propagating;
- on a real fetch error (not an idle timeout) drop the subscription and
  re-subscribe rather than fetching against a possibly-dead handle.

Also anchor the _content_hash etag-threading TODO to the PR #814 review thread
so it is discoverable outside git blame.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:56:12 +02:00
Chris CoutinhoandClaude Opus 4.8 874c7c109b fix: stop S7632 flagging NOSONAR mentioned in prose comments
SonarCloud's python:S7632 parses the literal ``# NOSONAR`` token wherever it
appears — including inside explanatory comments that *quote* the directive —
and treats the following text as a malformed suppression. The actual bare
``# NOSONAR`` suppression lines are fine; the flagged lines were the prose
comments describing them. Reword those comments to drop the inner ``#`` so the
analyzer no longer sees a directive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:38:14 +02:00
Chris CoutinhoandClaude Opus 4.8 52da297ded fix(auth): authenticate stored app passwords with loginName, not UID
Nextcloud authenticates app passwords against the *loginName*, which differs
from the UID for OIDC-provisioned users (e.g. user_oidc makes the UID the
display name: UID "Ada Lovelace", loginName "ada@example.com"). The runtime
consumers of stored app passwords bound the UID as the BasicAuth username, so
every Notes/Files/Shares/CalDAV call returned HTTP 401.

PR #818 fixed only the provisioning endpoint; the consuming paths were missed.
Observed on a login_flow tenant (NC's own OIDC app as IdP): the background-sync
scan loop never started ("Credential validation failed ... HTTP 401") and
semantic search returned 0 results because the ACL shared_with_me lookup 401'd
and degraded to a self-only owner filter.

Root cause: NextcloudClient / CalendarClient conflated two identities — the
DAV/URL path identity (the user_id the whole system keys on = NC UID) and the
auth-credential username (the loginName). Decouple them:

- Thread a keyword-only auth_username through NextcloudClient -> CalendarClient
  (defaults to username, so single-user / OAuth where UID == loginName is
  unchanged).
- get_user_client_basic_auth (background sync + the /api/v1/vector-viz/search
  endpoint) authenticates as the stored loginName, UID for paths.
- _get_client_from_login_flow (the get_client(ctx) MCP-tool path) does the same.
- cleanup_invalid_app_passwords validates with the loginName, so it no longer
  401s and wrongly deletes a valid OIDC user's password.

The loginName is already persisted in app_passwords.username and returned by
get_app_password_with_scopes. Adds unit tests covering the UID != loginName
split for both client builders, the calendar credential/path split, and the
cleanup validation. Also genericises the example user in the #818 comment/test
(real name/email -> Ada Lovelace / ada@example.com).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:34:05 +02:00
Chris Coutinho 1528a1248d Merge remote-tracking branch 'origin/master' into feat/decomp-hook-points
# Conflicts:
#	nextcloud_mcp_server/vector/scanner.py
2026-05-31 20:10:37 +02:00
Chris CoutinhoandClaude Opus 4.8 ccfe179a38 fix: well-form NOSONAR suppressions (SonarCloud S7632/S7503)
The new decomposition modules used `# NOSONAR: reason` (colon form), which
SonarCloud flags as a malformed suppression comment (python:S7632) and which
fails to suppress the intended issue. Switch to the repo's bare `# NOSONAR`
convention with the rationale in a comment above, matching config.py and
auth/storage.py. This also lets the suppression silence python:S7503 (async
method without await) on the protocol-required no-op aclose stubs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:04:44 +02:00
Chris CoutinhoandClaude Opus 4.8 2d845cb70f fix: address PR #814 reviewer follow-ups
- gateway_client: guard token cache with a lazy anyio.Lock so concurrent
  embed calls share one M2M token request instead of racing
- status subscriber: distinguish idle fetch timeouts from real broker
  errors (log + 5s backoff) instead of swallowing all and spinning
- nats: warn when the bus URL uses unencrypted transport (non-tls://)
- collection_metadata: accept an optional shared httpx client, make TLS
  verify explicit, document the unauthenticated control-plane contract
- replace python -O-stripped asserts with explicit ValueError in the bus
  status builder and the api metadata source
- document why the nil-UUID sentinel point can't collide with content ids

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:42:09 +02:00
Chris CoutinhoandClaude Opus 4.8 fdc0bdeae4 test(astrolabe): install app from Nextcloud app store; bump submodule to v0.16.6
Stop mounting the vendored astrolabe submodule into the Nextcloud `app`
container by default (comment out the /opt/apps/astrolabe bind mount). With
the mount absent, the post-installation hook (20-install-astrolabe-app.sh)
falls through to `occ app:install astrolabe` + `app:enable`, so the dev/CI
stack now exercises the published app-store package rather than a locally
built dev copy. This catches packaging issues (e.g. missing built assets in
the released app) that a source build would mask.

Bump the third_party/astrolabe submodule to v0.16.6, which includes the
background-indexing re-login fix (astrolabe#93).

The dev mount and the CI "Build Astrolabe app" step are retained (commented
mount can be re-enabled locally) so developers can still iterate against the
vendored source on demand.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 12:04:54 +02:00
Chris CoutinhoandClaude Opus 4.8 7f40f7fdb3 fix(vector-sync): isolate per-app scans so a disabled Notes app can't abort sync
The Notes scan in scan_user_documents ran inline without a try/except, while
files/news/deck each had their own guard. On instances without the Notes app
installed, notes.get_all_notes() raises HTTPStatusError 404, which propagated
out of scan_user_documents and aborted the entire per-user vector sync before
files/news/deck were ever reached -- yielding "0 documents indexed" and, after
5 consecutive errors, stopping the scanner.

Extract the Notes scan into scan_notes() (mirroring scan_news_items /
scan_deck_cards) and wrap the call in a per-app try/except. A 404 (app not
installed/disabled) is now logged at info and skipped; other apps still scan.
Deletion-tracking runs only after a successful Notes fetch, so a failed fetch
can never mass-delete a user's indexed notes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 14:34:25 +02:00
Chris CoutinhoandClaude Opus 4.8 0df3fb4a19 fix(api): validate app password against Nextcloud using loginName, not UID
provision_app_password validated the supplied app password by calling the
OCS cloud/user endpoint with BasicAuth as the *path user_id* (the UID).
Nextcloud keys app-password BasicAuth on the loginName, which differs from
the UID for OIDC-provisioned accounts whose UID is their display name
(UID "Chris Coutinho", loginName "chris@coutinho.io"). Authenticating as
the UID is rejected with HTTP 401 ("App password validation failed"), so
provisioning never completes.

Parse the request body up front and authenticate the OCS validation as the
body's `username` (the Nextcloud loginName), falling back to the path
user_id for legacy callers where UID == loginName. The OCS-returned account
id is still checked against the path user_id (the UID), and the password is
still stored keyed by UID with the loginName alongside.

Note this is not an encoding issue: BasicAuth places the user-id literally
in the header (RFC 7617, no URL-encoding); %20/+/literal-space forms of the
UID all fail — only the loginName authenticates.

Adds a regression test asserting the OCS BasicAuth uses the loginName while
storage is keyed by the UID, plus a backward-compat assertion that callers
without a loginName fall back to the UID.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 11:50:19 +02:00
Chris CoutinhoandClaude Opus 4.8 b5ed1e3b4d fix: address PR #814 review + SonarCloud gate
SonarCloud:
- Resolve 6 S5332 hotspots (http→https in test fixture URLs).
- S6418: hoist the unauthenticated AsyncOpenAI placeholder to a named constant
  + NOSONAR (genuine non-secret; gateway ignores it when unauthenticated).
- Fix two reliability bugs: None-index guard in the gateway token-cache test
  (S2259) and float `> 0.0` instead of `!= 0.0` in the sentinel test (S1244).
- status.py idle path sleeps 0.1s instead of sleep(0) (S7491); NOSONAR on the
  protocol-required async no-await aclose() stubs (S7503).

Claude review:
- Remove three leftover debug print() calls in app.py (logger.info already
  covers them).
- payload_backfill: drop parsed_at from the backfilled-keys docstring (it is
  per-document state, not a deployment scalar); add a clean 404 precondition
  for BasicAuth deployments without an OAuth token verifier.
- status.py: task_status typed TaskStatus | None (drop type: ignore).
- nats.py: TODO to thread etags for file/deck/news; note etag default → None.
- factory: warn on unknown INGEST_BUS_URL scheme; raise ValueError instead of
  assert for the external-mode preconditions.
- docs/configuration.md: document the decomposition hook-point env vars + that
  nats-py ships core (lazy-imported) and external+bus uses two NATS connections.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:36:42 +02:00
Chris Coutinho a92f6260fb Merge remote-tracking branch 'origin/master' into feat/decomp-hook-points
# Conflicts:
#	nextcloud_mcp_server/vector/scanner.py
2026-05-29 18:31:05 +02:00
Chris CoutinhoandClaude Opus 4.8 29500bea7e fix: PR #813 review — cap unified_search multi-type pool; document deck self-only
🟡 Performance: unified_search's _execute sorted but did not cap the merged
multi-doc_type pool, so N doc_types each fetched at search_limit sent
N*search_limit candidates into verify-on-read (one Nextcloud round-trip each).
Cap to search_limit*2 after the sort, matching vector_search, nc_semantic_search
and the viz_routes pattern — bounding verification cost to O(2*search_limit)
regardless of how many doc_types are requested.

🟡 Consistency: _get_deck_metadata_from_qdrant is the one internal Qdrant lookup
that uses a raw user_id filter instead of build_ownership_filter. This is not a
bug — deck cards are a documented cross-user gap (the Deck API is per-user, so
cross-user context can't be fetched with the caller's credentials) — but the
inconsistency was unexplained. Added a comment documenting the deliberate
self-only scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 17:49:21 +02:00
Chris CoutinhoandClaude Opus 4.8 063bd06ba6 fix: cache app-password storage to avoid per-request Alembic upgrade race
get_user_client_basic_auth built a fresh RefreshTokenStorage and ran
storage.initialize() — a full Alembic `upgrade` in a worker thread — on every
call. Once the /api/v1 search endpoints (unified_search, vector_search) and the
chunk-context endpoint were wired to use it, concurrent requests ran concurrent
Alembic upgrades, which race on Alembic's non-thread-safe module-global
EnvironmentContext proxy and intermittently raise `KeyError: 'script'` →
HTTP 500 (seen on multi-user-basic/nc31; nc32 got lucky).

Cache one process-wide, already-initialized storage instance behind a lazily
created anyio.Lock so the one-time migration runs exactly once and never races.
Callers passing an explicit `storage` are unaffected. Also removes a redundant
per-request migration from the search hot path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 17:37:36 +02:00
Chris CoutinhoandClaude Opus 4.8 350358b802 fix: PR #813 review — shared-file context in MCP tool path + viz over-fetch cap
🟡 Important: nc_semantic_search's include_context branch did not forward
accessible_owners to get_chunk_with_context, so context expansion for shared
files stayed self-only, found nothing in Qdrant, and silently fell back to the
plain excerpt. Forward accessible_owners (the per-file file_accessible_by_id
gate still enforces access).

🟡 Performance: auth/viz_routes.py's multi-doc_type branch sorted but did not
cap the candidate pool before verify-on-read, so N doc_types × limit*2 went
into verification (N× the Nextcloud round-trips). Cap to limit*2 after the
sort, matching server/semantic.py and the cross-app branch.

Also clear the SonarCloud gate (new_duplicated_lines_density 5.1% > 3%) the
ACL wiring introduced: extract the duplicated /api/v1 client-resolution +
owner-expansion + verify-on-read block from unified_search/vector_search into a
shared _search_with_acl helper, define a constant for the repeated
"Nextcloud host not configured" literal (S1192), and reword the access_filter
move_to_end comment so it isn't misread as commented-out code (S125) while
adding the other-owner count to its debug log (review nits).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 17:16:23 +02:00
Chris CoutinhoandClaude Opus 4.8 8deb48e6fa fix: address PR #813 review round 4 (log leak, cross-user chunk ctx, algo, overlap)
1. Don't log unverified result titles: both search algorithms logged top-5
   titles at DEBUG before verify-on-read; with owner-level share expansion the
   unverified set can contain other users' docs. Algorithms now log a count
   only; the verifying callers (server/semantic, viz_routes, api/visualization)
   log verified titles after verify-on-read.

2. Cross-user FILE chunk context: get_chunk_with_context + the Qdrant chunk
   helpers now take accessible_owners and use build_ownership_filter. For files
   the expanded scope is honoured only after a per-file file_accessible_by_id
   check (accessible_owners is owner-level, so the gate prevents a one-file
   share recipient from reading any of the owner's cached chunks). note/deck/
   news stay self-only (per-user APIs) — a documented gap. Both chunk endpoints
   pass accessible_owners.

3. Algorithm usage: SemanticSearchAlgorithm is not dead (it backs the dense-only
   option on the viz/API surfaces); added a clarifying comment in server/
   semantic.py. Additionally wired accessible_owners + verify-on-read into the
   /api/v1 search routes (unified_search, vector_search) so the astrolabe
   surface is ACL-aware too — degrading gracefully to self-only/unverified for
   non-provisioned callers instead of 401.

4. Overlapping conditions: build_ownership_filter no longer lists self in the
   owner_id MatchAny branch (self is already covered by the user_id branch);
   the owner_id branch carries only the OTHER owners.

Tests: build_ownership_filter dedup + chunk-bbox filter-shape updates; new
ACL-aware get_indexed_doc_types, cached-chunk lookup, and end-to-end cross-user
file chunk-context (recipient gets the chunk, non-recipient denied) tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 16:55:00 +02:00
Chris CoutinhoandClaude Opus 4.8 ac041d5c97 chore: bump astrolabe submodule to v0.16.1 (PR #89 merged)
PR #89 (session-derived JWT auth) merged to astrolabe main and released as
v0.16.1; track the released tag instead of the feature-branch commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:57:37 +02:00
Chris CoutinhoandClaude Opus 4.8 423d0a1758 fix: address PR #813 latest review (ACL-aware doc-type discovery, robustness)
- get_indexed_doc_types: add optional accessible_owners param and reuse
  build_ownership_filter so cross-user doc-type discovery matches the real
  search scope (was self-only / ACL-blind); docstring documents the self-only
  default. Covered by test_get_indexed_doc_types_is_acl_aware.
- access_filter: build_ownership_filter now omits the owner_id branch entirely
  for an empty owner set instead of relying on undocumented MatchAny(any=[])
  semantics; updated the empty-list unit test accordingly.
- access_filter: make the uid_owner/owner share-owner extraction explicit
  ("absent, not empty") to avoid skipping on a falsy-but-present field.
- access_filter: add an operator note that pre-owner_id points need a re-index
  to surface to share recipients (ACL search is a no-op for legacy data).
- verification/webdav: lock the file_accessible_by_id(scope="") contract with a
  targeted multi-user test (owner + recipient True, non-recipient False).
- viz_routes: comment that verify-on-read eviction runs inline by design (no
  lifespan task group available on the Starlette route).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:57:34 +02:00
Chris CoutinhoandClaude Opus 4.8 531228d407 chore: bump astrolabe submodule (cleartext-transport warn, not block)
Fixes the multi-user-basic regression introduced by the previous bump: the
astrolabe app's hard https-refusal blocked sending the app password to the
in-cluster http MCP endpoint (http://mcp-multi-user-basic:8000), so background
indexing never ran. Now warns instead of blocking; also clears the SonarCloud
S5332 hotspot on the dropped test's http literal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 13:49:21 +02:00
Chris CoutinhoandClaude Opus 4.8 cafbfd15a9 fix: address PR #813 review (owner_id index, cache bound, explicit param)
- vector/qdrant_client.py: add owner_id to _PAYLOAD_INDEX_FIELDS (BLOCKING).
  Every search applies MatchAny(key="owner_id", ...); without a keyword index
  Qdrant full-scans the collection and may 400 on Qdrant Cloud strict mode.
  _ensure_payload_indexes is idempotent so existing collections migrate at
  startup.
- search/access_filter.py: bound the process-global _owners_cache with an LRU
  cap (was one unbounded entry per active user, never evicted); document the
  owner-level over-fetch limitation (a prolific sharer floods the recall
  buffer with ghost candidates that verify-on-read drops, with no second
  Qdrant pass) as a TODO toward per-file filtering.
- search/algorithms.py + semantic.py + bm25_hybrid.py: promote
  accessible_owners from **kwargs to an explicit keyword-only parameter on the
  SearchAlgorithm ABC and both implementations, so a misspelled keyword is a
  type error rather than a silent fall back to self-only scope.
- search/verification.py: document that _verify_files now verifies by global
  file id (WebDAV SEARCH), not by path.
- tests/unit/search/test_access_filter.py: add cache-hit, TTL-expiry,
  failure-not-cached, and LRU-bound tests.

Bumps the astrolabe submodule with the matching #89 review fixes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 13:28:45 +02:00
Chris CoutinhoandClaude Opus 4.8 d883052fb8 feat: add opt-in MCP decomposition hook points (design §10)
Adds the seven §10.2 hook-point modules + five env vars so Astrolabe Cloud can
offload document processing to the external document-processor / embedding
gateway. Purely additive: with every setting unset the server behaves exactly
as today, so self-hosters are unaffected (Deck #92).

Hook points (all default to current monolith behavior):
- config: EMBEDDING_PROVIDER, INGEST_MODE, STATUS_BACKEND,
  COLLECTION_METADATA_SOURCE, FACT_EVENT_EMITTER (+ supporting settings),
  validated in Settings.__post_init__ (fail-fast STATUS_BACKEND=local with
  INGEST_MODE=external); shared canonical.py.
- vector/payload_keys.py + acl_hash.py: cross-impl NAMESPACE/point_id (§2.2)
  and BLAKE2b-128 ACL hash (§11), pinned by fixtures shared with the
  document-processor repo.
- embedding/gateway_client.py: OpenAI-compatible GatewayProvider authenticating
  via M2M OIDC client-credentials (separate realm); manual-only registry entry.
- vector/collection_metadata.py: sentinel-point / API metadata source with env
  fallback.
- vector/queue/: hexagonal ingest producer ports + memory/NATS adapters
  (Postgres seam); INGEST_MODE=external publishes mcp.ingest.requested.{tenant}
  instead of the in-memory stream and skips the in-process processor pool. The
  lifespan becomes a composition root across both deployment branches.
- vector/queue/status.py: STATUS_BACKEND=bus subscriber feeding a StatusStore
  the vector-sync status endpoint reads.
- admin/payload_backfill.py: POST /api/v1/admin/payload-backfill (admin scope);
  processor writes the new payload keys; query-side ACL pre-filter gated behind
  ACL_PREFILTER_ENABLED (default off).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 13:13:25 +02:00
Chris CoutinhoandClaude Opus 4.8 8f0955cfc9 chore: bump astrolabe submodule (SonarCloud test-fixture rename)
Tracks astrolabe feat/session-derived-jwt-auth tip (86616d9). Test-only
change (rename a unit-test constant to clear SonarCloud S2068); no functional
or runtime difference from the previously-verified submodule commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 03:01:10 +02:00
Chris CoutinhoandClaude Opus 4.8 32a852ad90 test: fix multi-user-basic astrolabe app-password validation regression
Bumps the astrolabe submodule to validate the one-click background-sync app
password internally (IProvider::getToken) instead of via an HTTP loopback to
overwrite.cli.url, which is unreachable from inside the app container and broke
the multi-user-basic integration legs (enable_background_sync never succeeded).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 02:43:29 +02:00
Chris CoutinhoandClaude Opus 4.8 c92d866615 chore: bump astrolabe submodule (SonarCloud reduce fix)
No MCP-server runtime change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 02:19:01 +02:00
Chris CoutinhoandClaude Opus 4.8 92ee14b943 chore: bump astrolabe submodule (PR #89 review fixes)
Aligns PR #813's submodule pointer with astrolabe PR #89 tip (2fc6c7d):
review fixes (rawurlencode, php://input, cache key, app-password name, etc.)
plus Psalm/SonarCloud cleanup. No MCP-server runtime change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 02:15:56 +02:00
Chris CoutinhoandClaude Opus 4.8 b1fac2d7a8 fix(search): address PR #813 review (viz verify-on-read, owners cache, docs)
- viz_routes: run verify_search_results before returning results. After the
  accessible_owners expansion the viz can surface OTHER users' shared docs, so
  it must drop ones the caller can no longer access (revoked share) — same as
  the nc_semantic_search tool path. (Blocking review item.)
- access_filter: cache list_accessible_owners per user for 30s to keep the OCS
  shares round-trip off the search hot path (failures aren't cached); document
  the single-page OCS limitation; add a clear_accessible_owners_cache() test
  helper. Comment the empty-accessible_owners MatchAny([]) edge case.
- verification: comment why cross-user eviction is a deliberate no-op (eviction
  is scoped to the querying user's id, so a recipient's revoked access never
  deletes the owner's points; the recipient self-heals via accessible_owners).
- algorithms: declare SearchResult.original_score (set by the viz route) so the
  now-precisely-typed result list type-checks.
- tests: cross-user eviction-no-op safety test; autouse owners-cache reset in
  the access_filter + shared-search tests; replace async-no-await qdrant fakes
  with AsyncMock (clears SonarCloud S7503).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 02:15:44 +02:00
Chris CoutinhoandClaude Opus 4.8 6206f4a634 chore: bump astrolabe submodule (SonarCloud fixes)
Aligns PR #813's submodule pointer with astrolabe PR #89 tip (05176f9).
No MCP-server runtime change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 01:35:57 +02:00
Chris CoutinhoandClaude Opus 4.8 bbc4c5e81c chore: bump astrolabe submodule (Psalm fixes)
Aligns PR #813's submodule pointer with astrolabe PR #89 tip (091c68b),
which makes `composer run psalm` clean. No MCP-server runtime change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 01:06:19 +02:00
Chris CoutinhoandClaude Opus 4.8 ec667a1646 test: use tempfile.gettempdir() for all bg-sync debug screenshots
The remaining SonarCloud S5443 (publicly-writable directory) findings were the
hard-coded /tmp screenshot paths in revoke_background_sync_access, which became
"new code" once the surrounding function was edited. Replace every /tmp literal
in the file with tempfile.gettempdir() (which S5443 accepts), eliminating the
findings consistently rather than per-line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 00:22:18 +02:00
Chris CoutinhoandClaude Opus 4.8 86e906b142 test: avoid SonarCloud security rules instead of NOSONAR
SonarCloud Automatic Analysis does not honour # NOSONAR, so the S6418
(hard-coded token) and S5443 (publicly writable /tmp) findings in the new
tests persisted. Fix them by construction instead:
- test_login_flow: use a trivial poll-token value ("tok") in the rewrite test
  (it asserts the URLs, not the token) so it no longer looks like a secret.
- test_astrolabe bg-sync: build the debug screenshot path from
  tempfile.gettempdir() rather than a hard-coded /tmp literal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 00:19:30 +02:00
Chris CoutinhoandClaude Opus 4.8 1d730c99bc test: clear SonarCloud security false positives in new tests
The new-code quality gate flagged test-only mock fixtures as security issues
(new_security_rating E):
- S2068 "hard-coded password" ×2: drop the unused "app_password" value from the
  get_app_password_with_scopes mocks (the code under test only reads truthiness
  + "scopes").
- S6418 "hard-coded token": NOSONAR on the Login Flow v2 poll-token test fixture.
- S5443 "publicly writable directory": NOSONAR on the /tmp debug screenshot path
  (matches this file's existing convention).

No behaviour change; all are test fixtures, not real credentials.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 00:10:35 +02:00
Chris CoutinhoandClaude Opus 4.8 9ee95cb7a6 chore: bump astrolabe submodule (Psalm fix in revokeFromMcpServer)
No runtime change — keeps the PR #813 submodule pointer aligned with the
astrolabe PR #89 tip (587caa6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:50:29 +02:00
Chris CoutinhoandClaude Opus 4.8 29037ad642 test(auth): use https mock URLs in login_url rewrite test
SonarCloud flagged the http:// mock URLs in the new login_url-rewrite test as
clear-text-protocol hotspots, failing the new-code quality gate (they were new
+ unreviewed). They're harmless test fixtures; switch to https mock origins to
match this file's existing convention. The login_url rewrite is scheme-agnostic
so the test still exercises the same internal->public origin replacement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:47:14 +02:00
Chris CoutinhoandClaude Opus 4.8 d9a716080a fix(auth): make provision/revoke consistent with the app-password store
The OAuth provisioning tools (check_provisioning_status, revoke_nextcloud_
access) only consulted the refresh-token store + Astrolabe status, ignoring the
app_passwords store that Login Flow v2 (nc_auth_provision_access) and the
management API write to — the same store require_provisioning / get_client use
to grant tool access. Result: status reported "not provisioned" while tools
worked, and revoke said "nothing to revoke" while the credential persisted.

- _get_provisioning_status: also check storage.get_app_password_with_scopes,
  reporting is_provisioned with credential_type=app_password,
  flow_type=login_flow_v2.
- _revoke_nextcloud_access: when the credential is an app password, delete it
  from storage + invalidate the scope cache (no IdP token to revoke);
  refresh-token revocation via the Token Broker is unchanged.
- tests/unit/test_oauth_tools_app_password_provisioning.py: cover status +
  revoke for the app-password path.
- bump astrolabe submodule (deprovision MCP on disable); fix a stale assertion
  in the migrated bg-sync test (one-click flow has no separate app-password
  generation step).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:22:40 +02:00
Chris CoutinhoandClaude Opus 4.8 4ed228613e test(astrolabe): migrate suite to session-JWT auth model
Astrolabe was refactored to mint session-derived JWTs (TokenGenerationRequest
Event) and a one-click background-indexing opt-in, dropping the OAuth
authorize/callback/refresh surface. Bump the submodule and bring the test
suite in line:

- New test_astrolabe_session_jwt_search.py: a logged-in user searches via the
  minted JWT with no provisioning (replaces the obsolete login_flow_provisioning
  OAuth-authorize test; token_refresh test deleted — refresh flow is gone).
- settings_buttons: assert the new revoke endpoint + that oauth/disconnect is
  gone (404).
- multi_user_background_sync / plotly / chunk_context: drop the OAuth authorize
  step; provision via the one-click "Enable background indexing" button
  (#mcp-enable-background-button -> #mcp-revoke-background-button) instead of
  generating + pasting an app password.
- docker-compose.yml: mount the astrolabe submodule into the app container.
- third_party/astrolabe: bump to the one-click opt-in commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:06:27 +02:00
Chris CoutinhoandClaude Opus 4.8 ae54956f27 fix(auth): login-flow provisioning — public login_url + session app passwords
Two fixes surfaced while testing Login Flow v2 provisioning behind a split
internal/external host (Docker: server↔Nextcloud over http://app, browser
over http://localhost:8080):

1. login_url pointed at the internal host. Nextcloud builds the login URL
   from the request host, so the browser-facing URL came back as
   http://app/login/v2/flow/... — unreachable from the user's browser. The
   poll endpoint was already rewritten to the internal host (correct, the
   server polls it); now LoginFlowV2Client also rewrites the login_url origin
   to settings.nextcloud_public_issuer_url when set (passed at all 5
   construction sites). When unset, behaviour is unchanged.

2. The app-password format guard rejected raw session tokens. core/
   getapppassword returns a long alphanumeric token, not the dashed 25-char
   Security-settings format, so the dashed-only regex 400'd the one-click
   opt-in handoff. Relax APP_PASSWORD_PATTERN to `^[a-zA-Z0-9-]{20,256}$`;
   the authoritative validation is still the BasicAuth check against Nextcloud.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:06:11 +02:00
Chris CoutinhoandClaude Opus 4.8 bf35200bab fix(search): verify shared files by global file id (ACL-aware)
The ACL-aware vector filter (PR #813) expands a user's search to documents
whose owner shared them, but verify-on-read still re-checked each file by
PATH under the *searching* user's WebDAV root. Nextcloud mounts received
shares at the recipient's root by basename, so a nested shared file (e.g.
owner's /docs/report.pdf) 404s for the recipient and was silently dropped —
defeating the filter for everything but root-level files.

Verify files by their global Nextcloud file id instead (the file doc_id IS
that id): WebDAVClient.get_file_info_by_id was insufficient (the dav/meta
endpoint only resolves the user's own storage, not shares), so add
WebDAVClient.file_accessible_by_id which runs a WebDAV SEARCH over the user's
whole tree (incl. mounted shares) filtered on oc:fileid. Empirically this
resolves owned, directly-shared, and folder-shared files; an empty result is
a definitive drop, transport errors are kept as transient.

- search/verification.py: _verify_files now checks file_accessible_by_id.
- client/webdav.py: add file_accessible_by_id (SEARCH by fileid).
- tests/integration/test_acl_owner_filter.py: filter matrix vs real Qdrant.
- tests/integration/test_acl_shared_search.py: real-Nextcloud share -> search.
- tests/integration/test_verify_on_read.py: nested shared file kept for the
  recipient; unshared file dropped.
- tests/unit/search/test_verification.py: id-based verifier semantics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:05:40 +02:00
Chris Coutinho dc0653c415 ci: Bump astrolabe 2026-05-28 21:18:22 +02:00
Chris Coutinho a26971e9b1 ci: Update astrolabe submodule 2026-05-28 20:32:15 +02:00
Chris CoutinhoandClaude Opus 4.7 37db82613d feat(search): ACL-aware vector filter via Nextcloud Shares lookup
The vector index has always been strictly per-user: every Qdrant payload
carries a `user_id` and the search filter is `user_id == querying_user`.
A file Alice indexed cannot be discovered by Bob even if she has shared
it with him — Bob would have to re-index it under his own user_id to
make it searchable, which means duplicate index entries for every share
recipient.

Switch to ownership-with-ACL-expansion:

- New `nextcloud_mcp_server.search.access_filter` module:
  - `list_accessible_owners(sharing_client, user_id)` calls the OCS
    Sharing API (`shared_with_me=true`) and returns
    `{user_id} ∪ {uid_owner of each share}`. Fails open to `[user_id]`
    so a misbehaving Sharing API doesn't black-hole search.
  - `build_ownership_filter(user_id, accessible_owners)` returns a
    Qdrant `Filter` whose `should` branch matches either the new
    `owner_id IN accessible_owners` field or the legacy `user_id` field.
    The legacy branch keeps points indexed before this change reachable
    without a migration backfill.
- Indexer payload (`vector/processor.py`) now writes `owner_id` alongside
  `user_id`. `DocumentTask` gains an optional `owner_id` field; today the
  scanner always runs as the owner so the processor falls back to
  `user_id`, but the field is plumbed so a future shared-with-me crawler
  can set the true owner without reshaping the payload contract.
- `SemanticSearchAlgorithm.search` and `BM25HybridSearchAlgorithm.search`
  accept `accessible_owners` via kwargs and use the new ownership filter.
  Default behaviour with no kwarg is unchanged (self-only).
- Both user-facing callers — the MCP tool path (`server/semantic.py`) and
  the visualization Starlette route (`auth/viz_routes.py`) — compute
  `accessible_owners` from the authenticated Nextcloud client before
  invoking the search algorithm. Eviction, scanner deletion, placeholder,
  and chunk-context paths intentionally keep the legacy `user_id`
  semantics (those are "operations on a specific user's records", not
  cross-user reads).
- 10 new unit tests in `tests/unit/search/test_access_filter.py` cover
  self-only default, owner expansion, dedup, fallback fields, OCS
  failure, and the legacy `should`-branch shape.

Pairs with cbcoutinho/astrolabe#89 — together they let an Astrolabe user
find content owners have shared with them without going through any
re-authorization flow or re-indexing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:48:34 +02:00
Chris Coutinho e2ad8220d5 Merge branch 'master' of github.com:cbcoutinho/nextcloud-mcp-server 2026-05-24 12:43:22 +02:00
Chris Coutinho 904c7938c3 chore: bump astrolabe 2026-05-24 12:43:04 +02:00
Chris CoutinhoandClaude Opus 4.7 c03223a41c fix(vector-sync): use resolved collection name in orphan sweep
The startup sweep was reading settings.qdrant_collection (the raw config
value, default "nextcloud_content") instead of settings.get_collection_name(),
which is what every other vector-sync operation uses. When QDRANT_COLLECTION
is not overridden, get_collection_name() auto-generates a
{deployment-id}-{model-name} name; the sweep was targeting a non-existent
collection and silently returning (0, 0).

Also adds the AsyncQdrantClient type annotation that was missing on
sweep_orphan_placeholders, and renames its parameter from collection_name
to collection to make it clear the value must be the resolved name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:29:48 +02:00
Chris CoutinhoandClaude Opus 4.7 a5cbe91b29 fix(vector-sync): sweep placeholder orphans at Pod startup (#101)
When the per-tenant nextcloud-mcp-server Pod OOMKills mid-batch, the
in-memory anyio processor queue is lost but the placeholder Qdrant
points (is_placeholder=true, status=pending) survive. The next Pod's
scanner re-runs, sees the existing placeholders, applies the
5 × VECTOR_SYNC_SCAN_INTERVAL staleness gate (~5h with the deployed
1h scan interval), and skips them. Result: 0 documents indexed for
the duration of the gate after every restart.

Stamps a process-level instance_id (UUID per Pod-process) onto every
placeholder write. A new sweep_orphan_placeholders helper, called
once from starlette_lifespan after the Qdrant client is initialised
and before the scanner / user-manager spawns, scrolls the collection
and deletes any placeholder whose instance_id doesn't match the
current Pod's (including placeholders with no instance_id field —
back-compat for pre-fix Pod versions). The scanner's next cycle
naturally re-creates fresh placeholders and queues work normally;
no DocumentTask reconstruction needed.

Sweep is one-shot at startup, not periodic — the existing staleness
gate still covers same-Pod recovery, and the cross-Pod-restart gap
was the only failure mode. Failure is non-fatal (logged via
vector_sync.orphan_sweep_failed) so a transient Qdrant hiccup at
boot doesn't prevent the scanner from running.

Both lifespan branches (single-user BasicAuth, OAuth / multi-user
BasicAuth) call the sweep via a module-local helper. A new
VECTOR_SYNC_ORPHAN_SWEEP_ENABLED setting (default True) provides
an escape hatch.

Closes Deck #101.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 21:03:23 +02:00
Chris CoutinhoandClaude Opus 4.7 688a03f00a fix(contacts): surface ORG/TITLE/NOTE/URL/CATEGORIES/PHOTO on read (refs #716)
PR #719 fixed the contact-create path so all documented fields persist to the
vCard, but the read path (list/search via MCP) still returned ``organization:
null`` / ``note: null`` / ``title: null`` because pythonvCard4 has no typed
parser for ORG/TITLE — they land in ``Contact.custom`` — and the server-side
mapper never read ``note``/``urls``/``categories``/``photo`` even when present.

Reads now surface what the write side persisted:

- ``client/contacts.py``: new ``_first_custom`` helper pulls raw values from
  ``Contact.custom`` for ORG/TITLE/unencoded PHOTO. ``list_contacts``
  extends its per-contact dict with org/title/note/url/categories/photo.
- ``server/contacts.py``: ``_raw_contact_to_model`` maps the new keys onto
  ``Contact.organization`` / ``.title`` / ``.note`` / ``.urls`` / ``.categories``
  / ``.photo``. URL accepts both list and plain-string shapes; categories
  accepts comma-separated strings for forward-compat.

Coverage:

- Unit: ``TestFirstCustom`` (five cases incl. bare-string library shape) and
  three new ``_raw_contact_to_model`` cases covering the full field set,
  plain-string URL, and comma-string categories.
- Integration: ``test_mcp_contacts_workflow`` now decodes the
  ``nc_contacts_search_contacts`` response and asserts
  ``organization`` / ``note`` round-trip — direct regression coverage for
  elvisdragonmao's report on issue #716.

Verified end-to-end against the local single-user docker stack: creating a
contact with ``{organization, title, note, url, categories}`` and reading it
back via ``nc_contacts_search_contacts`` returns every field populated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 10:07:47 +02:00
Chris CoutinhoandClaude Opus 4.7 7da72a3888 feat(api): log inbound User-Agent on management API and webhook receiver
Astrolabe (and any other PHP-side client) sends a stable User-Agent on
every outbound call to the MCP server. Capture it at the middleware
layer so backend access logs can attribute each request to a specific
client build — e.g. ``Nextcloud-Astrolabe/0.14.1``.

The middleware fires only for /api/v1/* and /webhooks/nextcloud,
which is the surface PHP-side clients hit; /mcp and /health stay
silent. The structured ``extra`` ({user_agent, http_method, http_path})
flows into OTel spans so the field is queryable in Grafana / Loki.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:04:14 +02:00
Chris CoutinhoandClaude Opus 4.7 fdcbd7bd3f feat(webhooks): add Deck card sync preset with vector indexing
Nextcloud Deck PR #7910 added IWebhookCompatibleEvent to CardCreated/
Updated/DeletedEvent and BoardUpdatedEvent, so Deck can finally emit
real-time webhooks via core's webhook_listeners app. Wire this into
the existing preset → parser → DocumentTask pipeline that already
backs Notes / Calendar / Tables / Forms / Files sync.

- Add deck_sync preset (app=deck, 4 events) and drop the stale
  "Deck does not support webhooks" comment.
- Teach webhook_parser to convert Deck card events into
  DocumentTask(doc_type=deck_card, operation=index|delete) with
  stack_id metadata. BoardUpdatedEvent logs delivery at INFO and
  returns None — the polling scanner reconciles affected cards.
- Cover three new unit tests for the deck create/delete/board-update
  paths plus symmetric fail-open tests for missing card.id /
  node.id in _parse_deck_event and _parse_file_event.

The astrolabe admin UI auto-discovers the new preset via
filter_presets_by_installed_apps(); no astrolabe-side wiring is
required for it to appear in the Webhook Management card grid.

Note: requires Deck ≥1.18.x (where PR #7910 lands); the preset is
hidden when the Deck app isn't installed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 09:03:59 +02:00
Chris CoutinhoandClaude Opus 4.7 a0b29a436e feat(ci): build arm64 Docker images natively on ubuntu-24.04-arm
Split the Docker image build into a per-platform matrix and merge job,
producing a single multi-arch manifest (linux/amd64 + linux/arm64) without
QEMU emulation. The arm64 build runs on the native ubuntu-24.04-arm runner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:06:36 +02:00
Chris CoutinhoandClaude Opus 4.7 e0b7afb4b1 fix(embedding): instantiate BM25 singleton off the event loop
The ``BM25SparseEmbeddingProvider.__init__`` calls
``fastembed.SparseTextEmbedding(model_name="Qdrant/bm25")`` which
downloads ~50 MB of model weights from HuggingFace and loads them
into memory — observed >5 s wall-clock in production. The inference
methods (``encode_async``, ``encode_batch_async``) already wrap work
in ``anyio.to_thread.run_sync``, so the design intent is clearly to
keep FastEmbed off the event loop. That protection just didn't
cover the constructor.

Symptom in the Astrolabe Cloud per-tenant deploy (deck #102 smoke):
~30–90 s after a user enables semantic search, the pod tips into a
SIGKILL-restart cycle. Loki shows a single log line

  Initializing BM25 sparse embedding provider: Qdrant/bm25

followed by nothing else from the event loop until exitCode 137.
Kubernetes ``/health/live`` httpGet probe timeout=5s fires 6 times
in a row, kubelet kills the container, restart, repeat.

Fix: switch ``get_bm25_service()`` to an async accessor that wraps
the first-time construction in ``anyio.to_thread.run_sync``. Two
existing call sites (``vector/processor.py:603``,
``search/bm25_hybrid.py:123``) update to ``await``. Both are
already inside async functions so the await is free.

New unit test pins the invariant by monkey-patching
``BM25SparseEmbeddingProvider.__init__`` with ``time.sleep(1)`` and
asserting a concurrent ``anyio.sleep(0.05)`` finishes promptly —
the test fails if the constructor ever runs back on the event loop.

Same pattern exists in ``OllamaEmbeddingProvider.__init__`` (sync
``httpx.get`` health-check). Ollama isn't enabled in any current
deploy; filed as a follow-up.

Refs:
- Astrolabe Cloud deck card #102 (smoke discovery)
- Sibling fix #799 (NullPool for cross-loop-asyncpg, same class
  of "anyio bites you in production" bug)

Verified:
- ``uv run pytest tests/unit/`` — 1027 passed
- ``uv run ruff check`` clean on touched files
- ``uv run ty check`` clean on touched files
- New tests pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:33:24 +02:00
Chris CoutinhoandClaude Opus 4.7 e98903c502 fix(storage): address review on PR #799 (stale comments, docs deprecation, unit test)
claude-review on #799 flagged:

1. Stale inline comment in ``initialize()`` (line 466) still said
   "Postgres uses a small bounded pool". Updated to reflect both
   backends now use NullPool.

2. Stale ``close()`` docstring referenced pool-size starving
   max_connections — irrelevant with NullPool. Replaced with the
   NullPool-aware rationale (dispose still tears down in-flight
   asyncpg connections cleanly).

3. ``docs/configuration.md`` actively directed operators to tune
   DATABASE_POOL_SIZE / DATABASE_MAX_OVERFLOW, with worked
   examples and pool math. Both are now deprecated no-ops; the
   table entries explain the deprecation and link to PR #799.
   Operators reading the docs will no longer be confused into
   tuning settings that don't do anything.

4. ``config.py`` comment for the deprecated fields updated to
   record the deprecation. Validators are intentionally kept
   (still reject < 1 / < 0) so misconfigured deploys fail loudly
   rather than silently — the reviewer flagged this as a minor
   UX wart but explicitly "not a blocker"; the docs change in (3)
   keeps operators away from the config altogether.

5. New ``tests/unit/test_storage_engine.py`` with three tests:
   - ``test_postgres_engine_uses_nullpool`` — pins ``isinstance(
     engine.pool, NullPool)`` so a refactor back to QueuePool /
     SingletonThreadPool can't silently re-introduce the cross-
     event-loop crashes.
   - ``test_postgres_engine_ignores_pool_sizing_settings`` —
     setting DATABASE_POOL_SIZE / DATABASE_MAX_OVERFLOW to huge
     values must not change pool type (proves the deprecated
     fields are wired-up no-ops).
   - ``test_postgres_engine_missing_asyncpg_driver_message`` —
     guards the existing actionable-error branch when the
     optional ``[postgres]`` extra isn't installed.

Verified:
- ``uv run pytest tests/unit/`` — 1028 passed
- ``uv run ruff check`` clean on the touched python files

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:45:14 +02:00
Chris CoutinhoandClaude Opus 4.7 8cd3092e87 fix(storage): use NullPool for Postgres engine (cross-loop crashes under anyio TaskGroup)
The Postgres backend hit a hard crashloop in production deployments
where the MCP server runs under anyio TaskGroups with multiple
concurrent background tasks (`vector.oauth_sync.user_manager_task`,
processors, etc.) alongside the request-path code. Symptom in the
pod logs:

  RuntimeError: Task <Task pending name='nextcloud_mcp_server.vector.oauth_sync.user_manager_task'>
    got Future <Future pending cb=[BaseProtocol._on_waiter_completed()]>
    attached to a different loop

followed seconds later by

  RuntimeError: Event loop is closed

while SQLAlchemy's pool tries to clean up the failed connection.
The event loop becomes increasingly unresponsive as asyncpg
protocol Futures pile up holding references to closed loops; the
`/health/live` endpoint eventually misses its probe window and
the kubelet SIGKILLs the pod (exitCode 137), restart-looping the
backend.

Root cause: the engine was built with the default
`AsyncAdaptedQueuePool` (`pool_size=2, max_overflow=5`) and
`pool_pre_ping=True`. asyncpg connection objects are bound to the
event loop they were created on. When the process holds a
singleton engine and tasks running under different anyio
TaskGroups check out connections from that pool, the pre-ping
probe runs on a cached connection whose underlying transport
references a different loop's selector → cross-loop access →
crash.

Switch to `NullPool` — one fresh asyncpg connection per
`engine.connect()`, no caching, no cross-loop bookkeeping to get
wrong. asyncpg connection setup is ~5 ms over LAN and a single
round-trip in the local-Postgres case, so the throughput cost is
negligible for the MCP server's traffic shape (low concurrency,
bursty per-user requests). This matches what the SQLite branch
already does (see `initialize()`) and what Alembic's `env.py`
uses for migrations, so the codebase is now consistent across
all backends.

`DATABASE_POOL_SIZE` / `DATABASE_MAX_OVERFLOW` config knobs are
preserved for backward compatibility but no longer affect the
Postgres engine. The validators in `config.py` continue to
reject values < 1 / < 0, so misconfigured deploys still fail
loudly. A follow-up could mark them deprecated in
`docs/configuration.md`; out of scope here.

Discovered while smoke-testing the per-tenant Postgres flow in
Astrolabe Cloud (every-tenant pod fresh-provisions a database
via the ADR-026 backend → hits this crashloop within ~5 min of
the first MCP-routed request).

Refs:
- ADR-026 § "Concurrency model and pool sizing" (the original
  QueuePool rationale, now superseded by this finding)
- nextcloud_mcp_server/alembic/env.py (NullPool for migrations)
- SQLAlchemy docs: NullPool is the documented choice when
  connection objects don't survive across the lifecycle of the
  pool's logical "owner" (here: the event loop)

Verified:
- `uv run ruff check nextcloud_mcp_server/auth/storage.py` clean
- `uv run pytest tests/unit/test_*storage*.py` → 29 passed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:36:22 +02:00
Chris Coutinho 39d70cfdf5 chore: Update third_party/astrolabe 2026-05-17 10:53:55 +02:00
Chris CoutinhoandClaude Opus 4.7 d717c64750 fix(storage): address PR #798 round-4 review (NOSONAR syntax + pg_advisory_lock + engine dispose + nits)
Addresses all 8 items in the round-4 bot review plus 4 remaining
SonarQube OPEN issues that were silently broken by round 3's
malformed NOSONAR markers.

NOSONAR syntax fix (clears the remaining 4 OPEN SQ issues)
----------------------------------------------------------
Round 3 used ``# NOSONAR S<rule_key>`` form. SonarQube Python doesn't
recognize the rule-key suffix — it treats the whole thing as a
malformed suppression directive (S7632) AND lets the underlying rule
keep firing (S7503 on ``_Cursor.__aenter__/__aexit__``).

Switch every marker to bare ``# NOSONAR``, with the rationale moved
into a preceding comment block. Affected sites:
- storage.py: ``_Cursor.__aenter__``, ``_Cursor.__aexit__``
- config.py: ``get_database_ssl()`` ``return False`` + ``ssl.create_default_context()``
- test_storage_logging.py: ``SENTINEL_PASSWORD_FRAGMENT`` constant
- test_storage_postgres.py: three ``bob_pw_v1`` / ``bob_pw_v2`` / ``carol_pw`` literals

Bot 🔴#1 — defensive NOSONAR on get_database_ssl `return False`
--------------------------------------------------------------
Bot predicted S4830 fires on the operator-opt-out path. SQ output
shows it doesn't currently fire, but bare NOSONAR added defensively
with rationale comment.

Bot 🔴#2 — defensive NOSONAR on f-string SQL
--------------------------------------------
``update_oauth_session`` builds its SET clause via ``f"{', '.join(update_fields)}"``;
``get_audit_logs`` builds its WHERE clause via string concatenation.
Both are safe (the fragments only come from this function's own
branches, no user input), but the patterns trip taint analysers.
Annotated both with bare NOSONAR + safety comment explaining the
hardcoded-fragments invariant. Note: S2077 doesn't currently fire
on these; defensive.

Bot 🟡#3 — pg_advisory_lock for concurrent migrations
-----------------------------------------------------
Without coordination, two pods rolling-updating simultaneously can
both observe ``has_alembic=False`` and both try to apply migrations
from scratch — the second crashes with "relation already exists".

New ``_migration_lock()`` async context manager:
- On Postgres: ``SELECT pg_advisory_lock(:lock_id)`` on a fresh
  connection (separate from the engine pool so it survives the
  ``to_thread.run_sync`` worker), held across BOTH the schema-inspect
  AND the migration call. Without that span, two pods could each
  observe "no alembic_version" before either started migrating,
  defeating the lock.
- On SQLite: yields immediately (file-level locking serializes
  writes natively).

Lock ID derived from
``sha256(b"nextcloud-mcp-server:migrations")[:8]`` as a stable signed
int64 so we can't collide with other apps sharing the same Postgres.

Bot 🟡#4 — RefreshTokenStorage.close() + lifespan wiring
--------------------------------------------------------
New idempotent ``close()`` method calls ``await engine.dispose()``,
nulls the engine, resets ``_initialized``. Wired into both
``app_lifespan_basic`` (BasicAuth) and the OAuth lifespan teardown,
each wrapped in ``try/except Exception`` with ``logger.warning`` so a
buggy dispose can't block SIGTERM. Without this, pooled asyncpg
connections leak server-side slots until
``idle_in_transaction_session_timeout`` reaps them — with small pool
defaults and frequent k8s rolling restarts this can starve
``max_connections``.

Bot 🟢#5 — is_sqlite_url docstring on :memory:
----------------------------------------------
Updated docstring to note both file-backed and in-memory forms are
recognized; caller is responsible for ``:memory:`` magic.

Bot 🟢#6 — db_path via make_url(...).database
---------------------------------------------
Replaced ``database_url.split("///", 1)[1]`` hack with SQLAlchemy's
own URL parsing. Naturally handles in-memory (``.database is None``
→ falls back to ``""``). Same lazy-import pattern as the existing
``mask_db_password`` to avoid module-import-time cost.

Bot 🟢#7 — _to_sync_url unrecognized-driver guard
-------------------------------------------------
Pulled ``_KNOWN_ASYNC_DRIVERS = ("aiosqlite", "asyncpg")`` into a
module constant. When an unrecognized ``+<driver>`` token survives
the strip, emits ``logger.warning`` with the known-supported list.
Behavior unchanged for valid URLs.

Bot 🟢#8 — get_audit_logs SELECT * → explicit columns
-----------------------------------------------------
Replaced ``SELECT *`` with explicit column list. Future schema
additions stay out of the dict return.

New tests
---------
- ``test_close_disposes_engine``: pins the public contract — engine
  nulled, state reset, second call is a no-op.
- ``test_concurrent_initialize_serialized_by_advisory_lock``: spawns
  3 concurrent inits against a fresh schema; asserts no "relation
  already exists" and exactly one ``alembic_version`` row at the end.
  Without the lock, this reliably fails on the second concurrent
  task.

Docs
----
- ADR-026: new "Concurrent migrations across pods" subsection
  documents the advisory-lock approach + lock-ID derivation.

Verification
------------
- ``uv run pytest tests/unit/`` — 1025 passed.
- ``TEST_DATABASE_URL=… uv run pytest tests/integration/test_storage_postgres.py -m postgres`` — 9 passed (was 7).
- ``ruff check && ruff format --check && ty check`` — clean.

Expected post-push: SQ scan reports 0 OPEN issues (was 4).

Tracked on Astrolabe Cloud POC board, card #99.

---

_This PR was generated with the help of AI, and reviewed by a Human_

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 09:25:42 +02:00
Chris CoutinhoandClaude Opus 4.7 51419329b0 fix(storage): address PR #798 round-3 review (SonarQube + pool sizing + RETURNING test)
Round-3 fixes. Two threads:
- 9 OPEN SonarQube issues caused the "E Security Rating on New Code"
  gate failure. The bot's diagnosis (sa.text(text_sql) → SQL injection)
  was a wrong guess; the actual SQ rules firing were different.
- Bot's substantive concerns: pool defaults too aggressive,
  delete_browser_session RETURNING path untested on Postgres,
  schema_version legacy table created on Postgres, stale module
  docstring.
- User's underlying question on the pool: "isn't 1 connection enough?"
  Right-sized to 2+5 and documented the concurrency model in ADR-026
  so the rationale is durable.

SonarQube quality-gate fixes (clears all 9 OPEN issues)
-------------------------------------------------------
- BLOCKER S6418: rename `SECRET` constant in test_storage_logging.py
  to `SENTINEL_PASSWORD_FRAGMENT` + NOSONAR with rationale.
- CRITICAL S3776: extract `_build_postgres_engine()` from
  `initialize()` (was complexity 26 > 15); incidentally creates a
  clean unit-test seam for engine args.
- CRITICAL S4423: `ssl.create_default_context(cafile=...)` is flagged
  as "weak protocol" — Python 3.10+ already negotiates the strongest
  available protocol. Explicitly pass `purpose=ssl.Purpose.SERVER_AUTH`
  and NOSONAR with the Python-version rationale.
- MAJOR S3358: split the TLS-mode nested ternary in the engine
  factory into a `_describe_ssl_arg()` helper.
- MAJOR S2068 ×3: bind test app-password literals to local vars and
  put `# NOSONAR S2068` on the same line as the literal (anchoring
  requirement) instead of on the closing paren.
- MINOR S7503 ×2: `# NOSONAR S7503` on `_Cursor.__aenter__/__aexit__`
  — they MUST be `async` per the context-manager protocol.

Pool sizing right-sized (answers "why so many connections?")
------------------------------------------------------------
- `DATABASE_POOL_SIZE` default 10 → **2**.
- `DATABASE_MAX_OVERFLOW` default 20 → **5**.
- Per-pod max drops from 30 to 7. With 3 replicas, total = 21
  connections (was 90) — well under managed-Postgres
  `max_connections=100`.
- New INFO log at startup: `Postgres engine ready: pool_size=N
  max_overflow=M (per-pod max K connections)`. Surfaces the active
  sizing without grepping config.
- New ADR-026 § "Concurrency model and pool sizing" explains
  asyncpg's single-flight connection semantics, the MCP workload
  shape (read-mostly point lookups), why-not-1 (multi-user
  serialization), and the tune-up/tune-down recipe.
- `docs/configuration.md` table updated with new defaults +
  homelab-vs-prod tuning guidance, linking the ADR.

RETURNING path covered on Postgres
----------------------------------
- New `test_browser_session_delete_returning` exercises the
  `DELETE … RETURNING user_id` path — the only RETURNING clause in
  the storage layer and the most dialect-sensitive SQL in this PR.
  Asserts both present-row (returns True, row gone) and absent-row
  (returns False) branches.

Schema portability polish
-------------------------
- `alembic 001`: gate `schema_version` table creation on
  `op.get_bind().dialect.name == "sqlite"`. The table exists purely
  to match the fingerprint of pre-Alembic SQLite databases; fresh
  Postgres installs no longer carry the dead legacy table.

Misc polish
-----------
- Module docstring: "SQLite-based" → "SQL-backed", with a sentence
  on the DATABASE_URL opt-in and an ADR-026 link.
- Comment on `_wrap_row` noting `row._mapping` is the documented
  RowMapping accessor in SQLAlchemy 2.x despite the underscore.

Skipped (rationale in PR reply)
-------------------------------
- `_qmark_to_named` SQL-comment handling: docstring already notes
  the limitation; no `?` in storage SQL comments today.
- Module-level `anyio.Lock()`: established precedent confirmed by
  the bot itself.
- `get_audit_logs` `SELECT *`: pre-existing pattern, out of scope.

Verification
------------
- `uv run pytest tests/unit/` — 1025 passed.
- `TEST_DATABASE_URL=… uv run pytest tests/integration/test_storage_postgres.py -m postgres` — 7 passed.
- `ruff check && ruff format --check && ty check` — clean.
- Confirmed `schema_version` absent on fresh Postgres, still present
  on fresh SQLite.

Tracked on Astrolabe Cloud POC board, card #99.

---

_This PR was generated with the help of AI, and reviewed by a Human_

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:33:23 +02:00
Chris CoutinhoandClaude Opus 4.7 f2b7bf132f fix(storage): address PR #798 review feedback (credentials, asyncpg extra, TLS, pool)
Round-2 fixes after the bot review on PR #798 plus two user follow-ups
(self-signed Postgres support; asyncpg should be a PyPI extra). Folded
into the same PR rather than a follow-up since the work is still
unmerged.

Security
--------
- Mask database credentials in all 5 log call sites (storage.py × 4,
  migrations.py × 1) via a new `mask_db_password()` helper in config.py.
  Uses SQLAlchemy's `make_url(...).render_as_string(hide_password=True)`
  with a regex fallback so the masking path never raises.
- New `tests/unit/test_storage_logging.py` asserts a sentinel password
  never appears in `caplog` during `RefreshTokenStorage.initialize()`.

Distribution
------------
- `asyncpg` moved to `[project.optional-dependencies] postgres` so a
  vanilla `pip install nextcloud-mcp-server` no longer pulls in the
  ~5 MB C extension. The Docker image runs `uv sync --extra postgres`,
  so containerized deployments are unchanged.
- When `DATABASE_URL=postgresql+asyncpg://...` is set on a venv missing
  the extra, `RefreshTokenStorage.initialize()` raises a friendly
  RuntimeError pointing at `[postgres]` rather than the generic
  ModuleNotFoundError.

TLS for the Postgres backend
----------------------------
- New `DATABASE_VERIFY_SSL` + `DATABASE_CA_BUNDLE` env vars mirror the
  existing `NEXTCLOUD_VERIFY_SSL` / `NEXTCLOUD_CA_BUNDLE` pattern
  (validators in Settings.__post_init__, `get_database_ssl()` helper
  alongside `get_nextcloud_ssl_verify()`). `DATABASE_VERIFY_SSL=false`
  wins over `DATABASE_CA_BUNDLE` for incident-response convenience.
- Default is **None** rather than True — keeps PR #798's behavior
  intact for cluster-internal Postgres that runs without TLS. Operators
  opt into verify-full or supply a private CA. ADR-026 records the
  reasoning vs the Nextcloud HTTPS default.
- Engine factory in `storage.py` passes `ssl` via `connect_args` only
  when `get_database_ssl()` returns non-None; otherwise asyncpg's
  default (`prefer`) applies.
- Storage logs which TLS mode is active at INFO (no secret material).

Configurable connection pool
----------------------------
- `DATABASE_POOL_SIZE` (default 10) and `DATABASE_MAX_OVERFLOW`
  (default 20) replace the hardcoded engine values. With many replicas
  this can blow past managed-Postgres `max_connections=100`; tune down
  for large fleets.
- gte-1 / gte-0 validators in __post_init__ reject 0/negative pool
  sizes at startup with the offending value in the error.

Consistency polish
------------------
- Migration 006: convert raw `op.execute("ALTER TABLE ... ADD COLUMN")`
  to `op.batch_alter_table(...).add_column(sa.Column("nonce", sa.Text))`
  for stylistic consistency with the rewritten 001-005. Downgrade now
  drops the column instead of being a no-op.
- `registered_webhooks.created_at` standardized from `sa.Float` to
  `sa.BigInteger` (all other `*_at` columns); `store_webhook()` casts
  `time.time()` → `int`.
- `is_sqlite_url()` made case-insensitive.

Testing
-------
- New `tests/integration/test_storage_postgres.py::test_cleanup_expired_roundtrip`
  exercises `cleanup_expired_tokens`, `cleanup_expired_sessions`, and
  `cleanup_expired_browser_sessions` — relies on DELETE rowcount,
  historically dialect-tricky.
- `tests/unit/test_ssl_config.py` extended with `TestDatabaseSSLSettings`
  + `TestGetDatabaseSSL` classes (9 new tests) mirroring the existing
  Nextcloud SSL tests one-for-one.

Docs
----
- `docs/configuration.md` Centralized-Storage section grew the four new
  env vars + a homelab example with a private CA.
- `docs/ADR-026` grew Distribution, TLS, and `alembic/env.py` async-pattern
  subsections explaining the non-obvious design choices.

Helm chart counterpart in cbcoutinho/helm-charts PR #34 (separate
commit on `feat/nextcloud-mcp-server-database-url`).

Verification
------------
- `uv run pytest tests/unit/` — 1025 passed.
- `TEST_DATABASE_URL=... uv run pytest tests/integration/test_storage_postgres.py -m postgres` — 6 passed (including new cleanup test).
- `uv run ruff check && uv run ruff format --check && uv run ty check -- nextcloud_mcp_server` — clean.

Tracked on Astrolabe Cloud POC board, card #99.

---

_This PR was generated with the help of AI, and reviewed by a Human_

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:53:45 +02:00
Chris CoutinhoandClaude Opus 4.7 292cbb3292 feat(storage): pluggable database backend via DATABASE_URL (ADR-026)
Adds a `DATABASE_URL` setting that lets `RefreshTokenStorage` run against
any SQLAlchemy async backend, primarily `postgresql+asyncpg://...` for
HA k8s deployments. Default behavior is unchanged: when `DATABASE_URL` is
unset the server falls back to the existing `TOKEN_STORAGE_DB` path /
ephemeral SQLite tempfile.

Why
---
Today every MCP pod needs its own PVC to hold the SQLite file, which
pins the Deployment to one replica and blocks horizontal scaling. With
this change, operators can point all replicas at a shared Postgres
(CNPG, RDS, etc.) and the pods become stateless. Encryption stays in
Python (Fernet); the database only sees ciphertext.

What changed
------------
- `config.get_database_url()` resolves DATABASE_URL → TOKEN_STORAGE_DB →
  ephemeral tempfile in that priority order.
- `RefreshTokenStorage` builds a process-shared `AsyncEngine` in
  `initialize()`. SQLite gets NullPool; Postgres gets pool_size=10,
  max_overflow=20, pool_pre_ping=True. 30 aiosqlite call sites adapted
  via a thin `_DBConn` / `_Cursor` / `_Row` / `_ExecuteCtx` shim so
  existing method bodies need no churn beyond the connection
  context-manager swap.
- 7 `INSERT OR REPLACE` statements rewritten as portable
  `INSERT ... ON CONFLICT (...) DO UPDATE` (SQLite ≥ 3.24, Postgres ≥ 9.5).
- `sqlite_master` legacy-detection lookup replaced with SQLAlchemy
  inspector so the path works against either backend.
- File-permission hardening + parent-dir creation gated on
  `is_sqlite_url(...)` — centralized backends manage their own filesystem.
- Alembic migrations 001/002/003/005 converted from raw `op.execute(SQL)`
  to portable `op.create_table()` / `op.create_index()` with SQLAlchemy
  types. All timestamp columns are `sa.BigInteger` so Postgres allocates
  BIGINT (unix epochs don't fit in INT4). SQLite treats BIGINT as
  INTEGER, so existing deployments at revision 006 see no schema drift.
- `migrations.py` + CLI take URLs; `db {upgrade,downgrade,current,history}`
  gain `--database-url / -u` alongside the legacy `--database-path / -d`.
  `get_current_revision()` uses SQLAlchemy inspector instead of raw
  sqlite3, so the CLI works against Postgres too.
- `docker-compose.yml` adds a `postgres-test` service under the
  `postgres` profile (pinned `postgres:16-alpine` digest) for
  integration testing.
- Unit storage tests parametrized over backends via shared
  `tests/fixtures/storage_backend.py` — every test in
  `test_app_password_storage.py` and `test_webhook_storage.py` runs
  once per backend that is available. Postgres is opted in by
  `TEST_DATABASE_URL`.
- New `tests/integration/test_storage_postgres.py` (5 tests, marked
  `postgres` + `integration`) covers refresh-token, app-password,
  OAuth-session, webhook, and audit-log paths end-to-end on Postgres.
- New `docs/ADR-026-pluggable-database-backend.md` records the decision;
  `docs/configuration.md` documents `DATABASE_URL` with examples.

Out of scope
------------
- No SQLite → Postgres data migration tool (clean cutover; tokens reissue
  on next login, webhooks re-register on next sync tick).
- This repo does not provision Postgres. The matching helm chart change
  lives in cbcoutinho/helm-charts (database.url / existingSecret values).

Verification
------------
- `uv run pytest tests/unit/` — 1012 passed, SQLite path unchanged.
- `docker compose --profile postgres up -d postgres-test`
- `TEST_DATABASE_URL=... uv run pytest tests/integration/test_storage_postgres.py -m postgres -v`
  — 5 passed.
- `TEST_DATABASE_URL=... uv run pytest tests/unit/test_app_password_storage.py
  tests/unit/test_webhook_storage.py` — 50 passed (25 per backend).
- `uv run ruff check && uv run ruff format --check && uv run ty check -- nextcloud_mcp_server` — clean.

Tracked on Astrolabe Cloud POC board, card #99.

---

_This PR was generated with the help of AI, and reviewed by a Human_

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 18:06:42 +02:00