Compare commits
95 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9da5f95bcb | |||
| 1d4aede0f9 | |||
| ec8eab99f3 | |||
| da104c59ac | |||
| b3e55d444b | |||
| 1786e204ec | |||
| 0a599c5c03 | |||
| 66e32d4705 | |||
| 8603ed114e | |||
| 7e6ef90423 | |||
| c5f2c8369f | |||
| b79ac29a9d | |||
| 334d62825c | |||
| 2233cb423c | |||
| 196a6cdfb2 | |||
| 93f5e70128 | |||
| e5248e70ee | |||
| 018b946b5b | |||
| 863ba0d52a | |||
| d3903c5e2e | |||
| 6ea97c5b88 | |||
| c12c825b11 | |||
| 3d8f7692a8 | |||
| b21c874c14 | |||
| a4661099e5 | |||
| a46d74d999 | |||
| 92f69c8dba | |||
| 6692a85007 | |||
| 1f09079b5a | |||
| 2535c95f4e | |||
| 4fac0ca40d | |||
| 719a432a95 | |||
| 14c4512ef8 | |||
| 6f482c9245 | |||
| a6ad3707c6 | |||
| b34f8d96e3 | |||
| d948f51b10 | |||
| 5eb5b5023c | |||
| 504213ae79 | |||
| 5eeaafbe95 | |||
| 0ddc62c371 | |||
| 36d901d5ae | |||
| 0a3052d0d9 | |||
| 2b691f1792 | |||
| e3da2e006c | |||
| 4539f2f486 | |||
| c85ad95faf | |||
| 60f7234908 | |||
| 1dd5698389 | |||
| 3a0096f8df | |||
| 7bcffd1e96 | |||
| 9674366312 | |||
| a7581a1d1b | |||
| 0ff442d61c | |||
| 96598510ee | |||
| 02cb1f5491 | |||
| 3856698d0a | |||
| 3a05f0cfb3 | |||
| fe5e7f7a60 | |||
| b7257f4e59 | |||
| 7cc852f0da | |||
| 525258be67 | |||
| 49bd3100ad | |||
| 6693bab9f9 | |||
| 8e0d64f7d3 | |||
| c97ffe8e47 | |||
| d0115170c2 | |||
| 9ec00d4de5 | |||
| 9527427782 | |||
| fbfc8b8a05 | |||
| e85000424d | |||
| 58ac60be12 | |||
| 77ef928060 | |||
| 00afac8e46 | |||
| d22cebc69a | |||
| 151d595360 | |||
| 7e02a58546 | |||
| 25dee9bfaf | |||
| f898d61077 | |||
| 0aaa3fc912 | |||
| 77fabccdb7 | |||
| 2648ef2567 | |||
| 405a57649a | |||
| 252df1d398 | |||
| 0ad81a1fd8 | |||
| dce864e947 | |||
| b9f1040dd5 | |||
| c7882adb24 | |||
| 9491d698e8 | |||
| 5b71ac3251 | |||
| 815a09be34 | |||
| c46f9eb212 | |||
| 28219e00e7 | |||
| daaf460b0c | |||
| 04f05f725c |
@@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@f64219702d7454cf29fe32a74104be6ed43dc637 # v1.0.34
|
||||
uses: anthropics/claude-code-action@b113f49a56229d8276e2bf05743ad6900121239c # v1.0.45
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
allowed_bots: "renovate-bot-cbcoutinho"
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@f64219702d7454cf29fe32a74104be6ed43dc637 # v1.0.34
|
||||
uses: anthropics/claude-code-action@b113f49a56229d8276e2bf05743ad6900121239c # v1.0.45
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Run docker compose with vector sync
|
||||
uses: hoverkraft-tech/compose-action@05da55b2bb8a5a759d1c4732095044bd9018c050 # v2.4.3
|
||||
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
||||
with:
|
||||
compose-file: |
|
||||
./docker-compose.yml
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
|
||||
- name: Wait for Nextcloud to be ready
|
||||
run: |
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
- name: Install Python 3.11
|
||||
run: uv python install 3.11
|
||||
- name: Build
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
- name: Check format
|
||||
run: |
|
||||
uv run --frozen ruff format --diff
|
||||
@@ -66,14 +66,14 @@ jobs:
|
||||
|
||||
|
||||
- name: Run docker compose
|
||||
uses: hoverkraft-tech/compose-action@05da55b2bb8a5a759d1c4732095044bd9018c050 # v2.4.3
|
||||
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
||||
with:
|
||||
compose-file: "./docker-compose.yml"
|
||||
#compose-flags: "--profile qdrant"
|
||||
up-flags: "--build"
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
|
||||
- name: Install Playwright dependencies
|
||||
run: |
|
||||
|
||||
@@ -5,6 +5,43 @@ All notable changes to the Nextcloud MCP Server will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/).
|
||||
|
||||
## v0.63.4 (2026-02-08)
|
||||
|
||||
### Fix
|
||||
|
||||
- strip whitespace from category names when splitting
|
||||
- handle categories, recurrence_rule, attendees, and reminder_minutes in update_event
|
||||
|
||||
## v0.63.3 (2026-02-08)
|
||||
|
||||
### Fix
|
||||
|
||||
- expand recurring events in date-range queries
|
||||
|
||||
## v0.63.2 (2026-02-07)
|
||||
|
||||
### Fix
|
||||
|
||||
- use CalDAV time-range filter for calendar date range queries
|
||||
|
||||
## v0.63.1 (2026-02-03)
|
||||
|
||||
### Fix
|
||||
|
||||
- **helm**: add backward compatibility for legacy persistence configs
|
||||
|
||||
## v0.63.0 (2026-01-28)
|
||||
|
||||
### Feat
|
||||
|
||||
- **astrolabe**: add background token refresh job
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: add pagination and psalm fixes for token refresh
|
||||
- **astrolabe**: add locking to prevent token refresh race condition
|
||||
- **astrolabe**: add issued_at to on-demand token refresh
|
||||
|
||||
## v0.62.0 (2026-01-26)
|
||||
|
||||
### Feat
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:5e2dbd4bbdd9c0e67412aea9463906f74a22c60f89eb7b5bbb7d45b66a2b68a6
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:9e01bf1ae5db7649a236da7be1e94ffbbbdd7a93f867dd0d8d5720d9e1f89fab
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.26@sha256:9a23023be68b2ed09750ae636228e903a54a05ea56ed03a934d00fe9fbeded4b /uv /uvx /bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.0@sha256:78a7ff97cd27b7124a5f3c2aefe146170793c56a1e03321dd31a289f6d82a04f /uv /uvx /bin/
|
||||
|
||||
# Install dependencies
|
||||
# 1. git (required for caldav dependency from git)
|
||||
|
||||
+2
-2
@@ -12,12 +12,12 @@
|
||||
# - Per-session app password authentication
|
||||
# - Multi-user support via Smithery session config
|
||||
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:5e2dbd4bbdd9c0e67412aea9463906f74a22c60f89eb7b5bbb7d45b66a2b68a6
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:9e01bf1ae5db7649a236da7be1e94ffbbbdd7a93f867dd0d8d5720d9e1f89fab
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install uv for fast dependency management
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.26@sha256:9a23023be68b2ed09750ae636228e903a54a05ea56ed03a934d00fe9fbeded4b /uv /uvx /bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.0@sha256:78a7ff97cd27b7124a5f3c2aefe146170793c56a1e03321dd31a289f6d82a04f /uv /uvx /bin/
|
||||
|
||||
# Install dependencies
|
||||
# 1. git (required for caldav dependency from git)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.commitizen]
|
||||
name = "cz_conventional_commits"
|
||||
version = "0.57.14"
|
||||
version = "0.57.41"
|
||||
tag_format = "nextcloud-mcp-server-$version"
|
||||
version_scheme = "semver"
|
||||
update_changelog_on_bump = true
|
||||
|
||||
@@ -14,6 +14,102 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Configurable resource limits
|
||||
- Grafana dashboard annotations
|
||||
|
||||
## nextcloud-mcp-server-0.57.41 (2026-02-08)
|
||||
|
||||
### Fix
|
||||
|
||||
- expand recurring events in date-range queries
|
||||
|
||||
## nextcloud-mcp-server-0.57.40 (2026-02-07)
|
||||
|
||||
### Fix
|
||||
|
||||
- use CalDAV time-range filter for calendar date range queries
|
||||
|
||||
## nextcloud-mcp-server-0.57.39 (2026-02-07)
|
||||
|
||||
## nextcloud-mcp-server-0.57.38 (2026-02-07)
|
||||
|
||||
## nextcloud-mcp-server-0.57.37 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.36 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.35 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.34 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.33 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.32 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.31 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.30 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.29 (2026-02-04)
|
||||
|
||||
## nextcloud-mcp-server-0.57.28 (2026-02-03)
|
||||
|
||||
## nextcloud-mcp-server-0.57.27 (2026-02-03)
|
||||
|
||||
### Fix
|
||||
|
||||
- **helm**: add backward compatibility for legacy persistence configs
|
||||
|
||||
## nextcloud-mcp-server-0.57.26 (2026-01-31)
|
||||
|
||||
## nextcloud-mcp-server-0.57.25 (2026-01-31)
|
||||
|
||||
## nextcloud-mcp-server-0.57.24 (2026-01-31)
|
||||
|
||||
## nextcloud-mcp-server-0.57.23 (2026-01-30)
|
||||
|
||||
## nextcloud-mcp-server-0.57.22 (2026-01-30)
|
||||
|
||||
## nextcloud-mcp-server-0.57.21 (2026-01-30)
|
||||
|
||||
## nextcloud-mcp-server-0.57.20 (2026-01-29)
|
||||
|
||||
## nextcloud-mcp-server-0.57.19 (2026-01-28)
|
||||
|
||||
## nextcloud-mcp-server-0.57.18 (2026-01-28)
|
||||
|
||||
## nextcloud-mcp-server-0.57.17 (2026-01-28)
|
||||
|
||||
## nextcloud-mcp-server-0.57.16 (2026-01-28)
|
||||
|
||||
### Feat
|
||||
|
||||
- **astrolabe**: add background token refresh job
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: add pagination and psalm fixes for token refresh
|
||||
- **astrolabe**: add locking to prevent token refresh race condition
|
||||
- **astrolabe**: add issued_at to on-demand token refresh
|
||||
|
||||
## nextcloud-mcp-server-0.57.15 (2026-01-26)
|
||||
|
||||
### Feat
|
||||
|
||||
- **scripts**: add database query helpers for development
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: resolve Psalm type errors in PDF preview code
|
||||
- **astrolabe**: fix Psalm baseline and ESLint import order
|
||||
- **astrolabe**: load pdfjs-dist externally to fix PDF viewer
|
||||
- **astrolabe**: improve error messages for authorization issues
|
||||
- **astrolabe**: rename OAuthController and fix app password check
|
||||
- **tests**: improve Astrolabe integration test reliability
|
||||
- **astrolabe**: update Plotly title attributes for v3 compatibility
|
||||
- **deps**: update dependency plotly.js-dist-min to v3
|
||||
|
||||
### Refactor
|
||||
|
||||
- **api**: split management.py into domain-focused modules
|
||||
- **astrolabe**: replace client-side PDF.js with server-side PyMuPDF rendering
|
||||
|
||||
## nextcloud-mcp-server-0.57.14 (2026-01-26)
|
||||
|
||||
## nextcloud-mcp-server-0.57.13 (2026-01-24)
|
||||
|
||||
@@ -4,6 +4,6 @@ dependencies:
|
||||
version: 1.16.3
|
||||
- name: ollama
|
||||
repository: https://otwld.github.io/ollama-helm
|
||||
version: 1.38.0
|
||||
digest: sha256:60b09d52759c84f8add5782c867f5a373aa6eb2477dc9380bef0134183c4b1ae
|
||||
generated: "2026-01-20T11:11:57.230612063Z"
|
||||
version: 1.41.0
|
||||
digest: sha256:1d5b958a64eb2102cf347ec199638bfac5b289bafdecff2529099ee6bce03b86
|
||||
generated: "2026-02-04T11:09:21.837825534Z"
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: nextcloud-mcp-server
|
||||
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
||||
type: application
|
||||
version: 0.57.14
|
||||
appVersion: "0.62.0"
|
||||
version: 0.57.41
|
||||
appVersion: "0.63.4"
|
||||
keywords:
|
||||
- nextcloud
|
||||
- mcp
|
||||
@@ -31,6 +31,6 @@ dependencies:
|
||||
repository: https://qdrant.github.io/qdrant-helm
|
||||
condition: qdrant.networkMode.deploySubchart
|
||||
- name: ollama
|
||||
version: "1.38.0"
|
||||
version: "1.41.0"
|
||||
repository: https://otwld.github.io/ollama-helm
|
||||
condition: ollama.enabled
|
||||
|
||||
@@ -118,6 +118,25 @@ ingress:
|
||||
| `auth.oauth.persistence.enabled` | Enable persistent storage for OAuth | `true` |
|
||||
| `auth.oauth.persistence.size` | Size of OAuth storage PVC | `100Mi` |
|
||||
|
||||
#### Data Storage
|
||||
|
||||
The `/app/data` directory is used for application data (token databases, Qdrant persistent storage, etc.). It is always mounted as writable to support the read-only root filesystem security context.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `dataStorage.enabled` | Enable persistent storage for `/app/data` | `false` |
|
||||
| `dataStorage.size` | Size of data storage PVC | `1Gi` |
|
||||
| `dataStorage.storageClass` | Storage class (leave empty for default) | `""` |
|
||||
| `dataStorage.accessMode` | Access mode | `ReadWriteOnce` |
|
||||
| `dataStorage.existingClaim` | Use existing PVC | `""` |
|
||||
|
||||
**When to enable persistence:**
|
||||
- Multi-user basic auth with offline access (stores `tokens.db`)
|
||||
- Qdrant persistent mode (stores vector database)
|
||||
- Any feature requiring persistent app data
|
||||
|
||||
**When persistence is disabled:** Uses `emptyDir` (non-persistent, data lost on pod restart, but directory remains writable).
|
||||
|
||||
#### MCP Server Configuration
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|
||||
@@ -120,6 +120,55 @@ Your Nextcloud MCP Server has been deployed in {{ .Values.auth.mode }} authentic
|
||||
The dashboard JSON is available in the chart at charts/nextcloud-mcp-server/dashboards/nextcloud-mcp-server.json
|
||||
{{- end }}
|
||||
|
||||
{{- $legacyMultiUserBasic := eq (include "nextcloud-mcp-server.legacyMultiUserBasicPersistence" .) "true" }}
|
||||
{{- $legacyQdrant := eq (include "nextcloud-mcp-server.legacyQdrantPersistence" .) "true" }}
|
||||
{{- if or $legacyMultiUserBasic $legacyQdrant }}
|
||||
|
||||
================================================================================
|
||||
DEPRECATION WARNING
|
||||
================================================================================
|
||||
|
||||
You are using deprecated persistence configuration that will be removed in a
|
||||
future release. Your deployment will continue to work, but please migrate to
|
||||
the new unified dataStorage configuration.
|
||||
|
||||
Deprecated settings detected:
|
||||
{{- if $legacyMultiUserBasic }}
|
||||
- auth.multiUserBasic.persistence.* (currently enabled)
|
||||
{{- end }}
|
||||
{{- if $legacyQdrant }}
|
||||
- qdrant.localPersistence.* (currently enabled)
|
||||
{{- end }}
|
||||
|
||||
To migrate, update your values.yaml:
|
||||
|
||||
dataStorage:
|
||||
enabled: true
|
||||
{{- if $legacyMultiUserBasic }}
|
||||
size: {{ .Values.auth.multiUserBasic.persistence.size }}
|
||||
{{- else if $legacyQdrant }}
|
||||
size: {{ .Values.qdrant.localPersistence.size }}
|
||||
{{- end }}
|
||||
# storageClass: "" # Optional: specify storage class
|
||||
# existingClaim: "" # Optional: use existing PVC to preserve data
|
||||
|
||||
After migrating, remove the deprecated settings:
|
||||
{{- if $legacyMultiUserBasic }}
|
||||
- auth.multiUserBasic.persistence.enabled
|
||||
- auth.multiUserBasic.persistence.size
|
||||
- auth.multiUserBasic.persistence.storageClass
|
||||
- auth.multiUserBasic.persistence.accessMode
|
||||
{{- end }}
|
||||
{{- if $legacyQdrant }}
|
||||
- qdrant.localPersistence.enabled
|
||||
- qdrant.localPersistence.size
|
||||
- qdrant.localPersistence.storageClass
|
||||
- qdrant.localPersistence.accessMode
|
||||
{{- end }}
|
||||
|
||||
================================================================================
|
||||
{{- end }}
|
||||
|
||||
For more information and documentation:
|
||||
- GitHub: https://github.com/cbcoutinho/nextcloud-mcp-server
|
||||
- Documentation: https://github.com/cbcoutinho/nextcloud-mcp-server#readme
|
||||
|
||||
@@ -127,6 +127,55 @@ Create the name of the PVC to use for Qdrant local persistent storage
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create the name of the PVC to use for /app/data storage
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.dataStoragePvcName" -}}
|
||||
{{- if .Values.dataStorage.existingClaim }}
|
||||
{{- .Values.dataStorage.existingClaim }}
|
||||
{{- else }}
|
||||
{{- include "nextcloud-mcp-server.fullname" . }}-data-storage
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Determine if data storage PVC should be enabled (backward compatible)
|
||||
Checks new dataStorage.enabled OR legacy persistence configs
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.dataStorageEnabled" -}}
|
||||
{{- if .Values.dataStorage.enabled -}}
|
||||
true
|
||||
{{- else if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled -}}
|
||||
true
|
||||
{{- else if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled -}}
|
||||
true
|
||||
{{- else -}}
|
||||
false
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Check if legacy multi-user-basic persistence config is being used
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.legacyMultiUserBasicPersistence" -}}
|
||||
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled (not .Values.dataStorage.enabled) -}}
|
||||
true
|
||||
{{- else -}}
|
||||
false
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Check if legacy qdrant persistence config is being used
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.legacyQdrantPersistence" -}}
|
||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled (not .Values.dataStorage.enabled) -}}
|
||||
true
|
||||
{{- else -}}
|
||||
false
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Return the MCP server port
|
||||
*/}}
|
||||
|
||||
@@ -286,14 +286,8 @@ spec:
|
||||
- name: oauth-storage
|
||||
mountPath: /app/.oauth
|
||||
{{- end }}
|
||||
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled }}
|
||||
- name: token-storage
|
||||
- name: data-storage
|
||||
mountPath: /app/data
|
||||
{{- end }}
|
||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
|
||||
- name: qdrant-data
|
||||
mountPath: /app/data
|
||||
{{- end }}
|
||||
{{- with .Values.volumeMounts }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
@@ -305,15 +299,12 @@ spec:
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "nextcloud-mcp-server.oauthPvcName" . }}
|
||||
{{- end }}
|
||||
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled }}
|
||||
- name: token-storage
|
||||
- name: data-storage
|
||||
{{- if eq (include "nextcloud-mcp-server.dataStorageEnabled" .) "true" }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "nextcloud-mcp-server.multiUserBasicPvcName" . }}
|
||||
{{- end }}
|
||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
|
||||
- name: qdrant-data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "nextcloud-mcp-server.qdrantPvcName" . }}
|
||||
claimName: {{ include "nextcloud-mcp-server.dataStoragePvcName" . }}
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- with .Values.volumes }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
|
||||
@@ -16,38 +16,34 @@ spec:
|
||||
storage: {{ .Values.auth.oauth.persistence.size }}
|
||||
{{- end }}
|
||||
---
|
||||
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled (not .Values.auth.multiUserBasic.persistence.existingClaim) }}
|
||||
{{- if and (eq (include "nextcloud-mcp-server.dataStorageEnabled" .) "true") (not .Values.dataStorage.existingClaim) }}
|
||||
{{- $legacyMultiUserBasic := eq (include "nextcloud-mcp-server.legacyMultiUserBasicPersistence" .) "true" }}
|
||||
{{- $legacyQdrant := eq (include "nextcloud-mcp-server.legacyQdrantPersistence" .) "true" }}
|
||||
{{- $accessMode := .Values.dataStorage.accessMode }}
|
||||
{{- $storageClass := .Values.dataStorage.storageClass }}
|
||||
{{- $size := .Values.dataStorage.size }}
|
||||
{{- if $legacyMultiUserBasic }}
|
||||
{{- $accessMode = .Values.auth.multiUserBasic.persistence.accessMode }}
|
||||
{{- $storageClass = .Values.auth.multiUserBasic.persistence.storageClass }}
|
||||
{{- $size = .Values.auth.multiUserBasic.persistence.size }}
|
||||
{{- else if $legacyQdrant }}
|
||||
{{- $accessMode = .Values.qdrant.localPersistence.accessMode }}
|
||||
{{- $storageClass = .Values.qdrant.localPersistence.storageClass }}
|
||||
{{- $size = .Values.qdrant.localPersistence.size }}
|
||||
{{- end }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}-token-storage
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}-data-storage
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.auth.multiUserBasic.persistence.accessMode }}
|
||||
{{- if .Values.auth.multiUserBasic.persistence.storageClass }}
|
||||
storageClassName: {{ .Values.auth.multiUserBasic.persistence.storageClass }}
|
||||
- {{ $accessMode }}
|
||||
{{- if $storageClass }}
|
||||
storageClassName: {{ $storageClass }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.auth.multiUserBasic.persistence.size }}
|
||||
{{- end }}
|
||||
---
|
||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled (not .Values.qdrant.localPersistence.existingClaim) }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}-qdrant-data
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.qdrant.localPersistence.accessMode }}
|
||||
{{- if .Values.qdrant.localPersistence.storageClass }}
|
||||
storageClassName: {{ .Values.qdrant.localPersistence.storageClass }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.qdrant.localPersistence.size }}
|
||||
storage: {{ $size }}
|
||||
{{- end }}
|
||||
|
||||
@@ -139,6 +139,27 @@ auth:
|
||||
# Use existing PVC
|
||||
existingClaim: ""
|
||||
|
||||
# Data Storage Configuration
|
||||
# Persistent volume for /app/data directory
|
||||
# Used for: token databases, qdrant persistent storage, and any app data
|
||||
# When disabled, uses emptyDir (non-persistent, but still writable)
|
||||
dataStorage:
|
||||
# Enable persistent storage for /app/data
|
||||
# Set to true when using:
|
||||
# - Multi-user basic auth with offline access (stores tokens.db)
|
||||
# - Qdrant persistent mode (stores vector database)
|
||||
# - Any feature requiring persistent app data
|
||||
# Set to false for basic auth without persistence (uses emptyDir)
|
||||
enabled: false
|
||||
# Storage class (leave empty for default)
|
||||
storageClass: ""
|
||||
accessMode: ReadWriteOnce
|
||||
# Size for data storage (should accommodate tokens.db and/or qdrant data)
|
||||
# Recommended: 1Gi minimum, 5Gi for production with qdrant
|
||||
size: 1Gi
|
||||
# Use existing PVC
|
||||
existingClaim: ""
|
||||
|
||||
# MCP server configuration
|
||||
mcp:
|
||||
# Transport mode (default: streamable-http for SSE)
|
||||
|
||||
+4
-4
@@ -19,11 +19,11 @@ services:
|
||||
# Note: Redis is an external service. You can find more information about the configuration here:
|
||||
# https://hub.docker.com/_/redis
|
||||
redis:
|
||||
image: docker.io/library/redis:alpine@sha256:6cbef353e480a8a6e7f10ec545f13d7d3fa85a212cdcc5ffaf5a1c818b9d3798
|
||||
image: docker.io/library/redis:alpine@sha256:0804c395e634e624243387d3c3a9c45fcaca876d313c2c8b52c3fdf9a912dded
|
||||
restart: always
|
||||
|
||||
app:
|
||||
image: docker.io/library/nextcloud:32.0.5@sha256:11a3a4f63bad8813c7455b4a3c473ccd1c41e2c48f55decb51718f15691e7568
|
||||
image: docker.io/library/nextcloud:32.0.5@sha256:4b66e9bd8cb2c8af5457c1e2606c9937af2fcccbe4f6338956bc5990caec8968
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:8080:80
|
||||
@@ -54,14 +54,14 @@ services:
|
||||
retries: 30
|
||||
|
||||
recipes:
|
||||
image: docker.io/library/nginx:alpine@sha256:66d420cc54ef85bcc1d72220e83d7aaa6c4850bd2904794e3a56f09fd4ccb66e
|
||||
image: docker.io/library/nginx:alpine@sha256:5878d06ae4c83d73285438255f705bb3f9a736f41cd24876ed25bb33faf76c7d
|
||||
restart: always
|
||||
volumes:
|
||||
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
|
||||
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
|
||||
unstructured:
|
||||
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:db5fcc831eb673ec835c41e8d47f993fdde276562285d6837cebb03f958536a2
|
||||
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:9945a842ba983afcf110053cbcc0df7e4bd09ba9f02aa213824ce3f986713635
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:8002:8000
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
# Authentication Flows by Deployment Mode
|
||||
|
||||
This document provides a unified reference for authentication flows across all deployment modes. For configuration details, see [Authentication](authentication.md). For OAuth protocol details, see [OAuth Architecture](oauth-architecture.md).
|
||||
|
||||
## Quick Reference Matrix
|
||||
|
||||
| Mode | Client → MCP → NC | Background Sync | Astrolabe → MCP |
|
||||
|------|-------------------|-----------------|-----------------|
|
||||
| [Single-User BasicAuth](#1-single-user-basicauth) | Embedded credentials | Same credentials | N/A |
|
||||
| [Multi-User BasicAuth](#2-multi-user-basicauth) | Header pass-through | App password (optional) | Bearer token |
|
||||
| [OAuth Single-Audience](#3-oauth-single-audience-default) | Multi-audience token | Refresh token exchange | Bearer token |
|
||||
| [OAuth Token Exchange](#4-oauth-token-exchange-rfc-8693) | RFC 8693 exchange | Refresh token exchange | Bearer token |
|
||||
| [Smithery Stateless](#5-smithery-stateless) | Session parameters | Not supported | N/A |
|
||||
|
||||
## Communication Patterns
|
||||
|
||||
This document covers three distinct communication patterns:
|
||||
|
||||
1. **MCP Client → MCP Server → Nextcloud**: Interactive tool calls initiated by users through MCP clients (Claude Desktop, etc.)
|
||||
2. **MCP Server → Nextcloud**: Background operations like vector sync that run without user interaction
|
||||
3. **Astrolabe → MCP Server**: Nextcloud app backend communication for settings UI and unified search
|
||||
|
||||
---
|
||||
|
||||
## Deployment Modes
|
||||
|
||||
### 1. Single-User BasicAuth
|
||||
|
||||
**Use Case:** Personal Nextcloud instance, local development, single-user deployments.
|
||||
|
||||
#### MCP Client → MCP Server → Nextcloud
|
||||
|
||||
```
|
||||
MCP Client MCP Server Nextcloud
|
||||
│ │ │
|
||||
│── MCP Request ─────────────▶│ │
|
||||
│ (no auth required) │ │
|
||||
│ │── HTTP + BasicAuth ───────▶│
|
||||
│ │ Authorization: Basic │
|
||||
│ │ (embedded credentials) │
|
||||
│ │◀── API Response ───────────│
|
||||
│◀── Tool Result ─────────────│ │
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- Credentials embedded in server configuration (`NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD`)
|
||||
- Single shared `NextcloudClient` created at startup
|
||||
- No MCP-level authentication required (server trusts local clients)
|
||||
- All requests use the same Nextcloud user
|
||||
|
||||
**Implementation:** `context.py:78-79` - Returns shared client from lifespan context
|
||||
|
||||
#### Background Sync
|
||||
|
||||
Uses the same embedded credentials as interactive requests. The background job accesses Nextcloud with the configured username/password.
|
||||
|
||||
**Implementation:** Background jobs use `get_settings()` to access credentials
|
||||
|
||||
#### Astrolabe Integration
|
||||
|
||||
Not applicable - Astrolabe is only used in multi-user deployments where users need personal settings and token management.
|
||||
|
||||
---
|
||||
|
||||
### 2. Multi-User BasicAuth
|
||||
|
||||
**Use Case:** Internal deployment where users provide their own credentials via HTTP headers.
|
||||
|
||||
#### MCP Client → MCP Server → Nextcloud
|
||||
|
||||
```
|
||||
MCP Client MCP Server Nextcloud
|
||||
│ │ │
|
||||
│── MCP Request ─────────────▶│ │
|
||||
│ Authorization: Basic │ │
|
||||
│ (user credentials) │ │
|
||||
│ │── BasicAuthMiddleware ────▶│
|
||||
│ │ Extracts credentials │
|
||||
│ │ │
|
||||
│ │── HTTP + BasicAuth ───────▶│
|
||||
│ │ (pass-through) │
|
||||
│ │◀── API Response ───────────│
|
||||
│◀── Tool Result ─────────────│ │
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- `BasicAuthMiddleware` extracts credentials from `Authorization: Basic` header
|
||||
- Credentials passed through to Nextcloud (not stored)
|
||||
- Client created per-request from extracted credentials
|
||||
- Stateless - no credential storage between requests
|
||||
|
||||
**Implementation:** `context.py:187-248` - `_get_client_from_basic_auth()` extracts credentials from request state
|
||||
|
||||
#### Background Sync (Optional)
|
||||
|
||||
Requires `ENABLE_OFFLINE_ACCESS=true`. Users can store app passwords via Astrolabe for background operations.
|
||||
|
||||
```
|
||||
Astrolabe MCP Server Nextcloud
|
||||
│ │ │
|
||||
│── Store App Password ──────▶│ │
|
||||
│ (via management API) │ │
|
||||
│ │── Store in SQLite ────────▶│
|
||||
│ │ (encrypted) │
|
||||
│◀── Confirmation ────────────│ │
|
||||
│ │ │
|
||||
│ [Background Job] │ │
|
||||
│ │── Retrieve app password ──▶│
|
||||
│ │ (from encrypted storage) │
|
||||
│ │── HTTP + BasicAuth ───────▶│
|
||||
│ │ (stored app password) │
|
||||
│ │◀── API Response ───────────│
|
||||
```
|
||||
|
||||
**Requirements:**
|
||||
- `ENABLE_OFFLINE_ACCESS=true`
|
||||
- `TOKEN_ENCRYPTION_KEY` for credential encryption
|
||||
- `TOKEN_STORAGE_DB` for SQLite storage path
|
||||
|
||||
#### Astrolabe → MCP Server
|
||||
|
||||
```
|
||||
Astrolabe MCP Server Nextcloud OIDC
|
||||
│ │ │
|
||||
│── OAuth Flow ──────────────▶│◀── Token from IdP ────────▶│
|
||||
│ (user initiates) │ │
|
||||
│ │ │
|
||||
│── Bearer Token ────────────▶│ │
|
||||
│ (management API calls) │ │
|
||||
│ │── Validate via JWKS ──────▶│
|
||||
│ │ (or introspection) │
|
||||
│◀── API Response ────────────│ │
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- Astrolabe has its own OAuth client (`astrolabe_client_id` in Nextcloud config)
|
||||
- Tokens are validated by MCP server using Nextcloud OIDC JWKS
|
||||
- Authorization check: `token.sub == requested_resource_owner`
|
||||
- Any valid Nextcloud OIDC token accepted (relaxed audience validation per ADR-018)
|
||||
|
||||
**Implementation:** `unified_verifier.py:120-183` - `verify_token_for_management_api()` validates without strict audience check
|
||||
|
||||
---
|
||||
|
||||
### 3. OAuth Single-Audience (Default)
|
||||
|
||||
**Use Case:** Multi-user deployment with OAuth authentication. Tokens work for both MCP and Nextcloud.
|
||||
|
||||
This is the default mode when `NEXTCLOUD_USERNAME`/`NEXTCLOUD_PASSWORD` are not set.
|
||||
|
||||
#### MCP Client → MCP Server → Nextcloud
|
||||
|
||||
```
|
||||
MCP Client MCP Server Nextcloud
|
||||
│ │ │
|
||||
│── Bearer Token ────────────▶│ │
|
||||
│ aud: ["mcp-server", │ │
|
||||
│ "nextcloud"] │ │
|
||||
│ │── Validate MCP audience ──▶│
|
||||
│ │ (UnifiedTokenVerifier) │
|
||||
│ │ │
|
||||
│ │── HTTP + Same Token ──────▶│
|
||||
│ │ Authorization: Bearer │
|
||||
│ │ (multi-audience token) │
|
||||
│ │ │
|
||||
│ │ NC validates its own aud │
|
||||
│ │◀── API Response ───────────│
|
||||
│◀── Tool Result ─────────────│ │
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- Token contains both audiences: `aud: ["mcp-server", "nextcloud"]`
|
||||
- MCP server validates only MCP audience (per RFC 7519)
|
||||
- Nextcloud independently validates its own audience
|
||||
- No token exchange needed - same token used throughout
|
||||
- Stateless operation for interactive requests
|
||||
|
||||
**Token validation flow:**
|
||||
1. `UnifiedTokenVerifier.verify_token()` validates MCP audience
|
||||
2. Token passed directly to Nextcloud via `get_client_from_context()`
|
||||
3. Nextcloud validates its own audience when receiving API calls
|
||||
|
||||
**Implementation:**
|
||||
- `unified_verifier.py:185-252` - `_verify_mcp_audience()` validates MCP audience only
|
||||
- `context.py:96-99` - Uses token directly in multi-audience mode
|
||||
|
||||
#### Background Sync
|
||||
|
||||
Requires `ENABLE_OFFLINE_ACCESS=true`. Uses stored refresh tokens to obtain access tokens for background operations.
|
||||
|
||||
```
|
||||
MCP Server Nextcloud OIDC
|
||||
│ │
|
||||
[Background Job starts] │ │
|
||||
│── Get refresh token ──────▶│
|
||||
│ (from encrypted storage) │
|
||||
│ │
|
||||
│── Token refresh request ──▶│
|
||||
│ grant_type=refresh_token │
|
||||
│ scope=openid profile ... │
|
||||
│◀── New access + refresh ───│
|
||||
│ (rotation) │
|
||||
│ │
|
||||
│── Store rotated refresh ──▶│
|
||||
│ (encrypted) │
|
||||
│ │
|
||||
│── HTTP + Access Token ────▶│
|
||||
│ Authorization: Bearer │
|
||||
│◀── API Response ───────────│
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- Refresh tokens stored encrypted in SQLite (`TOKEN_STORAGE_DB`)
|
||||
- Nextcloud OIDC rotates refresh tokens on every use (one-time use)
|
||||
- `TokenBrokerService` handles token lifecycle
|
||||
- Per-user locking prevents race conditions during concurrent refresh
|
||||
|
||||
**Implementation:**
|
||||
- `token_broker.py:269-362` - `get_background_token()` handles refresh with locking
|
||||
- `token_broker.py:428-509` - `_refresh_access_token_with_scopes()` exchanges refresh token
|
||||
|
||||
#### Astrolabe → MCP Server
|
||||
|
||||
Same as Multi-User BasicAuth. See [Astrolabe → MCP Server](#astrolabe--mcp-server) above.
|
||||
|
||||
---
|
||||
|
||||
### 4. OAuth Token Exchange (RFC 8693)
|
||||
|
||||
**Use Case:** Multi-user deployment where MCP tokens are separate from Nextcloud tokens. Provides stronger security boundaries.
|
||||
|
||||
Enabled by `ENABLE_TOKEN_EXCHANGE=true`.
|
||||
|
||||
#### MCP Client → MCP Server → Nextcloud
|
||||
|
||||
```
|
||||
MCP Client MCP Server Nextcloud OIDC
|
||||
│ │ │
|
||||
│── Bearer Token ────────────▶│ │
|
||||
│ aud: "mcp-server" │ │
|
||||
│ (MCP audience only) │ │
|
||||
│ │── Validate MCP audience ──▶│
|
||||
│ │ │
|
||||
│ │── RFC 8693 Exchange ──────▶│
|
||||
│ │ grant_type= │
|
||||
│ │ urn:ietf:params:oauth: │
|
||||
│ │ grant-type:token-exchange
|
||||
│ │ subject_token=<mcp-token>│
|
||||
│ │ requested_audience= │
|
||||
│ │ "nextcloud" │
|
||||
│ │◀── Delegated Token ────────│
|
||||
│ │ aud: "nextcloud" │
|
||||
│ │ │
|
||||
│ │── HTTP + Delegated Token ─▶│
|
||||
│ │ Authorization: Bearer │
|
||||
│ │◀── API Response ───────────│
|
||||
│◀── Tool Result ─────────────│ │
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- Strict audience separation: MCP token has `aud: "mcp-server"` only
|
||||
- Server exchanges for Nextcloud-audience token on each request
|
||||
- Ephemeral delegated tokens (not cached by default)
|
||||
- Strongest security boundary between MCP and Nextcloud access
|
||||
|
||||
**Token exchange details:**
|
||||
- Uses RFC 8693 "urn:ietf:params:oauth:grant-type:token-exchange"
|
||||
- Subject token: MCP access token
|
||||
- Requested audience: Nextcloud resource URI
|
||||
- Result: Short-lived token scoped for Nextcloud
|
||||
|
||||
**Implementation:**
|
||||
- `token_broker.py:220-267` - `get_session_token()` performs on-demand exchange
|
||||
- `token_exchange.py` - `exchange_token_for_delegation()` implements RFC 8693
|
||||
- `context.py:88-94` - Routes to session client in exchange mode
|
||||
|
||||
#### Background Sync
|
||||
|
||||
Same as OAuth Single-Audience. Uses stored refresh tokens from Flow 2 provisioning.
|
||||
|
||||
```
|
||||
MCP Server Nextcloud OIDC
|
||||
│ │
|
||||
[User provisions access] │ │
|
||||
│── Flow 2 OAuth ───────────▶│
|
||||
│ client_id="mcp-server" │
|
||||
│ scope=offline_access ... │
|
||||
│◀── Refresh Token ──────────│
|
||||
│ (stored encrypted) │
|
||||
│ │
|
||||
[Background Job runs later] │ │
|
||||
│── Refresh for background ─▶│
|
||||
│ (same as single-audience)│
|
||||
```
|
||||
|
||||
**Key difference from interactive:**
|
||||
- Interactive: On-demand token exchange per request
|
||||
- Background: Uses pre-provisioned refresh tokens (Flow 2)
|
||||
|
||||
#### Astrolabe → MCP Server
|
||||
|
||||
Same as Multi-User BasicAuth. See [Astrolabe → MCP Server](#astrolabe--mcp-server) above.
|
||||
|
||||
---
|
||||
|
||||
### 5. Smithery Stateless
|
||||
|
||||
**Use Case:** Multi-tenant SaaS deployment via Smithery platform. Fully stateless.
|
||||
|
||||
Enabled by `SMITHERY_DEPLOYMENT=true`.
|
||||
|
||||
#### MCP Client → MCP Server → Nextcloud
|
||||
|
||||
```
|
||||
MCP Client MCP Server Nextcloud
|
||||
│ │ │
|
||||
│── SSE Connect ─────────────▶│ │
|
||||
│ ?nextcloud_url=... │ │
|
||||
│ &username=... │ │
|
||||
│ &app_password=... │ │
|
||||
│ │── SmitheryConfigMiddleware │
|
||||
│ │ Extract URL params │
|
||||
│ │ │
|
||||
│── MCP Request ─────────────▶│ │
|
||||
│ (no Authorization header) │ │
|
||||
│ │── Create per-request ─────▶│
|
||||
│ │ NextcloudClient │
|
||||
│ │ │
|
||||
│ │── HTTP + BasicAuth ───────▶│
|
||||
│ │ (from session params) │
|
||||
│ │◀── API Response ───────────│
|
||||
│◀── Tool Result ─────────────│ │
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- Configuration passed via URL query parameters (Smithery `configSchema`)
|
||||
- No persistent state - client created fresh per request
|
||||
- No OAuth infrastructure
|
||||
- No background sync support (stateless)
|
||||
- No admin UI available
|
||||
|
||||
**Required session parameters:**
|
||||
- `nextcloud_url`: Nextcloud instance URL
|
||||
- `username`: Nextcloud username
|
||||
- `app_password`: Nextcloud app password
|
||||
|
||||
**Implementation:** `context.py:108-184` - `_get_client_from_session_config()` creates client from session params
|
||||
|
||||
#### Background Sync
|
||||
|
||||
Not supported. Smithery mode is fully stateless with no credential storage.
|
||||
|
||||
#### Astrolabe Integration
|
||||
|
||||
Not applicable. Smithery deployments don't integrate with Astrolabe.
|
||||
|
||||
---
|
||||
|
||||
## Astrolabe Background Token Refresh
|
||||
|
||||
The Astrolabe Nextcloud app includes a background job that proactively refreshes OAuth tokens before expiration.
|
||||
|
||||
```
|
||||
Nextcloud Cron Astrolabe MCP Server IdP
|
||||
│ │ │
|
||||
│── Run RefreshUserTokens ───▶│ │
|
||||
│ (every 15 minutes) │ │
|
||||
│ │── Get all user tokens ────▶│
|
||||
│ │ (from preferences) │
|
||||
│ │ │
|
||||
│ [For each user] │ │
|
||||
│ │── Check expiry ───────────▶│
|
||||
│ │ refresh if <50% lifetime │
|
||||
│ │ │
|
||||
│ │── Acquire user lock ──────▶│
|
||||
│ │ (prevent race condition) │
|
||||
│ │ │
|
||||
│ │── Token refresh request ──▶│
|
||||
│ │ grant_type=refresh_token │
|
||||
│ │◀── New tokens ─────────────│
|
||||
│ │ │
|
||||
│ │── Store new tokens ───────▶│
|
||||
│ │ (with issued_at) │
|
||||
│◀── Job complete ────────────│ │
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- Runs every 15 minutes via Nextcloud cron
|
||||
- Refreshes when <50% of token lifetime remains
|
||||
- Uses locking to prevent race conditions with on-demand refresh
|
||||
- Stores `issued_at` timestamp for accurate lifetime calculation
|
||||
- Batch processing (100 users at a time) for memory efficiency
|
||||
|
||||
**Implementation:** `third_party/astrolabe/lib/BackgroundJob/RefreshUserTokens.php`
|
||||
|
||||
---
|
||||
|
||||
## Configuration Quick Reference
|
||||
|
||||
### Single-User BasicAuth
|
||||
```bash
|
||||
NEXTCLOUD_HOST=http://localhost:8080
|
||||
NEXTCLOUD_USERNAME=admin
|
||||
NEXTCLOUD_PASSWORD=password
|
||||
```
|
||||
|
||||
### Multi-User BasicAuth
|
||||
```bash
|
||||
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||
ENABLE_MULTI_USER_BASIC_AUTH=true
|
||||
|
||||
# Optional: For background sync
|
||||
ENABLE_OFFLINE_ACCESS=true
|
||||
TOKEN_ENCRYPTION_KEY=<32-byte-key>
|
||||
TOKEN_STORAGE_DB=/data/tokens.db
|
||||
```
|
||||
|
||||
### OAuth Single-Audience (Default)
|
||||
```bash
|
||||
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||
# No username/password triggers OAuth mode
|
||||
|
||||
# Optional: Static client credentials (instead of DCR)
|
||||
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
|
||||
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
|
||||
|
||||
# Optional: For background sync
|
||||
ENABLE_OFFLINE_ACCESS=true
|
||||
TOKEN_ENCRYPTION_KEY=<32-byte-key>
|
||||
TOKEN_STORAGE_DB=/data/tokens.db
|
||||
```
|
||||
|
||||
### OAuth Token Exchange
|
||||
```bash
|
||||
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||
ENABLE_TOKEN_EXCHANGE=true
|
||||
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
|
||||
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
|
||||
|
||||
# Optional: For background sync
|
||||
ENABLE_OFFLINE_ACCESS=true
|
||||
TOKEN_ENCRYPTION_KEY=<32-byte-key>
|
||||
TOKEN_STORAGE_DB=/data/tokens.db
|
||||
```
|
||||
|
||||
### Smithery Stateless
|
||||
```bash
|
||||
SMITHERY_DEPLOYMENT=true
|
||||
# All other config comes from session URL parameters
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Authentication](authentication.md) - Configuration details and setup guides
|
||||
- [OAuth Architecture](oauth-architecture.md) - Deep OAuth protocol details
|
||||
- [ADR-004: Progressive Consent](ADR-004-mcp-application-oauth.md) - Dual OAuth flow architecture
|
||||
- [ADR-005: Token Audience Validation](ADR-005-token-audience-validation.md) - Audience validation strategy
|
||||
- [ADR-018: Nextcloud PHP App](ADR-018-nextcloud-php-app-for-settings-ui.md) - Astrolabe integration
|
||||
- [ADR-020: Deployment Modes](ADR-020-deployment-modes-and-configuration-validation.md) - Mode detection and validation
|
||||
@@ -0,0 +1,206 @@
|
||||
# Introducing Astrolabe: Navigate Your Data Universe in Nextcloud
|
||||
|
||||
Your Nextcloud instance holds years of notes, projects, recipes, contacts, and documents. But when you need to find something, you're stuck typing exact keywords and hoping for the best. Search "car repair" and miss that note titled "Vehicle maintenance tips." Search "meeting agenda" and overlook the calendar event called "Team sync." Traditional keyword search demands that you remember exactly how you wrote things down.
|
||||
|
||||
What if your search could understand what you *mean*, not just what you type?
|
||||
|
||||
Meet **Astrolabe**—a Nextcloud app that brings AI-powered semantic search to your self-hosted cloud. Named after the ancient navigational instrument that helped travelers chart courses by the stars, Astrolabe helps you navigate your personal knowledge by mapping the semantic connections between your documents.
|
||||
|
||||
## The Astrolabe Metaphor
|
||||
|
||||
The astrolabe was one of humanity's most elegant scientific instruments—an analog computer for solving problems related to time and the position of celestial bodies. Its theoretical foundation traces back to **Hipparchus of Nicaea** (c. 190–120 BCE), who discovered the stereographic projection that allows a three-dimensional celestial sphere to be represented on a flat surface. Later Greek scholars like **Theon of Alexandria** and his daughter **Hypatia** refined it into a practical instrument, and during the Islamic Golden Age, astronomers in Baghdad, Damascus, and Cordoba perfected its design and applications.
|
||||
|
||||
For nearly two millennia, astrolabes served astronomers, navigators, scholars, and religious officials across the Greek, Byzantine, Islamic, and medieval European worlds. These instruments allowed users to determine time, find celestial positions, calculate daylight hours, identify constellations, and even determine the direction of Mecca for prayer—all without complex calculations. The astrolabe made the vast complexity of the heavens understandable and navigable.
|
||||
|
||||
**Astrolabe** (the app) does the same for your data. Every document, note, and calendar event becomes a point of light in your personal data universe. The app maps their semantic relationships—their meaning, not just their words—and suddenly the connections become visible. Documents cluster by topic, related ideas sit nearby, and you can navigate this landscape as naturally as medieval scholars once read the stars. Where the original astrolabe projected the celestial sphere onto brass, this one projects your knowledge into explorable semantic space.
|
||||
|
||||
## Semantic Search: Find Meaning, Not Just Keywords
|
||||
|
||||
The core feature of Astrolabe is semantic search. Instead of matching exact keywords, it understands the concepts in your query and finds related content.
|
||||
|
||||
**What this looks like in practice:**
|
||||
|
||||
| You Search For | Traditional Search Finds | Astrolabe Also Finds |
|
||||
|----------------|--------------------------|----------------------|
|
||||
| "car repair" | Documents containing "car repair" | Notes about "vehicle maintenance," "fixing the truck" |
|
||||
| "team planning" | Documents with "team planning" | Calendar events titled "Q2 kickoff," Deck cards about "project roadmap" |
|
||||
| "pasta recipes" | Documents with "pasta recipes" | Notes about "Italian cooking," "homemade noodles," "carbonara tips" |
|
||||
|
||||
This works across multiple Nextcloud apps: Notes, Files (including PDFs with OCR), Deck cards, Calendar events, Contacts, and News/RSS items. One search bar, all your content, understood by meaning.
|
||||
|
||||
### Hybrid Search: Best of Both Worlds
|
||||
|
||||
Sometimes you want exact matches ("PROJ-2024-001"), sometimes you want semantic understanding ("that project from last year about authentication"). Astrolabe's hybrid search combines both approaches:
|
||||
|
||||
- **Semantic search** uses embeddings to find conceptually related content
|
||||
- **BM25 keyword search** finds exact matches and important terms
|
||||
- **Reciprocal Rank Fusion (RRF)** intelligently merges the results
|
||||
|
||||
You can adjust the balance or switch modes entirely depending on your needs.
|
||||
|
||||

|
||||
*Astrolabe results appear alongside traditional search in Nextcloud's unified search bar*
|
||||
|
||||
## Visualize Your Data Universe
|
||||
|
||||
Beyond search, Astrolabe includes an interactive 3D visualization that shows your documents positioned in semantic space. Similar documents cluster together. Topics form constellations. You can rotate, zoom, and explore.
|
||||
|
||||
This isn't just eye candy—it's a practical tool for knowledge discovery:
|
||||
|
||||
- **Find forgotten connections**: Search for your current project and watch as related documents from months ago light up nearby
|
||||
- **Spot topic clusters**: See how your notes naturally group by subject
|
||||
- **Explore the unknown**: Click on points near your search results to discover content you didn't know was related
|
||||
|
||||
The visualization uses Principal Component Analysis (PCA) to project high-dimensional embeddings (768 dimensions) down to 3D space while preserving the relationships between documents. We implemented a lightweight, custom PCA specifically for this—no heavyweight ML libraries required.
|
||||
|
||||

|
||||
*Documents cluster by semantic similarity. The query point (red) shows your search, and related documents cluster nearby*
|
||||
|
||||
## Power Your AI Agents
|
||||
|
||||
Astrolabe isn't just for humans—it's for your AI assistants too.
|
||||
|
||||
The backend runs a **Model Context Protocol (MCP)** server, which means AI tools like Claude Desktop, Cursor, or custom agents can connect directly to your Nextcloud data. Your AI assistant can:
|
||||
|
||||
- Search your notes semantically ("Find everything related to the Kubernetes migration")
|
||||
- Retrieve document content for context
|
||||
- Get AI-generated answers with citations from your documents (RAG)
|
||||
|
||||
The critical point: **your data never leaves your infrastructure**. The MCP server runs on your hardware. Your AI assistant sends queries, the server returns results, and you maintain full control. No documents uploaded to third-party services.
|
||||
|
||||
### Retrieval-Augmented Generation (RAG)
|
||||
|
||||
Ask a question, and Astrolabe can retrieve relevant documents and have your AI synthesize an answer—complete with citations:
|
||||
|
||||
```
|
||||
You: "What were the main issues we had deploying to production last month?"
|
||||
|
||||
Astrolabe finds: 3 relevant notes, 2 Deck cards, 1 calendar event
|
||||
|
||||
AI generates: "Based on your documents, there were three main issues:
|
||||
1. Database migration timeout (see Note: 'Prod deploy 2024-01-15')
|
||||
2. SSL certificate renewal (see Deck card: 'Ops Tasks')
|
||||
3. Resource limits on the new pods (see Note: 'K8s troubleshooting')
|
||||
```
|
||||
|
||||
This uses MCP's sampling capability—the server doesn't run its own LLM. Instead, it asks your client's AI to generate the response. You choose the model, you control the costs.
|
||||
|
||||
## Under the Hood
|
||||
|
||||
For the technically curious, here's how Astrolabe works:
|
||||
|
||||
### Embedding Providers
|
||||
|
||||
Astrolabe supports multiple backends for generating semantic embeddings:
|
||||
|
||||
- **Amazon Bedrock**: Enterprise-grade, Titan embeddings
|
||||
- **OpenAI**: Direct OpenAI API or compatible endpoints (including GitHub Models)
|
||||
- **Ollama**: Self-hosted, privacy-focused, runs entirely on your hardware
|
||||
|
||||
The system auto-detects available providers based on environment variables and falls back gracefully. Deploy Ollama on your server for full privacy, or use Bedrock for enterprise scale—same codebase, zero code changes.
|
||||
|
||||
### Background Indexing
|
||||
|
||||
Documents are indexed automatically via webhooks. When you create or edit a note, Nextcloud fires an event, and the MCP server processes it in the background. No manual sync required.
|
||||
|
||||
The indexing pipeline:
|
||||
1. **Scanner** detects changes via ETags and modification timestamps
|
||||
2. **Queue** manages backpressure (up to 10k pending documents)
|
||||
3. **Worker pool** processes embeddings concurrently (configurable, default 3 workers)
|
||||
4. **Qdrant** stores vectors for fast similarity search
|
||||
|
||||
### Lightweight by Design
|
||||
|
||||
We deliberately avoided heavyweight dependencies:
|
||||
|
||||
- **Custom PCA**: No scikit-learn, just efficient eigendecomposition
|
||||
- **In-process async**: No separate message queues or worker processes—just anyio TaskGroups
|
||||
- **Plugin architecture**: New apps (Notes, Calendar, etc.) are simple scanner/processor implementations
|
||||
|
||||
This means Astrolabe runs comfortably alongside your Nextcloud on modest hardware.
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌─────────────┐ ┌─────────┐
|
||||
│ Nextcloud │────▶│ MCP Server │────▶│ Qdrant │
|
||||
│ (Astrolabe) │◀────│ (Python) │◀────│ (Vectors)│
|
||||
└──────────────┘ └─────────────┘ └─────────┘
|
||||
│ │
|
||||
│ OAuth/Token │ Embeddings
|
||||
▼ ▼
|
||||
┌────────┐ ┌──────────┐
|
||||
│ User │ │ Ollama/ │
|
||||
│Browser │ │ Bedrock │
|
||||
└────────┘ └──────────┘
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Requirements
|
||||
|
||||
- Nextcloud 31 or 32
|
||||
- MCP server instance (Docker recommended)
|
||||
- Vector database (Qdrant, included in Docker setup)
|
||||
- Embedding provider (Ollama for self-hosted, or cloud options)
|
||||
|
||||
### Quick Setup
|
||||
|
||||
1. **Install the Astrolabe app** from the Nextcloud App Store (or manually)
|
||||
|
||||
2. **Start the MCP server** (Docker Compose makes this easy):
|
||||
```bash
|
||||
docker compose up -d mcp qdrant ollama
|
||||
```
|
||||
|
||||
3. **Configure the connection** in your Nextcloud `config.php`:
|
||||
```php
|
||||
'astrolabe' => [
|
||||
'mcp_server_url' => 'http://localhost:8000',
|
||||
],
|
||||
```
|
||||
|
||||
4. **Authorize access** in Settings → Personal → Astrolabe
|
||||
|
||||
5. **Start searching** using Nextcloud's unified search bar
|
||||
|
||||
For detailed setup instructions, including OAuth configuration and embedding provider options, see the [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server).
|
||||
|
||||
## What Can You Index?
|
||||
|
||||
Astrolabe currently supports:
|
||||
|
||||
| App | What Gets Indexed |
|
||||
|-----|-------------------|
|
||||
| **Notes** | Full text and metadata |
|
||||
| **Files** | PDFs (with OCR), DOCX, text files |
|
||||
| **Deck** | Card titles and descriptions |
|
||||
| **Calendar** | Event titles, descriptions, and details |
|
||||
| **Contacts** | Names, notes, and contact information |
|
||||
| **News** | RSS/Atom feed articles |
|
||||
|
||||
Each result shows the document type, relevance score, and a direct link to the source. For large documents, it shows which chunk (section) matched.
|
||||
|
||||

|
||||
*Click a result to see the matching chunk in context*
|
||||
|
||||
## Who Is This For?
|
||||
|
||||
**Researchers and students**: Find all notes related to your thesis topic, even when you used different terminology across semesters. Discover connections between papers you read months apart.
|
||||
|
||||
**Teams and organizations**: Surface institutional knowledge that would otherwise stay buried. New team members can search for concepts instead of knowing exactly what to look for.
|
||||
|
||||
**Developers**: Connect your AI coding assistant to your Nextcloud. Give it access to project notes, meeting records, and documentation without copy-pasting context.
|
||||
|
||||
**Personal knowledge managers**: Discover forgotten documents related to your current work. Watch your knowledge base evolve over time through the visualization.
|
||||
|
||||
## Try It Out
|
||||
|
||||
Astrolabe is open source (AGPL) and ready to use. Your data universe has been waiting in the dark—it's time to turn on the lights.
|
||||
|
||||
- **Install**: [Nextcloud App Store](https://apps.nextcloud.com/apps/astrolabe)
|
||||
- **Source**: [GitHub](https://github.com/cbcoutinho/nextcloud-mcp-server)
|
||||
- **Documentation**: [Setup Guide](https://github.com/cbcoutinho/nextcloud-mcp-server/tree/master/docs)
|
||||
- **Issues**: [Report bugs or request features](https://github.com/cbcoutinho/nextcloud-mcp-server/issues)
|
||||
|
||||
---
|
||||
|
||||
*Astrolabe is maintained by [Chris Coutinho](https://github.com/cbcoutinho). Contributions welcome.*
|
||||
@@ -255,18 +255,35 @@ class CalendarClient:
|
||||
"""List events in a calendar within date range."""
|
||||
calendar = self._get_calendar(calendar_name)
|
||||
|
||||
# Get all events using caldav library (now with proper filter)
|
||||
events = await calendar.events()
|
||||
if start_datetime or end_datetime:
|
||||
# Build CalDAV REPORT with time-range filter for server-side filtering
|
||||
events = await self._search_events_by_date(
|
||||
calendar, start_datetime, end_datetime
|
||||
)
|
||||
# Expand is only used when both bounds are provided
|
||||
expanded = bool(start_datetime and end_datetime)
|
||||
else:
|
||||
# No date filter — fetch all events
|
||||
events = await calendar.events()
|
||||
expanded = False
|
||||
|
||||
result = []
|
||||
for event in events:
|
||||
await event.load(only_if_unloaded=True)
|
||||
if event.data:
|
||||
event_dict = self._parse_ical_event(event.data)
|
||||
if event_dict:
|
||||
event_dict["href"] = str(event.url)
|
||||
event_dict["etag"] = ""
|
||||
result.append(event_dict)
|
||||
if expanded:
|
||||
# Server-side expansion: each response resource may contain
|
||||
# multiple VEVENTs (one per recurrence occurrence)
|
||||
for event_dict in self._parse_all_ical_events(event.data):
|
||||
event_dict["href"] = str(event.url)
|
||||
event_dict["etag"] = ""
|
||||
result.append(event_dict)
|
||||
else:
|
||||
event_dict = self._parse_ical_event(event.data)
|
||||
if event_dict:
|
||||
event_dict["href"] = str(event.url)
|
||||
event_dict["etag"] = ""
|
||||
result.append(event_dict)
|
||||
|
||||
if len(result) >= limit:
|
||||
break
|
||||
@@ -274,6 +291,57 @@ class CalendarClient:
|
||||
logger.debug(f"Found {len(result)} events")
|
||||
return result
|
||||
|
||||
async def _search_events_by_date(
|
||||
self,
|
||||
calendar: AsyncCalendar,
|
||||
start_datetime: Optional[dt.datetime] = None,
|
||||
end_datetime: Optional[dt.datetime] = None,
|
||||
) -> list:
|
||||
"""Execute a CalDAV REPORT with time-range filter."""
|
||||
from caldav.async_collection import AsyncEvent
|
||||
from caldav.elements import cdav, dav
|
||||
from lxml import etree # type: ignore[import-untyped]
|
||||
|
||||
# Ensure naive datetimes are treated as UTC
|
||||
if start_datetime and start_datetime.tzinfo is None:
|
||||
start_datetime = start_datetime.replace(tzinfo=dt.UTC)
|
||||
if end_datetime and end_datetime.tzinfo is None:
|
||||
end_datetime = end_datetime.replace(tzinfo=dt.UTC)
|
||||
|
||||
# Build comp-filter with time-range (mirrors sync Calendar.build_search_xml_query)
|
||||
inner_comp_filter = cdav.CompFilter(name="VEVENT")
|
||||
inner_comp_filter += cdav.TimeRange(start_datetime, end_datetime)
|
||||
outer_comp_filter = cdav.CompFilter(name="VCALENDAR") + inner_comp_filter
|
||||
filter_element = cdav.Filter() + outer_comp_filter
|
||||
|
||||
# When both bounds are provided, request server-side expansion of
|
||||
# recurring events (RFC 4791 §9.6.5). Each occurrence is returned as
|
||||
# a separate VEVENT with its own DTSTART, with RRULE stripped.
|
||||
data = cdav.CalendarData()
|
||||
if start_datetime and end_datetime:
|
||||
data += cdav.Expand(start_datetime, end_datetime)
|
||||
|
||||
query = cdav.CalendarQuery() + [dav.Prop() + data] + filter_element
|
||||
|
||||
body = etree.tostring(
|
||||
query.xmlelement(), encoding="utf-8", xml_declaration=True
|
||||
)
|
||||
assert calendar.client is not None
|
||||
response = await calendar.client.report(str(calendar.url), body, depth=1)
|
||||
|
||||
# Parse response (same pattern as AsyncCalendar.search)
|
||||
objects = []
|
||||
response_data = response.expand_simple_props([cdav.CalendarData()])
|
||||
for href, props in response_data.items():
|
||||
if href == str(calendar.url):
|
||||
continue
|
||||
cal_data = props.get(cdav.CalendarData.tag)
|
||||
if cal_data:
|
||||
obj = AsyncEvent(client=calendar.client, data=cal_data, parent=calendar)
|
||||
objects.append(obj)
|
||||
|
||||
return objects
|
||||
|
||||
async def create_event(
|
||||
self, calendar_name: str, event_data: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
@@ -583,7 +651,7 @@ class CalendarClient:
|
||||
# Add categories
|
||||
categories = event_data.get("categories", "")
|
||||
if categories:
|
||||
event.add("categories", categories.split(","))
|
||||
event.add("categories", [c.strip() for c in categories.split(",")])
|
||||
|
||||
# Add priority and status
|
||||
priority = event_data.get("priority", 5)
|
||||
@@ -633,75 +701,92 @@ class CalendarClient:
|
||||
cal.add_component(event)
|
||||
return cal.to_ical().decode("utf-8")
|
||||
|
||||
def _extract_vevent_data(self, component) -> Dict[str, Any]:
|
||||
"""Extract event data from a single VEVENT component.
|
||||
|
||||
Shared helper used by both _parse_ical_event() and _parse_all_ical_events().
|
||||
"""
|
||||
event_data: Dict[str, Any] = {
|
||||
"uid": str(component.get("uid", "")),
|
||||
"title": str(component.get("summary", "")),
|
||||
"description": str(component.get("description", "")),
|
||||
"location": str(component.get("location", "")),
|
||||
"status": str(component.get("status", "CONFIRMED")),
|
||||
"priority": int(component.get("priority", 5)),
|
||||
"privacy": str(component.get("class", "PUBLIC")),
|
||||
"url": str(component.get("url", "")),
|
||||
}
|
||||
|
||||
# Handle dates
|
||||
dtstart = component.get("dtstart")
|
||||
if dtstart:
|
||||
if isinstance(dtstart.dt, dt.date) and not isinstance(
|
||||
dtstart.dt, dt.datetime
|
||||
):
|
||||
event_data["start_datetime"] = dtstart.dt.isoformat()
|
||||
event_data["all_day"] = True
|
||||
else:
|
||||
event_data["start_datetime"] = dtstart.dt.isoformat()
|
||||
event_data["all_day"] = False
|
||||
|
||||
dtend = component.get("dtend")
|
||||
if dtend:
|
||||
if isinstance(dtend.dt, dt.date) and not isinstance(dtend.dt, dt.datetime):
|
||||
event_data["end_datetime"] = dtend.dt.isoformat()
|
||||
else:
|
||||
event_data["end_datetime"] = dtend.dt.isoformat()
|
||||
|
||||
# Handle categories
|
||||
categories = component.get("categories")
|
||||
if categories:
|
||||
event_data["categories"] = self._extract_categories(categories)
|
||||
|
||||
# Handle recurrence
|
||||
rrule = component.get("rrule")
|
||||
if rrule:
|
||||
event_data["recurring"] = True
|
||||
event_data["recurrence_rule"] = str(rrule)
|
||||
|
||||
# Handle attendees
|
||||
attendees = []
|
||||
for attendee in component.get("attendee", []):
|
||||
if isinstance(attendee, list):
|
||||
attendees.extend(str(a).replace("mailto:", "") for a in attendee)
|
||||
else:
|
||||
attendees.append(str(attendee).replace("mailto:", ""))
|
||||
if attendees:
|
||||
event_data["attendees"] = ",".join(attendees)
|
||||
|
||||
return event_data
|
||||
|
||||
def _parse_ical_event(self, ical_text: str) -> Optional[Dict[str, Any]]:
|
||||
"""Parse iCalendar text and extract event data."""
|
||||
"""Parse iCalendar text and extract the first event."""
|
||||
try:
|
||||
cal = Calendar.from_ical(ical_text)
|
||||
for component in cal.walk():
|
||||
if component.name == "VEVENT":
|
||||
event_data = {
|
||||
"uid": str(component.get("uid", "")),
|
||||
"title": str(component.get("summary", "")),
|
||||
"description": str(component.get("description", "")),
|
||||
"location": str(component.get("location", "")),
|
||||
"status": str(component.get("status", "CONFIRMED")),
|
||||
"priority": int(component.get("priority", 5)),
|
||||
"privacy": str(component.get("class", "PUBLIC")),
|
||||
"url": str(component.get("url", "")),
|
||||
}
|
||||
|
||||
# Handle dates
|
||||
dtstart = component.get("dtstart")
|
||||
if dtstart:
|
||||
if isinstance(dtstart.dt, dt.date) and not isinstance(
|
||||
dtstart.dt, dt.datetime
|
||||
):
|
||||
event_data["start_datetime"] = dtstart.dt.isoformat()
|
||||
event_data["all_day"] = True
|
||||
else:
|
||||
event_data["start_datetime"] = dtstart.dt.isoformat()
|
||||
event_data["all_day"] = False
|
||||
|
||||
dtend = component.get("dtend")
|
||||
if dtend:
|
||||
if isinstance(dtend.dt, dt.date) and not isinstance(
|
||||
dtend.dt, dt.datetime
|
||||
):
|
||||
event_data["end_datetime"] = dtend.dt.isoformat()
|
||||
else:
|
||||
event_data["end_datetime"] = dtend.dt.isoformat()
|
||||
|
||||
# Handle categories
|
||||
categories = component.get("categories")
|
||||
if categories:
|
||||
event_data["categories"] = self._extract_categories(categories)
|
||||
|
||||
# Handle recurrence
|
||||
rrule = component.get("rrule")
|
||||
if rrule:
|
||||
event_data["recurring"] = True
|
||||
event_data["recurrence_rule"] = str(rrule)
|
||||
|
||||
# Handle attendees
|
||||
attendees = []
|
||||
for attendee in component.get("attendee", []):
|
||||
if isinstance(attendee, list):
|
||||
attendees.extend(
|
||||
str(a).replace("mailto:", "") for a in attendee
|
||||
)
|
||||
else:
|
||||
attendees.append(str(attendee).replace("mailto:", ""))
|
||||
if attendees:
|
||||
event_data["attendees"] = ",".join(attendees)
|
||||
|
||||
return event_data
|
||||
|
||||
return self._extract_vevent_data(component)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing iCalendar event: {e}")
|
||||
return None
|
||||
|
||||
def _parse_all_ical_events(self, ical_text: str) -> list[Dict[str, Any]]:
|
||||
"""Parse iCalendar text and extract ALL event occurrences.
|
||||
|
||||
Used with server-side expansion where a single VCALENDAR contains
|
||||
multiple VEVENT components (one per recurrence occurrence).
|
||||
"""
|
||||
results: list[Dict[str, Any]] = []
|
||||
try:
|
||||
cal = Calendar.from_ical(ical_text)
|
||||
for component in cal.walk():
|
||||
if component.name == "VEVENT":
|
||||
results.append(self._extract_vevent_data(component))
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing iCalendar events: {e}")
|
||||
return results
|
||||
|
||||
def _merge_ical_properties(
|
||||
self, raw_ical: str, event_data: Dict[str, Any], event_uid: str
|
||||
) -> str:
|
||||
@@ -727,6 +812,50 @@ class CalendarClient:
|
||||
if "url" in event_data:
|
||||
component["URL"] = event_data["url"]
|
||||
|
||||
# Handle categories
|
||||
if "categories" in event_data:
|
||||
categories_str = event_data["categories"]
|
||||
if categories_str:
|
||||
component["CATEGORIES"] = [
|
||||
c.strip() for c in categories_str.split(",")
|
||||
]
|
||||
elif "CATEGORIES" in component:
|
||||
del component["CATEGORIES"]
|
||||
|
||||
# Handle recurrence rule
|
||||
if "recurrence_rule" in event_data:
|
||||
rrule_str = event_data["recurrence_rule"]
|
||||
if rrule_str:
|
||||
component["RRULE"] = vRecur.from_ical(rrule_str)
|
||||
elif "RRULE" in component:
|
||||
del component["RRULE"]
|
||||
|
||||
# Handle attendees
|
||||
if "attendees" in event_data:
|
||||
attendees_str = event_data["attendees"]
|
||||
# Remove all existing attendees first
|
||||
while "ATTENDEE" in component:
|
||||
del component["ATTENDEE"]
|
||||
if attendees_str:
|
||||
for email in attendees_str.split(","):
|
||||
if email.strip():
|
||||
component.add("attendee", f"mailto:{email.strip()}")
|
||||
|
||||
# Handle reminder (VALARM)
|
||||
if "reminder_minutes" in event_data:
|
||||
component.subcomponents = [
|
||||
sub
|
||||
for sub in component.subcomponents
|
||||
if sub.name != "VALARM"
|
||||
]
|
||||
minutes = event_data["reminder_minutes"]
|
||||
if minutes > 0:
|
||||
alarm = Alarm()
|
||||
alarm.add("action", "DISPLAY")
|
||||
alarm.add("description", "Event reminder")
|
||||
alarm.add("trigger", dt.timedelta(minutes=-minutes))
|
||||
component.add_component(alarm)
|
||||
|
||||
# Handle dates
|
||||
if "start_datetime" in event_data:
|
||||
start_str = event_data["start_datetime"]
|
||||
@@ -960,7 +1089,9 @@ class CalendarClient:
|
||||
if "categories" in todo_data:
|
||||
categories_str = todo_data["categories"]
|
||||
if categories_str:
|
||||
component["CATEGORIES"] = categories_str.split(",")
|
||||
component["CATEGORIES"] = [
|
||||
c.strip() for c in categories_str.split(",")
|
||||
]
|
||||
logger.debug(f"Set CATEGORIES to {categories_str}")
|
||||
|
||||
# Update timestamps
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.62.0"
|
||||
version = "0.63.4"
|
||||
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
||||
authors = [
|
||||
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
||||
@@ -114,7 +114,7 @@ caldav = { git = "https://github.com/cbcoutinho/caldav", branch = "feature/httpx
|
||||
qdrant-client = { git = "https://github.com/cbcoutinho/qdrant-client", branch = "fix/fusion-score-threshold" }
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.9.4,<0.10.0"]
|
||||
requires = ["uv_build>=0.10.0,<0.11.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[tool.uv.build-backend]
|
||||
|
||||
@@ -273,6 +273,86 @@ async def test_update_event(nc_client: NextcloudClient, temporary_event: dict):
|
||||
raise
|
||||
|
||||
|
||||
async def test_update_event_extended_fields(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test updating categories, recurrence_rule, attendees, and reminder_minutes."""
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
event_data = {
|
||||
"title": "Extended Fields Update Test",
|
||||
"start_datetime": tomorrow.strftime("%Y-%m-%dT10:00:00"),
|
||||
"end_datetime": tomorrow.strftime("%Y-%m-%dT11:00:00"),
|
||||
"description": "Base event for extended-field update test",
|
||||
}
|
||||
|
||||
event_uid = None
|
||||
try:
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created base event for extended fields test: {event_uid}")
|
||||
|
||||
# --- Phase 1: Set all four extended fields ---
|
||||
updated_data = {
|
||||
"categories": "work,meeting",
|
||||
"recurrence_rule": "FREQ=WEEKLY;COUNT=4",
|
||||
"attendees": "alice@example.com,bob@example.com",
|
||||
"reminder_minutes": 15,
|
||||
}
|
||||
await nc_client.calendar.update_event(calendar_name, event_uid, updated_data)
|
||||
|
||||
retrieved, _ = await nc_client.calendar.get_event(calendar_name, event_uid)
|
||||
|
||||
# Verify categories
|
||||
assert "work" in retrieved.get("categories", "")
|
||||
assert "meeting" in retrieved.get("categories", "")
|
||||
|
||||
# Verify recurrence rule
|
||||
assert retrieved.get("recurring") is True
|
||||
assert "WEEKLY" in retrieved.get("recurrence_rule", "")
|
||||
|
||||
# Verify attendees
|
||||
attendees = retrieved.get("attendees", "")
|
||||
assert "alice@example.com" in attendees
|
||||
assert "bob@example.com" in attendees
|
||||
|
||||
logger.info("Phase 1 passed: all extended fields set correctly")
|
||||
|
||||
# --- Phase 2: Clear all four extended fields ---
|
||||
cleared_data = {
|
||||
"categories": "",
|
||||
"recurrence_rule": "",
|
||||
"attendees": "",
|
||||
"reminder_minutes": 0,
|
||||
}
|
||||
await nc_client.calendar.update_event(calendar_name, event_uid, cleared_data)
|
||||
|
||||
cleared, _ = await nc_client.calendar.get_event(calendar_name, event_uid)
|
||||
|
||||
# Verify categories cleared
|
||||
assert not cleared.get("categories")
|
||||
|
||||
# Verify recurrence cleared
|
||||
assert cleared.get("recurring") is not True
|
||||
assert not cleared.get("recurrence_rule")
|
||||
|
||||
# Verify attendees cleared
|
||||
assert not cleared.get("attendees")
|
||||
|
||||
logger.info("Phase 2 passed: all extended fields cleared correctly")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Extended fields update test failed: {e}")
|
||||
raise
|
||||
finally:
|
||||
if event_uid:
|
||||
try:
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_create_event_with_attendees(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
@@ -380,6 +460,177 @@ async def test_event_with_url_and_categories(
|
||||
raise
|
||||
|
||||
|
||||
async def test_list_events_date_range_filtering(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test that date range filtering actually excludes events outside the range.
|
||||
|
||||
Reproduces GH-538: get_calendar_events() accepted date range parameters
|
||||
but returned events from the entire calendar history, ignoring date filters.
|
||||
"""
|
||||
calendar_name = temporary_calendar
|
||||
past_uid = None
|
||||
future_uid = None
|
||||
|
||||
try:
|
||||
# Create Event A: 30 days in the past
|
||||
past_date = datetime.now() - timedelta(days=30)
|
||||
past_event_data = {
|
||||
"title": f"Past Event {uuid.uuid4().hex[:8]}",
|
||||
"start_datetime": past_date.strftime("%Y-%m-%dT10:00:00"),
|
||||
"end_datetime": past_date.strftime("%Y-%m-%dT11:00:00"),
|
||||
"description": "Event in the past for date range test",
|
||||
}
|
||||
result_past = await nc_client.calendar.create_event(
|
||||
calendar_name, past_event_data
|
||||
)
|
||||
past_uid = result_past["uid"]
|
||||
logger.info(f"Created past event: {past_uid}")
|
||||
|
||||
# Create Event B: 1 day in the future
|
||||
future_date = datetime.now() + timedelta(days=1)
|
||||
future_event_data = {
|
||||
"title": f"Future Event {uuid.uuid4().hex[:8]}",
|
||||
"start_datetime": future_date.strftime("%Y-%m-%dT14:00:00"),
|
||||
"end_datetime": future_date.strftime("%Y-%m-%dT15:00:00"),
|
||||
"description": "Event in the future for date range test",
|
||||
}
|
||||
result_future = await nc_client.calendar.create_event(
|
||||
calendar_name, future_event_data
|
||||
)
|
||||
future_uid = result_future["uid"]
|
||||
logger.info(f"Created future event: {future_uid}")
|
||||
|
||||
# Query with date range: today → 7 days ahead
|
||||
now = datetime.now()
|
||||
week_ahead = now + timedelta(days=7)
|
||||
|
||||
events = await nc_client.calendar.get_calendar_events(
|
||||
calendar_name=calendar_name,
|
||||
start_datetime=now,
|
||||
end_datetime=week_ahead,
|
||||
limit=50,
|
||||
)
|
||||
|
||||
event_uids = [e["uid"] for e in events]
|
||||
|
||||
# Future event (tomorrow) SHOULD be in results
|
||||
assert future_uid in event_uids, (
|
||||
f"Future event {future_uid} should be in date-filtered results"
|
||||
)
|
||||
|
||||
# Past event (30 days ago) should NOT be in results
|
||||
assert past_uid not in event_uids, (
|
||||
f"Past event {past_uid} should be excluded by date range filter "
|
||||
f"(GH-538: date range was being ignored)"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Date range filtering works: {len(events)} events returned, "
|
||||
f"past event correctly excluded"
|
||||
)
|
||||
|
||||
finally:
|
||||
# Cleanup both events
|
||||
for uid in [past_uid, future_uid]:
|
||||
if uid:
|
||||
try:
|
||||
await nc_client.calendar.delete_event(calendar_name, uid)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cleanup failed for event {uid}: {e}")
|
||||
|
||||
|
||||
async def test_recurring_event_date_range_expansion(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test that recurring events are expanded into individual occurrences.
|
||||
|
||||
When querying with a date range, a recurring event should return one
|
||||
event dict per occurrence within the range, each with the correct
|
||||
start_datetime for that occurrence (not the original master event date).
|
||||
|
||||
This is a follow-up to GH-538: the time-range filter correctly selected
|
||||
recurring events, but returned the master event with its original DTSTART
|
||||
instead of expanding occurrences.
|
||||
"""
|
||||
calendar_name = temporary_calendar
|
||||
event_uid = None
|
||||
|
||||
try:
|
||||
# Create a daily recurring event starting 7 days ago
|
||||
start = datetime.now() - timedelta(days=7)
|
||||
event_data = {
|
||||
"title": f"Daily Recurrence {uuid.uuid4().hex[:8]}",
|
||||
"start_datetime": start.strftime("%Y-%m-%dT09:00:00"),
|
||||
"end_datetime": start.strftime("%Y-%m-%dT10:00:00"),
|
||||
"description": "Daily recurring event for expansion test",
|
||||
"recurring": True,
|
||||
"recurrence_rule": "FREQ=DAILY",
|
||||
}
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created daily recurring event: {event_uid}")
|
||||
|
||||
# Query with date range: today → 3 days ahead
|
||||
query_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
query_end = query_start + timedelta(days=3)
|
||||
|
||||
events = await nc_client.calendar.get_calendar_events(
|
||||
calendar_name=calendar_name,
|
||||
start_datetime=query_start,
|
||||
end_datetime=query_end,
|
||||
limit=50,
|
||||
)
|
||||
|
||||
# Filter to only our recurring event (calendar may have others)
|
||||
our_events = [e for e in events if e["uid"] == event_uid]
|
||||
|
||||
# Should have multiple occurrences (one per day in the range)
|
||||
assert len(our_events) >= 2, (
|
||||
f"Expected multiple expanded occurrences, got {len(our_events)}. "
|
||||
f"Expansion may not be working."
|
||||
)
|
||||
|
||||
# Each occurrence should have a different start_datetime
|
||||
start_dates = [e["start_datetime"] for e in our_events]
|
||||
assert len(set(start_dates)) == len(our_events), (
|
||||
f"Each occurrence should have a unique start_datetime, got: {start_dates}"
|
||||
)
|
||||
|
||||
# No start_datetime should fall outside the queried range
|
||||
for e in our_events:
|
||||
event_start = datetime.fromisoformat(e["start_datetime"])
|
||||
# Remove timezone info for comparison if present
|
||||
if event_start.tzinfo is not None:
|
||||
event_start = event_start.replace(tzinfo=None)
|
||||
assert event_start >= query_start - timedelta(hours=1), (
|
||||
f"Occurrence {e['start_datetime']} is before query start {query_start}"
|
||||
)
|
||||
assert event_start < query_end + timedelta(hours=1), (
|
||||
f"Occurrence {e['start_datetime']} is after query end {query_end}"
|
||||
)
|
||||
|
||||
# Expanded occurrences should NOT have recurrence rules
|
||||
# (server strips RRULE when expanding)
|
||||
for e in our_events:
|
||||
assert not e.get("recurring"), (
|
||||
"Expanded occurrence should not have recurring=True, "
|
||||
"RRULE should be stripped by server-side expansion"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Recurring event expansion works: {len(our_events)} occurrences "
|
||||
f"returned with unique start dates"
|
||||
)
|
||||
|
||||
finally:
|
||||
if event_uid:
|
||||
try:
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cleanup failed for recurring event {event_uid}: {e}")
|
||||
|
||||
|
||||
async def test_calendar_operations_error_handling(
|
||||
nc_client: NextcloudClient,
|
||||
):
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Integration tests for Calendar VEVENT update MCP tools - extended fields."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from mcp import ClientSession
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def test_mcp_update_event_extended_fields(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test updating categories, recurrence_rule, attendees, and reminder_minutes via MCP."""
|
||||
|
||||
calendar_name = temporary_calendar
|
||||
event_uid = None
|
||||
|
||||
try:
|
||||
# 1. Create a base event via MCP
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
create_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_create_event",
|
||||
{
|
||||
"calendar_name": calendar_name,
|
||||
"title": "Extended Fields MCP Test",
|
||||
"start_datetime": tomorrow.strftime("%Y-%m-%dT14:00:00"),
|
||||
"end_datetime": tomorrow.strftime("%Y-%m-%dT15:00:00"),
|
||||
"description": "Base event for MCP extended-field update test",
|
||||
},
|
||||
)
|
||||
assert create_result.isError is False, (
|
||||
f"MCP event creation failed: {create_result.content}"
|
||||
)
|
||||
|
||||
result_data = json.loads(create_result.content[0].text)
|
||||
event_uid = result_data["uid"]
|
||||
logger.info(f"Created base event via MCP: {event_uid}")
|
||||
|
||||
# 2. Update with all four extended fields via MCP
|
||||
update_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_update_event",
|
||||
{
|
||||
"calendar_name": calendar_name,
|
||||
"event_uid": event_uid,
|
||||
"categories": "work,meeting",
|
||||
"recurrence_rule": "FREQ=WEEKLY;COUNT=4",
|
||||
"attendees": "alice@example.com,bob@example.com",
|
||||
"reminder_minutes": 15,
|
||||
},
|
||||
)
|
||||
assert update_result.isError is False, (
|
||||
f"MCP event update failed: {update_result.content}"
|
||||
)
|
||||
|
||||
# 3. Verify via direct client
|
||||
event, _ = await nc_client.calendar.get_event(calendar_name, event_uid)
|
||||
|
||||
# Categories
|
||||
assert "work" in event.get("categories", ""), (
|
||||
f"Expected 'work' in categories, got: {event.get('categories')}"
|
||||
)
|
||||
assert "meeting" in event.get("categories", ""), (
|
||||
f"Expected 'meeting' in categories, got: {event.get('categories')}"
|
||||
)
|
||||
|
||||
# Recurrence
|
||||
assert event.get("recurring") is True, "Expected event to be recurring"
|
||||
assert "WEEKLY" in event.get("recurrence_rule", ""), (
|
||||
f"Expected WEEKLY in rrule, got: {event.get('recurrence_rule')}"
|
||||
)
|
||||
|
||||
# Attendees
|
||||
attendees = event.get("attendees", "")
|
||||
assert "alice@example.com" in attendees, (
|
||||
f"Expected alice in attendees, got: {attendees}"
|
||||
)
|
||||
assert "bob@example.com" in attendees, (
|
||||
f"Expected bob in attendees, got: {attendees}"
|
||||
)
|
||||
|
||||
logger.info("MCP extended fields update verified successfully")
|
||||
|
||||
# 4. Clear all four fields via MCP
|
||||
clear_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_update_event",
|
||||
{
|
||||
"calendar_name": calendar_name,
|
||||
"event_uid": event_uid,
|
||||
"categories": "",
|
||||
"recurrence_rule": "",
|
||||
"attendees": "",
|
||||
"reminder_minutes": 0,
|
||||
},
|
||||
)
|
||||
assert clear_result.isError is False, (
|
||||
f"MCP event clear failed: {clear_result.content}"
|
||||
)
|
||||
|
||||
# 5. Verify fields cleared
|
||||
cleared, _ = await nc_client.calendar.get_event(calendar_name, event_uid)
|
||||
assert not cleared.get("categories"), (
|
||||
f"Expected categories cleared, got: {cleared.get('categories')}"
|
||||
)
|
||||
assert cleared.get("recurring") is not True, (
|
||||
f"Expected recurring cleared, got: {cleared.get('recurring')}"
|
||||
)
|
||||
assert not cleared.get("attendees"), (
|
||||
f"Expected attendees cleared, got: {cleared.get('attendees')}"
|
||||
)
|
||||
|
||||
logger.info("MCP extended fields clear verified successfully")
|
||||
|
||||
finally:
|
||||
if event_uid:
|
||||
try:
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
except Exception:
|
||||
pass
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
[tool.commitizen]
|
||||
name = "cz_conventional_commits"
|
||||
version = "0.8.3"
|
||||
version = "0.10.1"
|
||||
tag_format = "astrolabe-v$version"
|
||||
version_scheme = "semver"
|
||||
update_changelog_on_bump = true
|
||||
|
||||
Vendored
+40
@@ -25,6 +25,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Requires external MCP server deployment
|
||||
- See documentation for setup: https://github.com/cbcoutinho/nextcloud-mcp-server
|
||||
|
||||
## astrolabe-v0.10.1 (2026-02-03)
|
||||
|
||||
### Fix
|
||||
|
||||
- **helm**: add backward compatibility for legacy persistence configs
|
||||
|
||||
## astrolabe-v0.10.0 (2026-01-28)
|
||||
|
||||
### Feat
|
||||
|
||||
- **astrolabe**: add background token refresh job
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: add pagination and psalm fixes for token refresh
|
||||
- **astrolabe**: add locking to prevent token refresh race condition
|
||||
- **astrolabe**: add issued_at to on-demand token refresh
|
||||
|
||||
## astrolabe-v0.9.0 (2026-01-26)
|
||||
|
||||
### Feat
|
||||
|
||||
- **scripts**: add database query helpers for development
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: resolve Psalm type errors in PDF preview code
|
||||
- **astrolabe**: fix Psalm baseline and ESLint import order
|
||||
- **astrolabe**: load pdfjs-dist externally to fix PDF viewer
|
||||
- **astrolabe**: improve error messages for authorization issues
|
||||
- **astrolabe**: rename OAuthController and fix app password check
|
||||
- **tests**: improve Astrolabe integration test reliability
|
||||
- **astrolabe**: update Plotly title attributes for v3 compatibility
|
||||
- **deps**: update dependency plotly.js-dist-min to v3
|
||||
|
||||
### Refactor
|
||||
|
||||
- **api**: split management.py into domain-focused modules
|
||||
- **astrolabe**: replace client-side PDF.js with server-side PyMuPDF rendering
|
||||
|
||||
## astrolabe-v0.8.3 (2026-01-17)
|
||||
|
||||
### Fix
|
||||
|
||||
+4
-1
@@ -29,7 +29,7 @@ Astrolabe connects to a semantic search service that understands the meaning of
|
||||
|
||||
See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for configuration details.
|
||||
]]></description>
|
||||
<version>0.8.3</version>
|
||||
<version>0.10.1</version>
|
||||
<licence>agpl</licence>
|
||||
<author homepage="https://github.com/cbcoutinho">Chris Coutinho</author>
|
||||
<namespace>Astrolabe</namespace>
|
||||
@@ -57,4 +57,7 @@ See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for conf
|
||||
<type>link</type>
|
||||
</navigation>
|
||||
</navigations>
|
||||
<background-jobs>
|
||||
<job>OCA\Astrolabe\BackgroundJob\RefreshUserTokens</job>
|
||||
</background-jobs>
|
||||
</info>
|
||||
|
||||
Vendored
+1
@@ -39,6 +39,7 @@
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/dbal": "^3.8",
|
||||
"nextcloud/ocp": "dev-stable30",
|
||||
"phpunit/phpunit": "^10.0",
|
||||
"roave/security-advisories": "dev-latest"
|
||||
|
||||
+316
-14
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "94a9d7f7619235ef2a310deec2ce14f0",
|
||||
"content-hash": "e6ea5a770c578a5d7694602bb2618cef",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bamarni/composer-bin-plugin",
|
||||
@@ -65,6 +65,259 @@
|
||||
}
|
||||
],
|
||||
"packages-dev": [
|
||||
{
|
||||
"name": "doctrine/dbal",
|
||||
"version": "3.10.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/doctrine/dbal.git",
|
||||
"reference": "63a46cb5aa6f60991186cc98c1d1b50c09311868"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/doctrine/dbal/zipball/63a46cb5aa6f60991186cc98c1d1b50c09311868",
|
||||
"reference": "63a46cb5aa6f60991186cc98c1d1b50c09311868",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"composer-runtime-api": "^2",
|
||||
"doctrine/deprecations": "^0.5.3|^1",
|
||||
"doctrine/event-manager": "^1|^2",
|
||||
"php": "^7.4 || ^8.0",
|
||||
"psr/cache": "^1|^2|^3",
|
||||
"psr/log": "^1|^2|^3"
|
||||
},
|
||||
"conflict": {
|
||||
"doctrine/cache": "< 1.11"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/cache": "^1.11|^2.0",
|
||||
"doctrine/coding-standard": "14.0.0",
|
||||
"fig/log-test": "^1",
|
||||
"jetbrains/phpstorm-stubs": "2023.1",
|
||||
"phpstan/phpstan": "2.1.30",
|
||||
"phpstan/phpstan-strict-rules": "^2",
|
||||
"phpunit/phpunit": "9.6.29",
|
||||
"slevomat/coding-standard": "8.24.0",
|
||||
"squizlabs/php_codesniffer": "4.0.0",
|
||||
"symfony/cache": "^5.4|^6.0|^7.0|^8.0",
|
||||
"symfony/console": "^4.4|^5.4|^6.0|^7.0|^8.0"
|
||||
},
|
||||
"suggest": {
|
||||
"symfony/console": "For helpful console commands such as SQL execution and import of files."
|
||||
},
|
||||
"bin": [
|
||||
"bin/doctrine-dbal"
|
||||
],
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Doctrine\\DBAL\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Guilherme Blanco",
|
||||
"email": "guilhermeblanco@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Roman Borschel",
|
||||
"email": "roman@code-factory.org"
|
||||
},
|
||||
{
|
||||
"name": "Benjamin Eberlei",
|
||||
"email": "kontakt@beberlei.de"
|
||||
},
|
||||
{
|
||||
"name": "Jonathan Wage",
|
||||
"email": "jonwage@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.",
|
||||
"homepage": "https://www.doctrine-project.org/projects/dbal.html",
|
||||
"keywords": [
|
||||
"abstraction",
|
||||
"database",
|
||||
"db2",
|
||||
"dbal",
|
||||
"mariadb",
|
||||
"mssql",
|
||||
"mysql",
|
||||
"oci8",
|
||||
"oracle",
|
||||
"pdo",
|
||||
"pgsql",
|
||||
"postgresql",
|
||||
"queryobject",
|
||||
"sasql",
|
||||
"sql",
|
||||
"sqlite",
|
||||
"sqlserver",
|
||||
"sqlsrv"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/doctrine/dbal/issues",
|
||||
"source": "https://github.com/doctrine/dbal/tree/3.10.4"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://www.doctrine-project.org/sponsorship.html",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/phpdoctrine",
|
||||
"type": "patreon"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-11-29T10:46:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/deprecations",
|
||||
"version": "1.1.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/doctrine/deprecations.git",
|
||||
"reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38",
|
||||
"reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"phpunit/phpunit": "<=7.5 || >=13"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/coding-standard": "^9 || ^12 || ^13",
|
||||
"phpstan/phpstan": "1.4.10 || 2.1.11",
|
||||
"phpstan/phpstan-phpunit": "^1.0 || ^2",
|
||||
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12",
|
||||
"psr/log": "^1 || ^2 || ^3"
|
||||
},
|
||||
"suggest": {
|
||||
"psr/log": "Allows logging deprecations via PSR-3 logger implementation"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Doctrine\\Deprecations\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.",
|
||||
"homepage": "https://www.doctrine-project.org/",
|
||||
"support": {
|
||||
"issues": "https://github.com/doctrine/deprecations/issues",
|
||||
"source": "https://github.com/doctrine/deprecations/tree/1.1.5"
|
||||
},
|
||||
"time": "2025-04-07T20:06:18+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/event-manager",
|
||||
"version": "2.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/doctrine/event-manager.git",
|
||||
"reference": "c07799fcf5ad362050960a0fd068dded40b1e312"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/doctrine/event-manager/zipball/c07799fcf5ad362050960a0fd068dded40b1e312",
|
||||
"reference": "c07799fcf5ad362050960a0fd068dded40b1e312",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.1"
|
||||
},
|
||||
"conflict": {
|
||||
"doctrine/common": "<2.9"
|
||||
},
|
||||
"require-dev": {
|
||||
"doctrine/coding-standard": "^14",
|
||||
"phpdocumentor/guides-cli": "^1.4",
|
||||
"phpstan/phpstan": "^2.1.32",
|
||||
"phpunit/phpunit": "^10.5.58"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Doctrine\\Common\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Guilherme Blanco",
|
||||
"email": "guilhermeblanco@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Roman Borschel",
|
||||
"email": "roman@code-factory.org"
|
||||
},
|
||||
{
|
||||
"name": "Benjamin Eberlei",
|
||||
"email": "kontakt@beberlei.de"
|
||||
},
|
||||
{
|
||||
"name": "Jonathan Wage",
|
||||
"email": "jonwage@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Johannes Schmitt",
|
||||
"email": "schmittjoh@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Marco Pivetta",
|
||||
"email": "ocramius@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.",
|
||||
"homepage": "https://www.doctrine-project.org/projects/event-manager.html",
|
||||
"keywords": [
|
||||
"event",
|
||||
"event dispatcher",
|
||||
"event manager",
|
||||
"event system",
|
||||
"events"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/doctrine/event-manager/issues",
|
||||
"source": "https://github.com/doctrine/event-manager/tree/2.1.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://www.doctrine-project.org/sponsorship.html",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://www.patreon.com/phpdoctrine",
|
||||
"type": "patreon"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-01-17T22:40:21+00:00"
|
||||
},
|
||||
{
|
||||
"name": "myclabs/deep-copy",
|
||||
"version": "1.13.4",
|
||||
@@ -668,16 +921,16 @@
|
||||
},
|
||||
{
|
||||
"name": "phpunit/phpunit",
|
||||
"version": "10.5.60",
|
||||
"version": "10.5.63",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
||||
"reference": "f2e26f52f80ef77832e359205f216eeac00e320c"
|
||||
"reference": "33198268dad71e926626b618f3ec3966661e4d90"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f2e26f52f80ef77832e359205f216eeac00e320c",
|
||||
"reference": "f2e26f52f80ef77832e359205f216eeac00e320c",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/33198268dad71e926626b618f3ec3966661e4d90",
|
||||
"reference": "33198268dad71e926626b618f3ec3966661e4d90",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -698,7 +951,7 @@
|
||||
"phpunit/php-timer": "^6.0.0",
|
||||
"sebastian/cli-parser": "^2.0.1",
|
||||
"sebastian/code-unit": "^2.0.0",
|
||||
"sebastian/comparator": "^5.0.4",
|
||||
"sebastian/comparator": "^5.0.5",
|
||||
"sebastian/diff": "^5.1.1",
|
||||
"sebastian/environment": "^6.1.0",
|
||||
"sebastian/exporter": "^5.1.4",
|
||||
@@ -749,7 +1002,7 @@
|
||||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
||||
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.60"
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.63"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -773,7 +1026,56 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-06T07:50:42+00:00"
|
||||
"time": "2026-01-27T05:48:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/cache",
|
||||
"version": "3.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/php-fig/cache.git",
|
||||
"reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
|
||||
"reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.0.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.0.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Psr\\Cache\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "PHP-FIG",
|
||||
"homepage": "https://www.php-fig.org/"
|
||||
}
|
||||
],
|
||||
"description": "Common interface for caching libraries",
|
||||
"keywords": [
|
||||
"cache",
|
||||
"psr",
|
||||
"psr-6"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/php-fig/cache/tree/3.0.0"
|
||||
},
|
||||
"time": "2021-02-03T23:26:27+00:00"
|
||||
},
|
||||
{
|
||||
"name": "psr/clock",
|
||||
@@ -2150,16 +2452,16 @@
|
||||
},
|
||||
{
|
||||
"name": "sebastian/comparator",
|
||||
"version": "5.0.4",
|
||||
"version": "5.0.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/comparator.git",
|
||||
"reference": "e8e53097718d2b53cfb2aa859b06a41abf58c62e"
|
||||
"reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e8e53097718d2b53cfb2aa859b06a41abf58c62e",
|
||||
"reference": "e8e53097718d2b53cfb2aa859b06a41abf58c62e",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55dfef806eb7dfeb6e7a6935601fef866f8ca48d",
|
||||
"reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2215,7 +2517,7 @@
|
||||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/comparator/issues",
|
||||
"security": "https://github.com/sebastianbergmann/comparator/security/policy",
|
||||
"source": "https://github.com/sebastianbergmann/comparator/tree/5.0.4"
|
||||
"source": "https://github.com/sebastianbergmann/comparator/tree/5.0.5"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -2235,7 +2537,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-09-07T05:25:07+00:00"
|
||||
"time": "2026-01-24T09:25:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/complexity",
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\BackgroundJob;
|
||||
|
||||
use OCA\Astrolabe\Service\IdpTokenRefresher;
|
||||
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\BackgroundJob\IJob;
|
||||
use OCP\BackgroundJob\TimedJob;
|
||||
use OCP\Lock\LockedException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Background job to proactively refresh OAuth tokens before expiration.
|
||||
*
|
||||
* Runs every 15 minutes and refreshes tokens based on their actual expiration
|
||||
* time. Works with any IdP (Nextcloud OIDC, Keycloak, etc.) since it uses
|
||||
* the real token expiration rather than IdP configuration.
|
||||
*
|
||||
* Refresh strategy: Refresh when less than 50% of token lifetime remains,
|
||||
* ensuring tokens are refreshed well before expiration regardless of the
|
||||
* IdP's configured token lifetime.
|
||||
*
|
||||
* @psalm-suppress UnusedClass - Background jobs are loaded dynamically by Nextcloud
|
||||
*/
|
||||
class RefreshUserTokens extends TimedJob {
|
||||
/** Job runs every 15 minutes */
|
||||
private const JOB_INTERVAL_SECONDS = 900;
|
||||
|
||||
/** Refresh when this percentage of token lifetime remains */
|
||||
private const REFRESH_AT_REMAINING_PERCENT = 0.5;
|
||||
|
||||
/** Minimum threshold to avoid constant refresh (5 minutes) */
|
||||
private const MIN_THRESHOLD_SECONDS = 300;
|
||||
|
||||
/** Default assumed token lifetime if we can't determine it (1 hour) */
|
||||
private const DEFAULT_TOKEN_LIFETIME_SECONDS = 3600;
|
||||
|
||||
/** Batch size for processing users (prevents memory issues on large installations) */
|
||||
private const BATCH_SIZE = 100;
|
||||
|
||||
public function __construct(
|
||||
ITimeFactory $time,
|
||||
private McpTokenStorage $tokenStorage,
|
||||
private IdpTokenRefresher $tokenRefresher,
|
||||
private LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct($time);
|
||||
$this->setInterval(self::JOB_INTERVAL_SECONDS);
|
||||
$this->setTimeSensitivity(IJob::TIME_INSENSITIVE);
|
||||
}
|
||||
|
||||
protected function run(mixed $argument): void {
|
||||
$this->logger->info('RefreshUserTokens: Starting background token refresh');
|
||||
|
||||
$refreshed = 0;
|
||||
$failed = 0;
|
||||
$skipped = 0;
|
||||
$offset = 0;
|
||||
$totalUsers = 0;
|
||||
|
||||
// Process users in batches to prevent memory issues on large installations
|
||||
do {
|
||||
$userIds = $this->tokenStorage->getAllUsersWithTokens(self::BATCH_SIZE, $offset);
|
||||
$batchCount = count($userIds);
|
||||
$totalUsers += $batchCount;
|
||||
|
||||
foreach ($userIds as $userId) {
|
||||
$result = $this->refreshUserTokenIfNeeded($userId);
|
||||
match ($result) {
|
||||
'refreshed' => $refreshed++,
|
||||
'failed' => $failed++,
|
||||
'skipped' => $skipped++,
|
||||
};
|
||||
}
|
||||
|
||||
$offset += self::BATCH_SIZE;
|
||||
} while ($batchCount === self::BATCH_SIZE);
|
||||
|
||||
$this->logger->info("RefreshUserTokens: Complete - total=$totalUsers, refreshed=$refreshed, failed=$failed, skipped=$skipped");
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh a user's token if it's nearing expiration.
|
||||
*
|
||||
* Calculates the refresh threshold based on the token's actual lifetime,
|
||||
* refreshing when less than 50% of the lifetime remains.
|
||||
*
|
||||
* Uses locking to prevent race conditions with on-demand refresh in
|
||||
* getAccessToken(). If lock cannot be acquired, skips this user since
|
||||
* on-demand refresh is already handling it.
|
||||
*
|
||||
* @return string 'refreshed', 'failed', or 'skipped'
|
||||
*/
|
||||
private function refreshUserTokenIfNeeded(string $userId): string {
|
||||
$token = $this->tokenStorage->getUserToken($userId);
|
||||
|
||||
if ($token === null) {
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
$expiresAt = (int)($token['expires_at'] ?? 0);
|
||||
$issuedAt = isset($token['issued_at']) ? (int)$token['issued_at'] : null;
|
||||
$timeRemaining = $expiresAt - time();
|
||||
|
||||
// Calculate token lifetime from stored data or use default
|
||||
if ($issuedAt !== null) {
|
||||
$tokenLifetime = $expiresAt - $issuedAt;
|
||||
} else {
|
||||
// Fallback: use default lifetime assumption
|
||||
$tokenLifetime = self::DEFAULT_TOKEN_LIFETIME_SECONDS;
|
||||
}
|
||||
|
||||
// Calculate threshold: refresh when 50% of lifetime remains
|
||||
$threshold = max(
|
||||
(int)($tokenLifetime * self::REFRESH_AT_REMAINING_PERCENT),
|
||||
self::MIN_THRESHOLD_SECONDS
|
||||
);
|
||||
|
||||
if ($timeRemaining > $threshold) {
|
||||
// Token still has plenty of time, skip
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
// Token is expiring soon, attempt refresh with lock
|
||||
try {
|
||||
return $this->tokenStorage->withTokenLock($userId, function () use ($userId) {
|
||||
// Re-check token after acquiring lock (double-check pattern)
|
||||
// Another process may have refreshed while we waited for lock
|
||||
$currentToken = $this->tokenStorage->getUserToken($userId);
|
||||
|
||||
if ($currentToken === null) {
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
// Recalculate threshold with current token data
|
||||
$currentExpiresAt = (int)($currentToken['expires_at'] ?? 0);
|
||||
$currentIssuedAt = isset($currentToken['issued_at']) ? (int)$currentToken['issued_at'] : null;
|
||||
$currentTimeRemaining = $currentExpiresAt - time();
|
||||
|
||||
if ($currentIssuedAt !== null) {
|
||||
$currentTokenLifetime = $currentExpiresAt - $currentIssuedAt;
|
||||
} else {
|
||||
$currentTokenLifetime = self::DEFAULT_TOKEN_LIFETIME_SECONDS;
|
||||
}
|
||||
|
||||
$currentThreshold = max(
|
||||
(int)($currentTokenLifetime * self::REFRESH_AT_REMAINING_PERCENT),
|
||||
self::MIN_THRESHOLD_SECONDS
|
||||
);
|
||||
|
||||
if ($currentTimeRemaining > $currentThreshold) {
|
||||
// Token was refreshed by another process while we waited
|
||||
$this->logger->debug("RefreshUserTokens: Token already refreshed for user $userId while waiting for lock");
|
||||
return 'skipped';
|
||||
}
|
||||
|
||||
// Still needs refresh, proceed
|
||||
if (!isset($currentToken['refresh_token'])) {
|
||||
$this->logger->warning("RefreshUserTokens: User $userId has no refresh token");
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
$this->logger->debug("RefreshUserTokens: Refreshing token for user $userId (remaining={$currentTimeRemaining}s, threshold={$currentThreshold}s)");
|
||||
|
||||
/** @var string $refreshToken */
|
||||
$refreshToken = $currentToken['refresh_token'];
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if ($newTokenData === null) {
|
||||
$this->logger->warning("RefreshUserTokens: Refresh returned null for user $userId");
|
||||
// Don't delete token here - let on-demand refresh handle cleanup
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
// Calculate new expiration and store issued_at for future calculations
|
||||
$expiresIn = (int)($newTokenData['expires_in'] ?? self::DEFAULT_TOKEN_LIFETIME_SECONDS);
|
||||
$now = time();
|
||||
|
||||
/** @var string $accessToken */
|
||||
$accessToken = $newTokenData['access_token'];
|
||||
/** @var string $newRefreshToken */
|
||||
$newRefreshToken = $newTokenData['refresh_token'] ?? $refreshToken;
|
||||
|
||||
$this->tokenStorage->storeUserToken(
|
||||
$userId,
|
||||
$accessToken,
|
||||
$newRefreshToken,
|
||||
$now + $expiresIn,
|
||||
$now // issued_at
|
||||
);
|
||||
|
||||
$this->logger->debug("RefreshUserTokens: Successfully refreshed token for user $userId");
|
||||
return 'refreshed';
|
||||
});
|
||||
} catch (LockedException $e) {
|
||||
// Lock held by on-demand refresh - expected, not an error
|
||||
$this->logger->debug("RefreshUserTokens: Lock held for user $userId, skipping");
|
||||
return 'skipped';
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("RefreshUserTokens: Failed to refresh for user $userId: " . $e->getMessage());
|
||||
return 'failed';
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
-3
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace OCA\Astrolabe\Search;
|
||||
|
||||
use OCA\Astrolabe\AppInfo\Application;
|
||||
use OCA\Astrolabe\Service\IdpTokenRefresher;
|
||||
use OCA\Astrolabe\Service\McpServerClient;
|
||||
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||
use OCA\Astrolabe\Settings\Admin as AdminSettings;
|
||||
@@ -35,6 +36,7 @@ class SemanticSearchProvider implements IProvider {
|
||||
public function __construct(
|
||||
private McpServerClient $client,
|
||||
private McpTokenStorage $tokenStorage,
|
||||
private IdpTokenRefresher $tokenRefresher,
|
||||
private IConfig $config,
|
||||
private IL10N $l10n,
|
||||
private IURLGenerator $urlGenerator,
|
||||
@@ -85,12 +87,30 @@ class SemanticSearchProvider implements IProvider {
|
||||
return SearchResult::complete($this->getName(), []);
|
||||
}
|
||||
|
||||
// Get OAuth token for user
|
||||
$accessToken = $this->tokenStorage->getAccessToken($user->getUID());
|
||||
$userId = $user->getUID();
|
||||
|
||||
// Create refresh callback matching ApiController pattern
|
||||
/** @return array{access_token: string, refresh_token: string, expires_in: int}|null */
|
||||
$refreshCallback = function (string $refreshToken): ?array {
|
||||
$newTokenData = $this->tokenRefresher->refreshAccessToken($refreshToken);
|
||||
|
||||
if ($newTokenData === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'access_token' => $newTokenData['access_token'],
|
||||
'refresh_token' => $newTokenData['refresh_token'] ?? $refreshToken,
|
||||
'expires_in' => $newTokenData['expires_in'] ?? 3600,
|
||||
];
|
||||
};
|
||||
|
||||
// Get OAuth token for user with automatic refresh
|
||||
$accessToken = $this->tokenStorage->getAccessToken($userId, $refreshCallback);
|
||||
if ($accessToken === null) {
|
||||
// User hasn't authorized the app yet - return empty results
|
||||
$this->logger->debug('No OAuth token for user in semantic search', [
|
||||
'user_id' => $user->getUID(),
|
||||
'user_id' => $userId,
|
||||
]);
|
||||
return SearchResult::complete($this->getName(), []);
|
||||
}
|
||||
|
||||
+164
-33
@@ -5,6 +5,9 @@ declare(strict_types=1);
|
||||
namespace OCA\Astrolabe\Service;
|
||||
|
||||
use OCP\IConfig;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\Lock\ILockingProvider;
|
||||
use OCP\Lock\LockedException;
|
||||
use OCP\Security\ICrypto;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
@@ -20,16 +23,22 @@ class McpTokenStorage {
|
||||
|
||||
private $config;
|
||||
private $crypto;
|
||||
private $db;
|
||||
private $logger;
|
||||
private ILockingProvider $lockingProvider;
|
||||
|
||||
public function __construct(
|
||||
IConfig $config,
|
||||
ICrypto $crypto,
|
||||
IDBConnection $db,
|
||||
LoggerInterface $logger,
|
||||
ILockingProvider $lockingProvider,
|
||||
) {
|
||||
$this->config = $config;
|
||||
$this->crypto = $crypto;
|
||||
$this->db = $db;
|
||||
$this->logger = $logger;
|
||||
$this->lockingProvider = $lockingProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,18 +50,21 @@ class McpTokenStorage {
|
||||
* @param string $accessToken OAuth access token
|
||||
* @param string $refreshToken OAuth refresh token
|
||||
* @param int $expiresAt Unix timestamp when token expires
|
||||
* @param int|null $issuedAt Unix timestamp when token was issued (for lifetime calculation)
|
||||
*/
|
||||
public function storeUserToken(
|
||||
string $userId,
|
||||
string $accessToken,
|
||||
string $refreshToken,
|
||||
int $expiresAt,
|
||||
?int $issuedAt = null,
|
||||
): void {
|
||||
try {
|
||||
$tokenData = [
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => $refreshToken,
|
||||
'expires_at' => $expiresAt,
|
||||
'issued_at' => $issuedAt ?? time(),
|
||||
];
|
||||
|
||||
// Encrypt token data before storage
|
||||
@@ -129,6 +141,42 @@ class McpTokenStorage {
|
||||
return time() >= ($token['expires_at'] - self::TOKEN_EXPIRY_BUFFER_SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the lock path for a user's token refresh operation.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
* @return string Lock path
|
||||
*/
|
||||
private function getTokenRefreshLockPath(string $userId): string {
|
||||
return 'astrolabe/oauth/tokens/' . $userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute callback while holding exclusive lock on user's token.
|
||||
*
|
||||
* Prevents race conditions between background job and on-demand token refresh.
|
||||
*
|
||||
* Note: Lock TTL is configured at the Nextcloud server level (default: 3600s).
|
||||
* If a process crashes while holding the lock, it will auto-expire after the TTL.
|
||||
* The ILockingProvider interface does not support per-call timeouts.
|
||||
*
|
||||
* @template T
|
||||
* @param string $userId User ID
|
||||
* @param callable(): T $callback
|
||||
* @return T
|
||||
* @throws LockedException If lock cannot be acquired
|
||||
*/
|
||||
public function withTokenLock(string $userId, callable $callback): mixed {
|
||||
$lockPath = $this->getTokenRefreshLockPath($userId);
|
||||
|
||||
$this->lockingProvider->acquireLock($lockPath, ILockingProvider::LOCK_EXCLUSIVE);
|
||||
try {
|
||||
return $callback();
|
||||
} finally {
|
||||
$this->lockingProvider->releaseLock($lockPath, ILockingProvider::LOCK_EXCLUSIVE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete stored tokens for a user.
|
||||
*
|
||||
@@ -153,65 +201,148 @@ class McpTokenStorage {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user IDs that have OAuth tokens stored.
|
||||
*
|
||||
* Queries oc_preferences directly since IConfig doesn't support
|
||||
* listing all users with a specific key set.
|
||||
*
|
||||
* @param int $limit Maximum users to return (0 = no limit, for backward compatibility)
|
||||
* @param int $offset Starting offset for pagination
|
||||
* @return list<string> Array of user IDs
|
||||
*/
|
||||
public function getAllUsersWithTokens(int $limit = 0, int $offset = 0): array {
|
||||
$qb = $this->db->getQueryBuilder();
|
||||
$qb->select('userid')
|
||||
->from('preferences')
|
||||
->where($qb->expr()->eq('appid', $qb->createNamedParameter('astrolabe')))
|
||||
->andWhere($qb->expr()->eq('configkey', $qb->createNamedParameter('oauth_tokens')));
|
||||
|
||||
if ($limit > 0) {
|
||||
$qb->setMaxResults($limit);
|
||||
}
|
||||
if ($offset > 0) {
|
||||
$qb->setFirstResult($offset);
|
||||
}
|
||||
|
||||
$result = $qb->executeQuery();
|
||||
/** @var list<string> $userIds */
|
||||
$userIds = [];
|
||||
/** @psalm-suppress MixedAssignment - IResult::fetch() returns mixed */
|
||||
while (($row = $result->fetch()) !== false) {
|
||||
if (is_array($row) && isset($row['userid']) && is_string($row['userid'])) {
|
||||
$userIds[] = $row['userid'];
|
||||
}
|
||||
}
|
||||
$result->closeCursor();
|
||||
|
||||
return $userIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the access token for a user, handling expiration and refresh.
|
||||
*
|
||||
* This is a convenience method that combines token retrieval,
|
||||
* expiration checking, and automatic refresh if needed.
|
||||
*
|
||||
* Uses double-check locking pattern to prevent race conditions between
|
||||
* background job and on-demand refresh while minimizing lock contention.
|
||||
*
|
||||
* @param string $userId User ID
|
||||
* @param callable|null $refreshCallback Callback to refresh token if expired
|
||||
* Should accept (refreshToken) and return new token data
|
||||
* @return string|null Access token, or null if not available
|
||||
*/
|
||||
public function getAccessToken(string $userId, ?callable $refreshCallback = null): ?string {
|
||||
// Quick check without lock (optimization)
|
||||
$token = $this->getUserToken($userId);
|
||||
|
||||
if (!$token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if ($this->isExpired($token)) {
|
||||
// Try to refresh if callback provided
|
||||
if ($refreshCallback && isset($token['refresh_token'])) {
|
||||
try {
|
||||
$newTokenData = $refreshCallback($token['refresh_token']);
|
||||
// If not expired, return immediately without lock
|
||||
if (!$this->isExpired($token)) {
|
||||
return $token['access_token'];
|
||||
}
|
||||
|
||||
if ($newTokenData && isset($newTokenData['access_token'])) {
|
||||
// Store refreshed token
|
||||
// Use new refresh token if provided (rotation), otherwise keep old one
|
||||
$this->storeUserToken(
|
||||
$userId,
|
||||
$newTokenData['access_token'],
|
||||
$newTokenData['refresh_token'] ?? $token['refresh_token'],
|
||||
time() + ($newTokenData['expires_in'] ?? 3600)
|
||||
);
|
||||
// Token expired - acquire lock for refresh
|
||||
try {
|
||||
/**
|
||||
* @return string|null
|
||||
* @psalm-suppress MixedInferredReturnType
|
||||
*/
|
||||
return $this->withTokenLock($userId, function () use ($userId, $refreshCallback): ?string {
|
||||
// Re-check after acquiring lock (double-check pattern)
|
||||
// Another process may have refreshed while we waited for the lock
|
||||
$currentToken = $this->getUserToken($userId);
|
||||
|
||||
return $newTokenData['access_token'];
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to refresh token for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
// Delete stale token to prevent repeated refresh attempts
|
||||
$this->deleteUserToken($userId);
|
||||
if ($currentToken === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Refresh callback returned null or invalid data - delete stale token
|
||||
// Check if another process already refreshed the token
|
||||
if (!$this->isExpired($currentToken)) {
|
||||
$this->logger->debug("Token already refreshed for user $userId while waiting for lock");
|
||||
/** @var string */
|
||||
return $currentToken['access_token'];
|
||||
}
|
||||
|
||||
// Still expired, perform refresh
|
||||
if ($refreshCallback && isset($currentToken['refresh_token'])) {
|
||||
try {
|
||||
/** @var string $refreshToken */
|
||||
$refreshToken = $currentToken['refresh_token'];
|
||||
$newTokenData = $refreshCallback($refreshToken);
|
||||
|
||||
if ($newTokenData && isset($newTokenData['access_token'])) {
|
||||
// Store refreshed token
|
||||
// Use new refresh token if provided (rotation), otherwise keep old one
|
||||
$now = time();
|
||||
/** @var string $accessToken */
|
||||
$accessToken = $newTokenData['access_token'];
|
||||
/** @var string $newRefreshToken */
|
||||
$newRefreshToken = $newTokenData['refresh_token'] ?? $refreshToken;
|
||||
$expiresIn = (int)($newTokenData['expires_in'] ?? 3600);
|
||||
|
||||
$this->storeUserToken(
|
||||
$userId,
|
||||
$accessToken,
|
||||
$newRefreshToken,
|
||||
$now + $expiresIn,
|
||||
$now // issued_at for accurate lifetime calculation
|
||||
);
|
||||
|
||||
return $accessToken;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error("Failed to refresh token for user $userId", [
|
||||
'error' => $e->getMessage()
|
||||
]);
|
||||
// Delete stale token to prevent repeated refresh attempts
|
||||
$this->deleteUserToken($userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Refresh callback returned null or invalid data - delete stale token
|
||||
$this->deleteUserToken($userId);
|
||||
$this->logger->info("Deleted stale token for user $userId after refresh failure");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Token expired and no refresh callback available - delete stale token
|
||||
$this->deleteUserToken($userId);
|
||||
$this->logger->info("Deleted stale token for user $userId after refresh failure");
|
||||
$this->logger->info("Token expired for user $userId, no refresh available");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Token expired and no refresh callback available - delete stale token
|
||||
$this->deleteUserToken($userId);
|
||||
$this->logger->info("Token expired for user $userId, no refresh available");
|
||||
return null;
|
||||
});
|
||||
} catch (LockedException $e) {
|
||||
// Could not acquire lock - another process is refreshing
|
||||
// Return stale token rather than failing - caller can retry if needed
|
||||
$this->logger->warning("Could not acquire token lock for user $userId, returning stale token");
|
||||
/** @var string|null $staleToken */
|
||||
$staleToken = $token['access_token'] ?? null;
|
||||
return $staleToken;
|
||||
}
|
||||
|
||||
return $token['access_token'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+66
-254
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "astrolabe",
|
||||
"version": "0.8.3",
|
||||
"version": "0.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "astrolabe",
|
||||
"version": "0.8.3",
|
||||
"version": "0.10.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@nextcloud/axios": "^2.5.1",
|
||||
@@ -16,7 +16,6 @@
|
||||
"@nextcloud/router": "^3.0.1",
|
||||
"@nextcloud/vue": "^9.3.3",
|
||||
"markdown-it": "^14.1.0",
|
||||
"pdfjs-dist": "^4.0.379",
|
||||
"plotly.js-dist-min": "^3.0.0",
|
||||
"vue": "^3.0.0",
|
||||
"vue-material-design-icons": "^5.3.1"
|
||||
@@ -1191,185 +1190,6 @@
|
||||
"integrity": "sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "0.1.84",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"workspaces": [
|
||||
"e2e/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas-android-arm64": "0.1.84",
|
||||
"@napi-rs/canvas-darwin-arm64": "0.1.84",
|
||||
"@napi-rs/canvas-darwin-x64": "0.1.84",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.84",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "0.1.84",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "0.1.84",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.84",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "0.1.84",
|
||||
"@napi-rs/canvas-linux-x64-musl": "0.1.84",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "0.1.84"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "0.1.84",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.84.tgz",
|
||||
"integrity": "sha512-pdvuqvj3qtwVryqgpAGornJLV6Ezpk39V6wT4JCnRVGy8I3Tk1au8qOalFGrx/r0Ig87hWslysPpHBxVpBMIww==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||
"version": "0.1.84",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.84.tgz",
|
||||
"integrity": "sha512-A8IND3Hnv0R6abc6qCcCaOCujTLMmGxtucMTZ5vbQUrEN/scxi378MyTLtyWg+MRr6bwQJ6v/orqMS9datIcww==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||
"version": "0.1.84",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.84.tgz",
|
||||
"integrity": "sha512-AUW45lJhYWwnA74LaNeqhvqYKK/2hNnBBBl03KRdqeCD4tKneUSrxUqIv8d22CBweOvrAASyKN3W87WO2zEr/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||
"version": "0.1.84",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.84.tgz",
|
||||
"integrity": "sha512-8zs5ZqOrdgs4FioTxSBrkl/wHZB56bJNBqaIsfPL4ZkEQCinOkrFF7xIcXiHiKp93J3wUtbIzeVrhTIaWwqk+A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||
"version": "0.1.84",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.84.tgz",
|
||||
"integrity": "sha512-i204vtowOglJUpbAFWU5mqsJgH0lVpNk/Ml4mQtB4Lndd86oF+Otr6Mr5KQnZHqYGhlSIKiU2SYnUbhO28zGQA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||
"version": "0.1.84",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.84.tgz",
|
||||
"integrity": "sha512-VyZq0EEw+OILnWk7G3ZgLLPaz1ERaPP++jLjeyLMbFOF+Tr4zHzWKiKDsEV/cT7btLPZbVoR3VX+T9/QubnURQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||
"version": "0.1.84",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.84.tgz",
|
||||
"integrity": "sha512-PSMTh8DiThvLRsbtc/a065I/ceZk17EXAATv9uNvHgkgo7wdEfTh2C3aveNkBMGByVO3tvnvD5v/YFtZL07cIg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||
"version": "0.1.84",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||
"version": "0.1.84",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||
"version": "0.1.84",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.84.tgz",
|
||||
"integrity": "sha512-YSs8ncurc1xzegUMNnQUTYrdrAuaXdPMOa+iYYyAxydOtg0ppV386hyYMsy00Yip1NlTgLCseRG4sHSnjQx6og==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||
@@ -1657,9 +1477,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@nextcloud/vue": {
|
||||
"version": "9.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-9.3.3.tgz",
|
||||
"integrity": "sha512-M/M4L9vp1AJQ8RRk75mbMwUo7sOwWDaTDmAwgpTa9LARDe5e6UBJoMhOmiz5EPkYRHLn2SLE+baOIXVmtVMdqw==",
|
||||
"version": "9.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@nextcloud/vue/-/vue-9.4.0.tgz",
|
||||
"integrity": "sha512-MoEbaFqFeZfTB+8d/BtgObAfzJMQ+vdidzMP/zKzx9J4cW+vgY5bciDUueY+t3f0uwSJXO3xsqXXWj9x2KihzQ==",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@ckpack/vue-color": "^1.6.0",
|
||||
@@ -1684,7 +1504,7 @@
|
||||
"emoji-mart-vue-fast": "^15.0.5",
|
||||
"escape-html": "^1.0.3",
|
||||
"floating-vue": "^5.2.2",
|
||||
"focus-trap": "7.6.6",
|
||||
"focus-trap": "^7.8.0",
|
||||
"linkifyjs": "^4.3.2",
|
||||
"p-queue": "^9.1.0",
|
||||
"rehype-external-links": "^3.0.0",
|
||||
@@ -3003,22 +2823,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz",
|
||||
"integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==",
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.27.tgz",
|
||||
"integrity": "sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.5",
|
||||
"@vue/shared": "3.5.26",
|
||||
"@vue/shared": "3.5.27",
|
||||
"entities": "^7.0.0",
|
||||
"estree-walker": "^2.0.2",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-core/node_modules/entities": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz",
|
||||
"integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==",
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
@@ -3028,26 +2848,26 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-dom": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz",
|
||||
"integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==",
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.27.tgz",
|
||||
"integrity": "sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-core": "3.5.26",
|
||||
"@vue/shared": "3.5.26"
|
||||
"@vue/compiler-core": "3.5.27",
|
||||
"@vue/shared": "3.5.27"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-sfc": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz",
|
||||
"integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==",
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.27.tgz",
|
||||
"integrity": "sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.28.5",
|
||||
"@vue/compiler-core": "3.5.26",
|
||||
"@vue/compiler-dom": "3.5.26",
|
||||
"@vue/compiler-ssr": "3.5.26",
|
||||
"@vue/shared": "3.5.26",
|
||||
"@vue/compiler-core": "3.5.27",
|
||||
"@vue/compiler-dom": "3.5.27",
|
||||
"@vue/compiler-ssr": "3.5.27",
|
||||
"@vue/shared": "3.5.27",
|
||||
"estree-walker": "^2.0.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"postcss": "^8.5.6",
|
||||
@@ -3055,13 +2875,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-ssr": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz",
|
||||
"integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==",
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.27.tgz",
|
||||
"integrity": "sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.26",
|
||||
"@vue/shared": "3.5.26"
|
||||
"@vue/compiler-dom": "3.5.27",
|
||||
"@vue/shared": "3.5.27"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/devtools-api": {
|
||||
@@ -3095,53 +2915,53 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz",
|
||||
"integrity": "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==",
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.27.tgz",
|
||||
"integrity": "sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.5.26"
|
||||
"@vue/shared": "3.5.27"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-core": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.26.tgz",
|
||||
"integrity": "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==",
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.27.tgz",
|
||||
"integrity": "sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.26",
|
||||
"@vue/shared": "3.5.26"
|
||||
"@vue/reactivity": "3.5.27",
|
||||
"@vue/shared": "3.5.27"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-dom": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz",
|
||||
"integrity": "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==",
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.27.tgz",
|
||||
"integrity": "sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.5.26",
|
||||
"@vue/runtime-core": "3.5.26",
|
||||
"@vue/shared": "3.5.26",
|
||||
"@vue/reactivity": "3.5.27",
|
||||
"@vue/runtime-core": "3.5.27",
|
||||
"@vue/shared": "3.5.27",
|
||||
"csstype": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/server-renderer": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.26.tgz",
|
||||
"integrity": "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==",
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.27.tgz",
|
||||
"integrity": "sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-ssr": "3.5.26",
|
||||
"@vue/shared": "3.5.26"
|
||||
"@vue/compiler-ssr": "3.5.27",
|
||||
"@vue/shared": "3.5.27"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "3.5.26"
|
||||
"vue": "3.5.27"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz",
|
||||
"integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==",
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.27.tgz",
|
||||
"integrity": "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vuepic/vue-datepicker": {
|
||||
@@ -5352,10 +5172,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/focus-trap": {
|
||||
"version": "7.6.6",
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz",
|
||||
"integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tabbable": "^6.3.0"
|
||||
"tabbable": "^6.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
@@ -7879,16 +7701,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "4.10.38",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.65"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"license": "ISC"
|
||||
@@ -10299,16 +10111,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vue": {
|
||||
"version": "3.5.26",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
|
||||
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz",
|
||||
"integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.26",
|
||||
"@vue/compiler-sfc": "3.5.26",
|
||||
"@vue/runtime-dom": "3.5.26",
|
||||
"@vue/server-renderer": "3.5.26",
|
||||
"@vue/shared": "3.5.26"
|
||||
"@vue/compiler-dom": "3.5.27",
|
||||
"@vue/compiler-sfc": "3.5.27",
|
||||
"@vue/runtime-dom": "3.5.27",
|
||||
"@vue/server-renderer": "3.5.27",
|
||||
"@vue/shared": "3.5.27"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "*"
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "astrolabe",
|
||||
"version": "0.8.3",
|
||||
"version": "0.10.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"engines": {
|
||||
"node": "^22.0.0",
|
||||
|
||||
-7
@@ -388,11 +388,6 @@
|
||||
<InvalidReturnType>
|
||||
<code><![CDATA[array|null]]></code>
|
||||
</InvalidReturnType>
|
||||
<MixedArgument>
|
||||
<code><![CDATA[$newTokenData['access_token']]]></code>
|
||||
<code><![CDATA[$newTokenData['refresh_token'] ?? $token['refresh_token']]]></code>
|
||||
<code><![CDATA[time() + ($newTokenData['expires_in'] ?? 3600)]]></code>
|
||||
</MixedArgument>
|
||||
<MixedAssignment>
|
||||
<code><![CDATA[$newTokenData]]></code>
|
||||
</MixedAssignment>
|
||||
@@ -400,11 +395,9 @@
|
||||
<code><![CDATA[string|null]]></code>
|
||||
</MixedInferredReturnType>
|
||||
<MixedOperand>
|
||||
<code><![CDATA[$newTokenData['expires_in'] ?? 3600]]></code>
|
||||
<code><![CDATA[$token['expires_at']]]></code>
|
||||
</MixedOperand>
|
||||
<MixedReturnStatement>
|
||||
<code><![CDATA[$newTokenData['access_token']]]></code>
|
||||
<code><![CDATA[$token['access_token']]]></code>
|
||||
</MixedReturnStatement>
|
||||
<PossiblyUnusedMethod>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 218 KiB After Width: | Height: | Size: 736 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 736 KiB After Width: | Height: | Size: 218 KiB |
@@ -0,0 +1,635 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\Astrolabe\Tests\Unit\BackgroundJob;
|
||||
|
||||
use OCA\Astrolabe\BackgroundJob\RefreshUserTokens;
|
||||
use OCA\Astrolabe\Service\IdpTokenRefresher;
|
||||
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||
use OCP\AppFramework\Utility\ITimeFactory;
|
||||
use OCP\Lock\LockedException;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* Unit tests for RefreshUserTokens background job.
|
||||
*
|
||||
* Tests proactive OAuth token refresh functionality.
|
||||
*/
|
||||
final class RefreshUserTokensTest extends TestCase {
|
||||
private ITimeFactory&MockObject $timeFactory;
|
||||
private McpTokenStorage&MockObject $tokenStorage;
|
||||
private IdpTokenRefresher&MockObject $tokenRefresher;
|
||||
private LoggerInterface&MockObject $logger;
|
||||
private RefreshUserTokens $job;
|
||||
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
|
||||
$this->timeFactory = $this->createMock(ITimeFactory::class);
|
||||
$this->tokenStorage = $this->createMock(McpTokenStorage::class);
|
||||
$this->tokenRefresher = $this->createMock(IdpTokenRefresher::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$this->job = new RefreshUserTokens(
|
||||
$this->timeFactory,
|
||||
$this->tokenStorage,
|
||||
$this->tokenRefresher,
|
||||
$this->logger
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up default withTokenLock behavior that executes the callback.
|
||||
* Call this in tests that need the lock to succeed.
|
||||
*/
|
||||
private function setupDefaultLockBehavior(): void {
|
||||
$this->tokenStorage->method('withTokenLock')
|
||||
->willReturnCallback(fn ($userId, $callback) => $callback());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Constructor Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testConstructorSetsInterval(): void {
|
||||
// Use reflection to access the protected interval property
|
||||
$reflection = new \ReflectionClass($this->job);
|
||||
$property = $reflection->getProperty('interval');
|
||||
$property->setAccessible(true);
|
||||
|
||||
$this->assertEquals(900, $property->getValue($this->job));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// run() Method Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testRunWithNoUsers(): void {
|
||||
$this->tokenStorage->method('getAllUsersWithTokens')
|
||||
->willReturn([]);
|
||||
|
||||
$this->logger->expects($this->exactly(2))
|
||||
->method('info')
|
||||
->willReturnCallback(function (string $message) {
|
||||
static $callCount = 0;
|
||||
$callCount++;
|
||||
if ($callCount === 1) {
|
||||
$this->assertStringContainsString('Starting', $message);
|
||||
} else {
|
||||
$this->assertStringContainsString('total=0', $message);
|
||||
$this->assertStringContainsString('refreshed=0, failed=0, skipped=0', $message);
|
||||
}
|
||||
});
|
||||
|
||||
// Call run() via reflection since it's protected
|
||||
$this->invokeRun();
|
||||
}
|
||||
|
||||
public function testRunWithMultipleUsersAndMixedResults(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$this->tokenStorage->method('getAllUsersWithTokens')
|
||||
->willReturn(['alice', 'bob', 'charlie']);
|
||||
|
||||
// Alice: token with plenty of time (skipped)
|
||||
// Bob: token near expiry with refresh token (refreshed)
|
||||
// Charlie: token near expiry without refresh token (failed)
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->willReturnCallback(function (string $userId) {
|
||||
$now = time();
|
||||
return match ($userId) {
|
||||
'alice' => [
|
||||
'access_token' => 'alice-token',
|
||||
'refresh_token' => 'alice-refresh',
|
||||
'expires_at' => $now + 3600, // 1 hour remaining (>50% of default lifetime)
|
||||
'issued_at' => $now,
|
||||
],
|
||||
'bob' => [
|
||||
'access_token' => 'bob-token',
|
||||
'refresh_token' => 'bob-refresh',
|
||||
'expires_at' => $now + 100, // ~100s remaining (<50% of default lifetime)
|
||||
'issued_at' => $now - 3500,
|
||||
],
|
||||
'charlie' => [
|
||||
'access_token' => 'charlie-token',
|
||||
// No refresh_token
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
],
|
||||
default => null,
|
||||
};
|
||||
});
|
||||
|
||||
// Bob's refresh should succeed
|
||||
$this->tokenRefresher->method('refreshAccessToken')
|
||||
->with('bob-refresh')
|
||||
->willReturn([
|
||||
'access_token' => 'bob-new-token',
|
||||
'refresh_token' => 'bob-new-refresh',
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('storeUserToken')
|
||||
->with(
|
||||
'bob',
|
||||
'bob-new-token',
|
||||
'bob-new-refresh',
|
||||
$this->anything(),
|
||||
$this->anything()
|
||||
);
|
||||
|
||||
$this->logger->expects($this->exactly(2))
|
||||
->method('info')
|
||||
->willReturnCallback(function (string $message) {
|
||||
static $callCount = 0;
|
||||
$callCount++;
|
||||
if ($callCount === 2) {
|
||||
$this->assertStringContainsString('total=3', $message);
|
||||
$this->assertStringContainsString('refreshed=1, failed=1, skipped=1', $message);
|
||||
}
|
||||
});
|
||||
|
||||
$this->invokeRun();
|
||||
}
|
||||
|
||||
public function testRunProcessesUsersInBatches(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
// Simulate 150 users processed in 2 batches (100 + 50)
|
||||
$batch1 = array_map(fn ($i) => "user{$i}", range(1, 100));
|
||||
$batch2 = array_map(fn ($i) => "user{$i}", range(101, 150));
|
||||
|
||||
$callCount = 0;
|
||||
$this->tokenStorage->method('getAllUsersWithTokens')
|
||||
->willReturnCallback(function (int $limit, int $offset) use (&$callCount, $batch1, $batch2) {
|
||||
$callCount++;
|
||||
// First call: offset 0, return 100 users (full batch)
|
||||
if ($offset === 0) {
|
||||
$this->assertEquals(100, $limit);
|
||||
return $batch1;
|
||||
}
|
||||
// Second call: offset 100, return 50 users (partial batch = last)
|
||||
if ($offset === 100) {
|
||||
$this->assertEquals(100, $limit);
|
||||
return $batch2;
|
||||
}
|
||||
// Should not be called again
|
||||
$this->fail("Unexpected getAllUsersWithTokens call with offset $offset");
|
||||
});
|
||||
|
||||
// All tokens have plenty of time (all skipped)
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->willReturnCallback(function (string $userId) {
|
||||
$now = time();
|
||||
return [
|
||||
'access_token' => "{$userId}-token",
|
||||
'refresh_token' => "{$userId}-refresh",
|
||||
'expires_at' => $now + 3600,
|
||||
'issued_at' => $now,
|
||||
];
|
||||
});
|
||||
|
||||
$this->tokenRefresher->expects($this->never())
|
||||
->method('refreshAccessToken');
|
||||
|
||||
$this->logger->expects($this->exactly(2))
|
||||
->method('info')
|
||||
->willReturnCallback(function (string $message) {
|
||||
static $infoCallCount = 0;
|
||||
$infoCallCount++;
|
||||
if ($infoCallCount === 2) {
|
||||
$this->assertStringContainsString('total=150', $message);
|
||||
$this->assertStringContainsString('refreshed=0, failed=0, skipped=150', $message);
|
||||
}
|
||||
});
|
||||
|
||||
$this->invokeRun();
|
||||
|
||||
// Verify getAllUsersWithTokens was called exactly twice (2 batches)
|
||||
$this->assertEquals(2, $callCount);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// refreshUserTokenIfNeeded() Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testRefreshSkippedWhenTokenHasPlentyOfTime(): void {
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'valid-token',
|
||||
'refresh_token' => 'refresh-token',
|
||||
'expires_at' => $now + 3600, // 1 hour remaining
|
||||
'issued_at' => $now,
|
||||
]);
|
||||
|
||||
$this->tokenRefresher->expects($this->never())
|
||||
->method('refreshAccessToken');
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('skipped', $result);
|
||||
}
|
||||
|
||||
public function testRefreshTriggeredWhenTokenNearExpiry(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'expiring-token',
|
||||
'refresh_token' => 'refresh-token',
|
||||
'expires_at' => $now + 300, // 5 min remaining (< 50% of 3600s)
|
||||
'issued_at' => $now - 3300, // Issued 55 min ago
|
||||
]);
|
||||
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->with('refresh-token')
|
||||
->willReturn([
|
||||
'access_token' => 'new-token',
|
||||
'refresh_token' => 'new-refresh-token',
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('storeUserToken');
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('refreshed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshFailsWhenNoRefreshToken(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'expiring-token',
|
||||
// No refresh_token
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
]);
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('warning')
|
||||
->with($this->stringContains('no refresh token'));
|
||||
|
||||
$this->tokenRefresher->expects($this->never())
|
||||
->method('refreshAccessToken');
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('failed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshFailsWhenRefresherReturnsNull(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'expiring-token',
|
||||
'refresh_token' => 'invalid-refresh',
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
]);
|
||||
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->with('invalid-refresh')
|
||||
->willReturn(null);
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('warning')
|
||||
->with($this->stringContains('Refresh returned null'));
|
||||
|
||||
// Should NOT delete token - let on-demand refresh handle cleanup
|
||||
$this->tokenStorage->expects($this->never())
|
||||
->method('deleteUserToken');
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('failed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshUsesIssuedAtForLifetimeCalculation(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
// Token with custom lifetime: issued 50 min ago, expires in 10 min (total 60 min)
|
||||
// 10/60 = 16.7% remaining, which is < 50%, so should refresh
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'token',
|
||||
'refresh_token' => 'refresh',
|
||||
'expires_at' => $now + 600, // 10 min remaining
|
||||
'issued_at' => $now - 3000, // 50 min ago, total lifetime 60 min
|
||||
]);
|
||||
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->willReturn([
|
||||
'access_token' => 'new-token',
|
||||
'refresh_token' => 'new-refresh',
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('refreshed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshUsesDefaultLifetimeWhenNoIssuedAt(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
// Token without issued_at, uses default 3600s lifetime
|
||||
// 300s remaining / 3600s = 8.3% remaining, which is < 50%, so should refresh
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'token',
|
||||
'refresh_token' => 'refresh',
|
||||
'expires_at' => $now + 300, // 5 min remaining
|
||||
// No issued_at
|
||||
]);
|
||||
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->willReturn([
|
||||
'access_token' => 'new-token',
|
||||
'refresh_token' => 'new-refresh',
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('refreshed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshStoresNewTokenWithIssuedAt(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'old-token',
|
||||
'refresh_token' => 'old-refresh',
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
]);
|
||||
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->willReturn([
|
||||
'access_token' => 'new-token',
|
||||
'refresh_token' => 'new-refresh',
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
// Verify storeUserToken is called with issued_at parameter
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('storeUserToken')
|
||||
->with(
|
||||
'testuser',
|
||||
'new-token',
|
||||
'new-refresh',
|
||||
$this->greaterThan($now), // expires_at = now + 3600
|
||||
$this->greaterThanOrEqual($now) // issued_at = now
|
||||
);
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('refreshed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshKeepsOldRefreshTokenIfNotRotated(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'old-token',
|
||||
'refresh_token' => 'original-refresh',
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
]);
|
||||
|
||||
// IdP returns new access token but no new refresh token (no rotation)
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->willReturn([
|
||||
'access_token' => 'new-token',
|
||||
// No refresh_token in response
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
// Should use the original refresh token
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('storeUserToken')
|
||||
->with(
|
||||
'testuser',
|
||||
'new-token',
|
||||
'original-refresh', // Original refresh token preserved
|
||||
$this->anything(),
|
||||
$this->anything()
|
||||
);
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('refreshed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshHandlesException(): void {
|
||||
$this->setupDefaultLockBehavior();
|
||||
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'token',
|
||||
'refresh_token' => 'refresh',
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
]);
|
||||
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->willThrowException(new \Exception('Network error'));
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('error')
|
||||
->with($this->stringContains('Failed to refresh'));
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('failed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshSkippedWhenNoToken(): void {
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn(null);
|
||||
|
||||
$this->tokenRefresher->expects($this->never())
|
||||
->method('refreshAccessToken');
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('skipped', $result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Locking Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testRefreshSkippedWhenLockCannotBeAcquired(): void {
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'expiring-token',
|
||||
'refresh_token' => 'refresh-token',
|
||||
'expires_at' => $now + 100, // ~100s remaining (< 50% of default)
|
||||
'issued_at' => $now - 3500,
|
||||
]);
|
||||
|
||||
// Lock acquisition fails (on-demand refresh is holding it)
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('withTokenLock')
|
||||
->willThrowException(new LockedException('astrolabe/oauth/tokens/testuser'));
|
||||
|
||||
// Token refresher should NOT be called when lock fails
|
||||
$this->tokenRefresher->expects($this->never())
|
||||
->method('refreshAccessToken');
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('debug')
|
||||
->with($this->stringContains('Lock held for user testuser'));
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('skipped', $result);
|
||||
}
|
||||
|
||||
public function testRefreshUsesLockForTokenRefresh(): void {
|
||||
$now = time();
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturn([
|
||||
'access_token' => 'expiring-token',
|
||||
'refresh_token' => 'refresh-token',
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
]);
|
||||
|
||||
// withTokenLock is called and executes the callback
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('withTokenLock')
|
||||
->with('testuser', $this->isInstanceOf(\Closure::class))
|
||||
->willReturnCallback(function ($userId, $callback) {
|
||||
return $callback();
|
||||
});
|
||||
|
||||
$this->tokenRefresher->expects($this->once())
|
||||
->method('refreshAccessToken')
|
||||
->with('refresh-token')
|
||||
->willReturn([
|
||||
'access_token' => 'new-token',
|
||||
'refresh_token' => 'new-refresh-token',
|
||||
'expires_in' => 3600,
|
||||
]);
|
||||
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('storeUserToken');
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('refreshed', $result);
|
||||
}
|
||||
|
||||
public function testRefreshSkippedWhenTokenAlreadyRefreshedWhileWaitingForLock(): void {
|
||||
$now = time();
|
||||
|
||||
// First call (before lock): token is expiring
|
||||
// Calls inside lock callback: token is now fresh
|
||||
$callCount = 0;
|
||||
$this->tokenStorage->method('getUserToken')
|
||||
->with('testuser')
|
||||
->willReturnCallback(function () use (&$callCount, $now) {
|
||||
$callCount++;
|
||||
if ($callCount === 1) {
|
||||
// First check: token is expiring
|
||||
return [
|
||||
'access_token' => 'expiring-token',
|
||||
'refresh_token' => 'refresh-token',
|
||||
'expires_at' => $now + 100,
|
||||
'issued_at' => $now - 3500,
|
||||
];
|
||||
}
|
||||
// Inside lock: token was already refreshed
|
||||
return [
|
||||
'access_token' => 'already-refreshed-token',
|
||||
'refresh_token' => 'new-refresh-token',
|
||||
'expires_at' => $now + 3600, // Fresh token
|
||||
'issued_at' => $now,
|
||||
];
|
||||
});
|
||||
|
||||
// withTokenLock is called and executes the callback
|
||||
$this->tokenStorage->expects($this->once())
|
||||
->method('withTokenLock')
|
||||
->willReturnCallback(function ($userId, $callback) {
|
||||
return $callback();
|
||||
});
|
||||
|
||||
// Token refresher should NOT be called since token is already fresh
|
||||
$this->tokenRefresher->expects($this->never())
|
||||
->method('refreshAccessToken');
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('debug')
|
||||
->with($this->stringContains('already refreshed'));
|
||||
|
||||
$result = $this->invokeRefreshUserTokenIfNeeded('testuser');
|
||||
|
||||
$this->assertEquals('skipped', $result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helper Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Invoke the protected run() method.
|
||||
*/
|
||||
private function invokeRun(): void {
|
||||
$reflection = new \ReflectionClass($this->job);
|
||||
$method = $reflection->getMethod('run');
|
||||
$method->setAccessible(true);
|
||||
$method->invoke($this->job, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke the private refreshUserTokenIfNeeded() method.
|
||||
*/
|
||||
private function invokeRefreshUserTokenIfNeeded(string $userId): string {
|
||||
$reflection = new \ReflectionClass($this->job);
|
||||
$method = $reflection->getMethod('refreshUserTokenIfNeeded');
|
||||
$method->setAccessible(true);
|
||||
return $method->invoke($this->job, $userId);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,13 @@ declare(strict_types=1);
|
||||
namespace OCA\Astrolabe\Tests\Unit\Service;
|
||||
|
||||
use OCA\Astrolabe\Service\McpTokenStorage;
|
||||
use OCP\DB\IResult;
|
||||
use OCP\DB\QueryBuilder\IExpressionBuilder;
|
||||
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||
use OCP\IConfig;
|
||||
use OCP\IDBConnection;
|
||||
use OCP\Lock\ILockingProvider;
|
||||
use OCP\Lock\LockedException;
|
||||
use OCP\Security\ICrypto;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
@@ -19,7 +25,9 @@ use Psr\Log\LoggerInterface;
|
||||
final class McpTokenStorageTest extends TestCase {
|
||||
private IConfig&MockObject $config;
|
||||
private ICrypto&MockObject $crypto;
|
||||
private IDBConnection&MockObject $db;
|
||||
private LoggerInterface&MockObject $logger;
|
||||
private ILockingProvider&MockObject $lockingProvider;
|
||||
private McpTokenStorage $storage;
|
||||
|
||||
protected function setUp(): void {
|
||||
@@ -27,12 +35,16 @@ final class McpTokenStorageTest extends TestCase {
|
||||
|
||||
$this->config = $this->createMock(IConfig::class);
|
||||
$this->crypto = $this->createMock(ICrypto::class);
|
||||
$this->db = $this->createMock(IDBConnection::class);
|
||||
$this->logger = $this->createMock(LoggerInterface::class);
|
||||
$this->lockingProvider = $this->createMock(ILockingProvider::class);
|
||||
|
||||
$this->storage = new McpTokenStorage(
|
||||
$this->config,
|
||||
$this->crypto,
|
||||
$this->logger
|
||||
$this->db,
|
||||
$this->logger,
|
||||
$this->lockingProvider
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,15 +58,15 @@ final class McpTokenStorageTest extends TestCase {
|
||||
$refreshToken = 'refresh-token-456';
|
||||
$expiresAt = time() + 3600;
|
||||
|
||||
$expectedTokenData = [
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => $refreshToken,
|
||||
'expires_at' => $expiresAt,
|
||||
];
|
||||
|
||||
$this->crypto->expects($this->once())
|
||||
->method('encrypt')
|
||||
->with(json_encode($expectedTokenData))
|
||||
->with($this->callback(function (string $json) use ($accessToken, $refreshToken, $expiresAt) {
|
||||
$data = json_decode($json, true);
|
||||
return $data['access_token'] === $accessToken
|
||||
&& $data['refresh_token'] === $refreshToken
|
||||
&& $data['expires_at'] === $expiresAt
|
||||
&& isset($data['issued_at']); // issued_at should be set (defaults to time())
|
||||
}))
|
||||
->willReturn('encrypted-data');
|
||||
|
||||
$this->config->expects($this->once())
|
||||
@@ -284,6 +296,155 @@ final class McpTokenStorageTest extends TestCase {
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Token Refresh Locking Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testGetAccessTokenAcquiresLockWhenRefreshing(): void {
|
||||
$userId = 'testuser';
|
||||
$expiredTokenData = [
|
||||
'access_token' => 'expired-access-token',
|
||||
'refresh_token' => 'old-refresh-token',
|
||||
'expires_at' => time() - 100, // Expired
|
||||
];
|
||||
|
||||
$newTokenData = [
|
||||
'access_token' => 'new-access-token',
|
||||
'refresh_token' => 'new-refresh-token',
|
||||
'expires_in' => 3600,
|
||||
];
|
||||
|
||||
$this->config->method('getUserValue')
|
||||
->willReturn('encrypted-data');
|
||||
|
||||
$this->crypto->method('decrypt')
|
||||
->willReturn(json_encode($expiredTokenData));
|
||||
|
||||
$this->crypto->method('encrypt')
|
||||
->willReturn('new-encrypted-data');
|
||||
|
||||
// Verify lock is acquired and released
|
||||
$this->lockingProvider->expects($this->once())
|
||||
->method('acquireLock')
|
||||
->with('astrolabe/oauth/tokens/testuser', ILockingProvider::LOCK_EXCLUSIVE);
|
||||
|
||||
$this->lockingProvider->expects($this->once())
|
||||
->method('releaseLock')
|
||||
->with('astrolabe/oauth/tokens/testuser', ILockingProvider::LOCK_EXCLUSIVE);
|
||||
|
||||
$refreshCallback = fn (string $refreshToken) => $newTokenData;
|
||||
|
||||
$result = $this->storage->getAccessToken($userId, $refreshCallback);
|
||||
|
||||
$this->assertEquals('new-access-token', $result);
|
||||
}
|
||||
|
||||
public function testGetAccessTokenReturnsStaleTokenOnLockedException(): void {
|
||||
$userId = 'testuser';
|
||||
$expiredTokenData = [
|
||||
'access_token' => 'expired-access-token',
|
||||
'refresh_token' => 'old-refresh-token',
|
||||
'expires_at' => time() - 100, // Expired
|
||||
];
|
||||
|
||||
$this->config->method('getUserValue')
|
||||
->willReturn('encrypted-data');
|
||||
|
||||
$this->crypto->method('decrypt')
|
||||
->willReturn(json_encode($expiredTokenData));
|
||||
|
||||
// Lock acquisition fails
|
||||
$this->lockingProvider->expects($this->once())
|
||||
->method('acquireLock')
|
||||
->willThrowException(new LockedException('astrolabe/oauth/tokens/testuser'));
|
||||
|
||||
// Refresh callback should NOT be called when lock fails
|
||||
$refreshCallbackCalled = false;
|
||||
$refreshCallback = function (string $refreshToken) use (&$refreshCallbackCalled) {
|
||||
$refreshCallbackCalled = true;
|
||||
return ['access_token' => 'new-token', 'expires_in' => 3600];
|
||||
};
|
||||
|
||||
$result = $this->storage->getAccessToken($userId, $refreshCallback);
|
||||
|
||||
// Should return stale token instead of failing
|
||||
$this->assertEquals('expired-access-token', $result);
|
||||
$this->assertFalse($refreshCallbackCalled);
|
||||
}
|
||||
|
||||
public function testGetAccessTokenSkipsRefreshWhenTokenAlreadyRefreshedWhileWaitingForLock(): void {
|
||||
$userId = 'testuser';
|
||||
$expiredTokenData = [
|
||||
'access_token' => 'expired-access-token',
|
||||
'refresh_token' => 'old-refresh-token',
|
||||
'expires_at' => time() - 100, // Expired
|
||||
];
|
||||
|
||||
// After lock is acquired, token appears fresh (another process refreshed it)
|
||||
$freshTokenData = [
|
||||
'access_token' => 'fresh-access-token',
|
||||
'refresh_token' => 'fresh-refresh-token',
|
||||
'expires_at' => time() + 3600, // Valid for 1 hour
|
||||
];
|
||||
|
||||
$callCount = 0;
|
||||
$this->config->method('getUserValue')
|
||||
->willReturn('encrypted-data');
|
||||
|
||||
// First call returns expired, subsequent calls return fresh
|
||||
$this->crypto->method('decrypt')
|
||||
->willReturnCallback(function () use (&$callCount, $expiredTokenData, $freshTokenData) {
|
||||
$callCount++;
|
||||
return $callCount === 1
|
||||
? json_encode($expiredTokenData)
|
||||
: json_encode($freshTokenData);
|
||||
});
|
||||
|
||||
$this->lockingProvider->expects($this->once())
|
||||
->method('acquireLock');
|
||||
|
||||
$this->lockingProvider->expects($this->once())
|
||||
->method('releaseLock');
|
||||
|
||||
// Refresh callback should NOT be called since token is already fresh
|
||||
$refreshCallbackCalled = false;
|
||||
$refreshCallback = function (string $refreshToken) use (&$refreshCallbackCalled) {
|
||||
$refreshCallbackCalled = true;
|
||||
return ['access_token' => 'new-token', 'expires_in' => 3600];
|
||||
};
|
||||
|
||||
$result = $this->storage->getAccessToken($userId, $refreshCallback);
|
||||
|
||||
$this->assertEquals('fresh-access-token', $result);
|
||||
$this->assertFalse($refreshCallbackCalled);
|
||||
}
|
||||
|
||||
public function testGetAccessTokenNoLockRequiredWhenNotExpired(): void {
|
||||
$userId = 'testuser';
|
||||
$validTokenData = [
|
||||
'access_token' => 'valid-access-token',
|
||||
'refresh_token' => 'refresh-token',
|
||||
'expires_at' => time() + 3600, // Valid for 1 hour
|
||||
];
|
||||
|
||||
$this->config->method('getUserValue')
|
||||
->willReturn('encrypted-data');
|
||||
|
||||
$this->crypto->method('decrypt')
|
||||
->willReturn(json_encode($validTokenData));
|
||||
|
||||
// Lock should NOT be acquired for valid tokens
|
||||
$this->lockingProvider->expects($this->never())
|
||||
->method('acquireLock');
|
||||
|
||||
$this->lockingProvider->expects($this->never())
|
||||
->method('releaseLock');
|
||||
|
||||
$result = $this->storage->getAccessToken($userId);
|
||||
|
||||
$this->assertEquals('valid-access-token', $result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// App Password Storage Tests (Multi-User Basic Auth)
|
||||
// =========================================================================
|
||||
@@ -524,4 +685,145 @@ final class McpTokenStorageTest extends TestCase {
|
||||
|
||||
$this->assertNull($result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// getAllUsersWithTokens Tests
|
||||
// =========================================================================
|
||||
|
||||
public function testGetAllUsersWithTokensReturnsUserIds(): void {
|
||||
$qb = $this->createMock(IQueryBuilder::class);
|
||||
$expr = $this->createMock(IExpressionBuilder::class);
|
||||
$result = $this->createMock(IResult::class);
|
||||
|
||||
// Chain builder methods
|
||||
$qb->method('select')->willReturnSelf();
|
||||
$qb->method('from')->willReturnSelf();
|
||||
$qb->method('where')->willReturnSelf();
|
||||
$qb->method('andWhere')->willReturnSelf();
|
||||
$qb->method('expr')->willReturn($expr);
|
||||
$qb->method('createNamedParameter')->willReturnArgument(0);
|
||||
$qb->method('executeQuery')->willReturn($result);
|
||||
|
||||
// Mock expression builder
|
||||
$expr->method('eq')->willReturn('mocked_condition');
|
||||
|
||||
// Mock result set with multiple users
|
||||
$result->method('fetch')->willReturnOnConsecutiveCalls(
|
||||
['userid' => 'admin'],
|
||||
['userid' => 'alice'],
|
||||
['userid' => 'bob'],
|
||||
false // End of results
|
||||
);
|
||||
$result->expects($this->once())->method('closeCursor');
|
||||
|
||||
$this->db->method('getQueryBuilder')->willReturn($qb);
|
||||
|
||||
$userIds = $this->storage->getAllUsersWithTokens();
|
||||
|
||||
$this->assertEquals(['admin', 'alice', 'bob'], $userIds);
|
||||
}
|
||||
|
||||
public function testGetAllUsersWithTokensReturnsEmptyArrayWhenNoTokens(): void {
|
||||
$qb = $this->createMock(IQueryBuilder::class);
|
||||
$expr = $this->createMock(IExpressionBuilder::class);
|
||||
$result = $this->createMock(IResult::class);
|
||||
|
||||
// Chain builder methods
|
||||
$qb->method('select')->willReturnSelf();
|
||||
$qb->method('from')->willReturnSelf();
|
||||
$qb->method('where')->willReturnSelf();
|
||||
$qb->method('andWhere')->willReturnSelf();
|
||||
$qb->method('expr')->willReturn($expr);
|
||||
$qb->method('createNamedParameter')->willReturnArgument(0);
|
||||
$qb->method('executeQuery')->willReturn($result);
|
||||
|
||||
// Mock expression builder
|
||||
$expr->method('eq')->willReturn('mocked_condition');
|
||||
|
||||
// Mock empty result set
|
||||
$result->method('fetch')->willReturn(false);
|
||||
$result->expects($this->once())->method('closeCursor');
|
||||
|
||||
$this->db->method('getQueryBuilder')->willReturn($qb);
|
||||
|
||||
$userIds = $this->storage->getAllUsersWithTokens();
|
||||
|
||||
$this->assertEquals([], $userIds);
|
||||
}
|
||||
|
||||
public function testGetAllUsersWithTokensWithLimitAndOffset(): void {
|
||||
$qb = $this->createMock(IQueryBuilder::class);
|
||||
$expr = $this->createMock(IExpressionBuilder::class);
|
||||
$result = $this->createMock(IResult::class);
|
||||
|
||||
// Chain builder methods
|
||||
$qb->method('select')->willReturnSelf();
|
||||
$qb->method('from')->willReturnSelf();
|
||||
$qb->method('where')->willReturnSelf();
|
||||
$qb->method('andWhere')->willReturnSelf();
|
||||
$qb->method('expr')->willReturn($expr);
|
||||
$qb->method('createNamedParameter')->willReturnArgument(0);
|
||||
$qb->method('executeQuery')->willReturn($result);
|
||||
|
||||
// Verify setMaxResults and setFirstResult are called with correct values
|
||||
$qb->expects($this->once())
|
||||
->method('setMaxResults')
|
||||
->with(50)
|
||||
->willReturnSelf();
|
||||
$qb->expects($this->once())
|
||||
->method('setFirstResult')
|
||||
->with(100)
|
||||
->willReturnSelf();
|
||||
|
||||
// Mock expression builder
|
||||
$expr->method('eq')->willReturn('mocked_condition');
|
||||
|
||||
// Mock result set
|
||||
$result->method('fetch')->willReturnOnConsecutiveCalls(
|
||||
['userid' => 'user1'],
|
||||
['userid' => 'user2'],
|
||||
false
|
||||
);
|
||||
$result->expects($this->once())->method('closeCursor');
|
||||
|
||||
$this->db->method('getQueryBuilder')->willReturn($qb);
|
||||
|
||||
$userIds = $this->storage->getAllUsersWithTokens(50, 100);
|
||||
|
||||
$this->assertEquals(['user1', 'user2'], $userIds);
|
||||
}
|
||||
|
||||
public function testGetAllUsersWithTokensWithZeroLimitDoesNotSetMaxResults(): void {
|
||||
$qb = $this->createMock(IQueryBuilder::class);
|
||||
$expr = $this->createMock(IExpressionBuilder::class);
|
||||
$result = $this->createMock(IResult::class);
|
||||
|
||||
// Chain builder methods
|
||||
$qb->method('select')->willReturnSelf();
|
||||
$qb->method('from')->willReturnSelf();
|
||||
$qb->method('where')->willReturnSelf();
|
||||
$qb->method('andWhere')->willReturnSelf();
|
||||
$qb->method('expr')->willReturn($expr);
|
||||
$qb->method('createNamedParameter')->willReturnArgument(0);
|
||||
$qb->method('executeQuery')->willReturn($result);
|
||||
|
||||
// setMaxResults should NOT be called when limit is 0
|
||||
$qb->expects($this->never())
|
||||
->method('setMaxResults');
|
||||
|
||||
// setFirstResult should NOT be called when offset is 0
|
||||
$qb->expects($this->never())
|
||||
->method('setFirstResult');
|
||||
|
||||
// Mock expression builder
|
||||
$expr->method('eq')->willReturn('mocked_condition');
|
||||
|
||||
// Mock result set
|
||||
$result->method('fetch')->willReturn(false);
|
||||
$result->expects($this->once())->method('closeCursor');
|
||||
|
||||
$this->db->method('getQueryBuilder')->willReturn($qb);
|
||||
|
||||
$this->storage->getAllUsersWithTokens(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
+14
-14
@@ -566,16 +566,16 @@
|
||||
},
|
||||
{
|
||||
"name": "phpunit/phpunit",
|
||||
"version": "10.5.60",
|
||||
"version": "10.5.63",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
||||
"reference": "f2e26f52f80ef77832e359205f216eeac00e320c"
|
||||
"reference": "33198268dad71e926626b618f3ec3966661e4d90"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f2e26f52f80ef77832e359205f216eeac00e320c",
|
||||
"reference": "f2e26f52f80ef77832e359205f216eeac00e320c",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/33198268dad71e926626b618f3ec3966661e4d90",
|
||||
"reference": "33198268dad71e926626b618f3ec3966661e4d90",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -596,7 +596,7 @@
|
||||
"phpunit/php-timer": "^6.0.0",
|
||||
"sebastian/cli-parser": "^2.0.1",
|
||||
"sebastian/code-unit": "^2.0.0",
|
||||
"sebastian/comparator": "^5.0.4",
|
||||
"sebastian/comparator": "^5.0.5",
|
||||
"sebastian/diff": "^5.1.1",
|
||||
"sebastian/environment": "^6.1.0",
|
||||
"sebastian/exporter": "^5.1.4",
|
||||
@@ -647,7 +647,7 @@
|
||||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
||||
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.60"
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.63"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -671,7 +671,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-06T07:50:42+00:00"
|
||||
"time": "2026-01-27T05:48:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/cli-parser",
|
||||
@@ -843,16 +843,16 @@
|
||||
},
|
||||
{
|
||||
"name": "sebastian/comparator",
|
||||
"version": "5.0.4",
|
||||
"version": "5.0.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/comparator.git",
|
||||
"reference": "e8e53097718d2b53cfb2aa859b06a41abf58c62e"
|
||||
"reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e8e53097718d2b53cfb2aa859b06a41abf58c62e",
|
||||
"reference": "e8e53097718d2b53cfb2aa859b06a41abf58c62e",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55dfef806eb7dfeb6e7a6935601fef866f8ca48d",
|
||||
"reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -908,7 +908,7 @@
|
||||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/comparator/issues",
|
||||
"security": "https://github.com/sebastianbergmann/comparator/security/policy",
|
||||
"source": "https://github.com/sebastianbergmann/comparator/tree/5.0.4"
|
||||
"source": "https://github.com/sebastianbergmann/comparator/tree/5.0.5"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -928,7 +928,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-09-07T05:25:07+00:00"
|
||||
"time": "2026-01-24T09:25:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/complexity",
|
||||
@@ -1687,5 +1687,5 @@
|
||||
"platform-overrides": {
|
||||
"php": "8.1"
|
||||
},
|
||||
"plugin-api-version": "2.6.0"
|
||||
"plugin-api-version": "2.9.0"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user