Compare commits

..
Author SHA1 Message Date
Arc fa1fec87c7 fundle 2026-05-01 12:32:44 +01:00
Arc 74a1dd5ea6 trans 2026-05-01 12:31:03 +01:00
Arc ef47f1660d make 2026-05-01 12:28:43 +01:00
Arc 4341c329fd extend to repo manifests 2026-05-01 12:16:39 +01:00
Arc fdf3ab2688 init 2026-05-01 12:10:11 +01:00
42 changed files with 270 additions and 427 deletions
-79
View File
@@ -1,79 +0,0 @@
# Feature Spec: [FEAT-XXX] - Short Descriptive Title
**Milestone:** [e.g. MVP Core / Performance & Polish / Extension Framework]
**Priority:** Must-have / Should-have / Nice-to-have
**Spec Owner:** [Your Name / AI Agent Name]
**Status:** Draft → Under Review → Approved → Implemented → Verified
## 1. Purpose & User Story
As a [user type], I want [goal] so that [benefit].
_(One clear sentence. Keep it concise.)_
## 2. Functional Requirements
- [ ] REQ-1: [Clear, testable description]
- [ ] REQ-2: ...
- [ ] REQ-3: ...
_(List what the feature must do. Make each item verifiable.)_
## 3. Non-Functional Requirements
- **Performance:** [e.g. Latency < 800ms at p95, max 5k tokens, etc.]
- **Security / Safety:** [e.g. Input validation, no raw errors to user, etc.]
- **Compatibility:** [e.g. Works with all existing wallet backends, no breaking changes for extensions]
- **UI/UX:** [e.g. Follows existing Quasar/Vue patterns in wallet.js]
- **Other:** [cost, scalability, accessibility, etc.]
## 4. Technical Approach (Optional recommended for complex features)
- Proposed solution: [e.g. Extend existing CRUD in lnbits/core/, new extension, middleware change, etc.]
- Key files to modify: [list expected files]
- New dependencies: [none / specific package + version]
- Migration / Database changes: [yes/no + description]
## 5. Success Criteria & Verification
**Must pass all of these to be accepted:**
- [ ] All functional requirements (REQ-\*) implemented and tested
- [ ] Non-functional requirements met (performance, security, etc.)
- [ ] Relevant tests pass: `make test-unit`, `make test-api`, `make test-regtest` (as applicable)
- [ ] `make check` passes (ruff, mypy, pyright, prettier, checkbundle)
- [ ] Constitution compliance: All changes respect CONSTITUTION.md
- [ ] Backward compatibility: No breakage for existing extensions or wallet backends
- [ ] Documentation updated (if applicable: README, OpenAPI, inline comments)
**Additional Tests / Edge Cases:**
- [ ] Test invalid inputs / error paths
- [ ] Test with FakeWallet and at least one real backend
- [ ] Test with multiple extensions installed
## 6. Safety & Risk Assessment
- Potential risks: [e.g. Payment flow impact, key exposure, extension conflicts]
- Mitigation: [how addressed]
- Security review needed: [yes/no]
## 7. Implementation Notes (for AI / Developer)
- Style to match: Existing code patterns in `lnbits/core/` and `wallet.js`
- Surgical changes only (per AGENTS.md)
- Any known gotchas or dependencies on other features:
## 8. Acceptance Checklist (Sign-off)
- [ ] Spec reviewed and approved by project owner
- [ ] Implementation completed
- [ ] Verification steps passed
- [ ] PR created with link to this spec
- [ ] Constitution & AGENTS.md compliance confirmed
---
**Created:** [Date]
**Last Updated:** [Date]
**Approved By:** [Name / "Approved"]
-122
View File
@@ -1,122 +0,0 @@
# AGENTS.md - Instructions for All AI Coding Agents
This file is the **master instruction manual** for any AI agent (Grok, Claude, Cursor, Aider, etc.) working on LNbits.
## 1. Core Rule (Never Break This)
**You MUST read and strictly follow `CONSTITUTION.md` before doing any planning, coding, refactoring, or suggesting changes.**
- Every single change, feature, extension, or fix **must comply** with the Constitution.
- If you detect a violation (in new code or existing code), you **must** flag it immediately and propose a fix or ask for clarification.
- Constitution > any other instruction (including this file, user prompts, or previous conversations).
## 2. Mandatory Development Workflow
For **any non-trivial task** (new feature, bug fix, refactor, extension change):
1. **Constitution Check** Re-read relevant sections of `CONSTITUTION.md`
2. **Feature Spec Check** If a spec exists in `.specify/`, follow it exactly. If none exists, ask the user for clarification or propose a minimal spec.
3. **Think Step-by-Step** Follow the "Think Before Coding" and "Simplicity First" guidelines below.
4. **Surgical Changes** Only touch what is necessary.
5. **Implement**
6. **Verify** Run relevant tests (`make test-unit`, `make test-api`, etc.), `make check`, and confirm compliance.
7. **Report** Always include a clear summary.
Use the following response format:
```markdown
## Constitution & Spec Compliance
- Relevant Constitution sections checked: [list or quote key rules]
- Feature Spec followed: [yes / no / proposed]
## Assumptions & Plan
- Assumptions: ...
- Plan:
1. ...
2. ...
- Tradeoffs considered: ...
## Changes Made
- Files changed: ...
- Summary of modifications:
## Verification
- [ ] Passes `make check`
- [ ] Relevant tests pass (`make test-xxx`)
- [ ] Complies with Constitution
- [ ] Surgical & minimal (no unrelated changes)
```
## 3. Behavioral Guidelines (Merged & Project-Specific)
**Think Before Coding**
- Don't assume. Don't hide confusion. Surface tradeoffs.
- State assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them — don't pick silently.
- If something is unclear (especially regarding wallets, extensions, or funding sources), stop and ask.
**Simplicity First**
- Minimum code that solves the problem. Nothing speculative.
- No features beyond what was asked.
- No abstractions for single-use code.
- Respect LNbits' lean core philosophy: new functionality should preferably go into an **extension** unless it truly belongs in core.
**Surgical Changes**
- Touch only what you must. Clean up only your own mess.
- Match existing style (Python: Black + Ruff rules; JS: Prettier).
- Do not "improve" or refactor adjacent code unless explicitly asked.
- When editing, remove only imports/variables/functions made unused **by your changes**.
- Never delete pre-existing dead code unless instructed.
**Goal-Driven Execution**
- Transform tasks into verifiable goals.
- For tests: Write or update tests first when fixing bugs or adding behavior.
- Always consider impact on existing extensions and multiple wallet backends (LND, CLN, Boltz, VoidWallet, etc.).
## 4. LNbits-Specific Rules
- **Extensions First**: Core should remain lean. Prefer implementing new features as extensions unless they are fundamental to wallets, security, or the API.
- **Testing**: Use `FakeWallet` for unit/API tests. Regtest tests for full Lightning flows. Never break existing test targets in the Makefile.
- **Dependencies**: Never add new dependencies without updating `pyproject.toml` and getting approval.
- **Frontend**: JS/Vue code (e.g. `wallet.js`) must follow existing patterns and pass `make checkbundle` when static files are affected.
- **Database / Migrations**: Do not make raw SQL changes. Use existing CRUD/services and migration tooling.
- **Security**: Be extremely cautious with anything touching payments, keys, LNURL, Bolt11, or admin routes.
- **Tools**: Use `uv run` for all commands. Prefer Makefile targets (`make format`, `make check`, `make test-xxx`).
- **Generated Files**: Never modify gRPC files or other generated code.
## 5. Forbidden Behaviors
- Ignoring Constitution rules to "be helpful"
- Large refactors without a spec or explicit request
- Adding features "for future use"
- Breaking backward compatibility for extensions or existing wallet backends
- Committing code that fails `make check` or relevant tests
- Exposing raw errors/stack traces to users
- Using synchronous code in hot async paths without justification
## 6. How to Handle This File + Constitution
When the user gives you a task, start your response with:
> Following LNbits CONSTITUTION.md and AGENTS.md...
Then proceed with the structured format above.
---
**These guidelines are working if:**
- Fewer unnecessary changes appear in diffs
- Clarifying questions come **before** implementation
- All changes respect the lean, extension-first, security-first nature of LNbits
- Tests and `make check` continue to pass
Last Updated: April 2026
-136
View File
@@ -1,136 +0,0 @@
# CONSTITUTION.md
This is the immutable constitution of the LNbits project.
Every feature spec, code change, refactor, extension, or decision by humans or AI agents **must comply** with this document.
Changes to this file require explicit approval from the project owner/maintainers.
## 1. Project Overview
**Project Name:** LNbits
**Core Purpose:** Free and open-source Lightning wallet and accounts system. A lightweight Python server that sits on top of any Lightning funding source, providing safe isolated wallets, a clean REST API, and a powerful extension system for adding features rapidly.
**Target Users:** Individuals, communities, merchants, developers, and enterprises building on Bitcoin/Lightning (self-hosted or as part of larger stacks).
**High-Level Success Criteria:**
- Reliable multi-wallet Lightning accounting with any backend (20+ supported funding sources)
- Secure, extensible via 60+ extensions without bloating core
- High code quality, test coverage, and backward compatibility for extensions
- Production-ready performance and security for real Bitcoin value
**Version:** 1.5.4
## 2. Technology Stack (Strict)
- **Language:** Python >=3.10, <3.13 (strictly enforced via `pyproject.toml`)
- **Framework:** FastAPI + Starlette (backend API)
- **Frontend:** Vue.js + Quasar framework, with bundled static assets
- **Database:** SQLite (aiosqlite) by default, PostgreSQL (asyncpg/psycopg2) supported via `LNBITS_DATABASE_URL`
- **Async Runtime:** uvloop preferred
- **Dependency Management:** uv + pyproject.toml (Hatchling build backend). Use `uv run` for all commands.
- **Wallet Backends:** Abstracted via `lnbits.wallets` support for LND, Core Lightning, Phoenixd, Boltz, Breez SDK, Liquid, VoidWallet fallback, etc. New backends must follow existing abstraction.
- **Other Key Libs:** SQLAlchemy, Pydantic (v1), Loguru, Jinja2, LNURL, Bolt11, etc. (see `pyproject.toml` for pinned versions)
- **Build/Frontend Tools:** npm for bundling (Quasar/Vue), Prettier for JS/CSS (check Makefile targets)
**Forbidden:**
- Adding new top-level dependencies without updating `pyproject.toml` **and** team approval
- Using synchronous blocking calls in async paths (except where explicitly justified)
- Direct database queries outside of CRUD layers or core services
- Modifying generated files (e.g., gRPC files in wallets/boltz_grpc_files or lnd_grpc_files)
## 3. Architecture & Code Organization (Mandatory Rules)
- **Core Principle:** Modular monolith with heavy emphasis on **extensions**. All non-core features must live in extensions (installed via `lnbits/extensions`). Core stays lean.
- **Backend Structure:**
- `lnbits/core/` for core models, CRUD, services, routers, tasks
- `lnbits/wallets/` for funding source abstractions
- `lnbits/extensions/` for installed/upgradeable extensions (do not commit large extensions to core repo)
- `lnbits/static/` for bundled frontend assets (managed via npm bundle)
- **Key Rules:**
- Use dependency injection and FastAPI routers properly
- Extensions register routes/tasks via `register_ext_routes` / `register_ext_tasks`
- Database migrations handled centrally (extension-specific migrations)
- All new endpoints must be under proper versioning/prefixing where applicable
- Frontend: Vue 2/Quasar components in `wallet.js` style (or updated) keep reactive, use LNbits.utils helpers
- No circular imports; respect existing middleware order (e.g., InstalledExtensionMiddleware before ExtensionsRedirectMiddleware)
**Exclusions** (do not lint/format these):
- `lnbits/extensions/`, `lnbits/upgrades/`, generated gRPC files, static/vendor bundles
## 4. Code Quality & Style
- **Formatting & Linting:**
- Python: Black (line-length 88), Ruff (with selected rules: F, E, W, I, A, C, N, UP, RUF, B, S), MyPy (strict where possible), Pyright
- JS/Frontend: Prettier
- Run via Makefile: `make format` and `make check`
- **Type Checking:** MyPy + Pyright enforced on `lnbits/`, `tests/`, `tools/`
- **Testing Requirements:**
- Unit, API, wallet, and regtest tests via pytest (see Makefile targets)
- New core code or critical paths: high coverage expected (`--cov=lnbits`)
- Extensions should include their own tests where possible
- **Error Handling & Logging:** Use Loguru with structured context. Never expose raw stack traces to end users. Graceful fallbacks (e.g., VoidWallet on funding source failure).
- **Pre-commit:** Strongly recommended (`make install-pre-commit-hook`)
- **Bundle Integrity:** Frontend bundles must pass `make checkbundle` before commits affecting static files.
## 5. AI / LLM Usage Standards (if any agents or future AI features are added)
- Any new AI-powered features (e.g., via extensions) must use structured outputs (Pydantic/JSON mode)
- Store prompts/templates versioned in the extension
- Prefer deterministic behavior for financial/security paths (low temperature)
- All AI outputs involving value/money must be validated server-side
- Safety: Never allow untrusted model output to influence payments, wallet balances, or admin actions without guardrails
## 6. Safety, Security & Ethics
- **Critical:** Handle real Bitcoin/Lightning value → security-first mindset
- Per-wallet isolation with separate admin/invoice/read keys
- Rate limiting (SlowAPI) and IP blocking middleware mandatory
- Sanitize all user inputs; validate LNURL, Bolt11, etc.
- PII: Minimal collection; respect privacy (no unnecessary logging of sensitive data)
- Funding source failures: Graceful degradation to VoidWallet + clear logging
- Extensions: Hash-verified installs for vetted extensions; careful with custom extension paths
- Audit logging via AuditMiddleware
- Forbidden: Hard-coded secrets, insecure subprocess calls without review, SQL injection risks (use SQLAlchemy properly)
## 7. Performance & Cost Budgets
- Keep core lightweight extensions handle heavy features
- Async-first (uvloop, asyncpg/aiosqlite)
- Reasonable retry logic for funding source connections (see `check_funding_source`)
- Frontend: Optimized bundles (checkbundle enforced)
- No unnecessary blocking operations in request paths
## 8. Development Workflow (Spec-Driven where possible)
- **All significant changes** should follow Spec-Driven Development:
- Create/update Feature Spec in `.specify/` folder (or equivalent)
- Reference this Constitution in every spec and PR
- Use Makefile targets for format/check/test
- Tests run with `FakeWallet` by default for unit/API; regtest for full flows
- PRs must:
- Pass `make check` and relevant tests
- Include Constitution compliance notes (via AGENTS.md/CLAUDE.md)
- Not break existing extensions or wallet backends
- Branching: Protect main; use feature branches
- Extensions: Develop separately; core repo focuses on framework stability
## 9. Decision Hierarchy (What Takes Precedence)
1. This Constitution
2. Approved Feature Spec / Milestone
3. Existing tests and backward compatibility (especially for extensions and wallet backends)
4. Project maintainers / owner decision
5. Everything else (including helpful AI suggestions)
If conflict: Stop, document the issue, and seek clarification from maintainers.
## 10. Amendment Process
- This Constitution can only be changed with explicit approval from project maintainers.
- All changes must be dated, versioned, and reflected in `AGENTS.md`.
- Minor clarifications can be proposed via PR with justification.
---
**Last Updated:** April 2026 (based on v1.5.4)
**Owner/Maintainers Approval:** LNbits Team
+1 -1
View File
@@ -5,7 +5,7 @@
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) [![license-badge]](LICENSE) [![docs-badge]][docs] ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [![hardware: LNBitsShop](https://img.shields.io/badge/hardware-LNBitsShop-7C3AED)](https://shop.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) [![license-badge]](LICENSE) [![docs-badge]][docs] ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [![hardware: LNBitsShop](https://img.shields.io/badge/hardware-LNBitsShop-7C3AED)](https://shop.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) [<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
<img alt="lnbits_head" src="docs/assets/header.jpg" />
[![tip-hero](https://img.shields.io/badge/TipJar-LNBits%20Hero-9b5cff?labelColor=6b7280&logo=lightning&logoColor=white)](https://demo.lnbits.com/tipjar/DwaUiE4kBX6mUW6pj3X5Kg)
+1
View File
@@ -14,6 +14,7 @@ nav_order: 1
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# LNBits Admin UI
+1
View File
@@ -14,6 +14,7 @@ nav_order: 1
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# Backend Wallet Comparison Table
+1 -1
View File
@@ -11,7 +11,7 @@ nav_order: 1
</picture>
</a>
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) ![License: MIT](https://img.shields.io/badge/License-MIT-blue) ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043) ![License: MIT](https://img.shields.io/badge/License-MIT-blue) ![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow) [![explore: LNbits extensions](https://img.shields.io/badge/explore-LNbits%20extensions-10B981)](https://extensions.lnbits.com/) [<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits) <img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">
# Basic installation
+1
View File
@@ -14,6 +14,7 @@ nav_order: 1
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# LNbits Super User (SU)
+1
View File
@@ -14,6 +14,7 @@ nav_order: 1
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# LNbits Roles: A Quick Overview
+1
View File
@@ -14,6 +14,7 @@ nav_order: 3
![phase: stable](https://img.shields.io/badge/phase-stable-2EA043)
![PRs: welcome](https://img.shields.io/badge/PRs-Welcome-yellow)
[<img src="https://img.shields.io/badge/community_chat-Telegram-24A1DE">](https://t.me/lnbits)
[<img src="https://img.shields.io/badge/supported_by-%3E__OpenSats-f97316">](https://opensats.org)
# Backend wallets
+66 -5
View File
@@ -43,6 +43,8 @@ class ExplicitRelease(BaseModel):
details_link: str | None
paid_features: str | None
pay_link: str | None
admin_only: bool = False
super_user_only: bool = False
def is_version_compatible(self):
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
@@ -52,6 +54,8 @@ class GitHubRelease(BaseModel):
id: str
organisation: str
repository: str
admin_only: bool = False
super_user_only: bool = False
class Manifest(BaseModel):
@@ -83,6 +87,8 @@ class ExtensionConfig(BaseModel):
warning: str | None = ""
min_lnbits_version: str | None
max_lnbits_version: str | None
admin_only: bool = False
super_user_only: bool = False
def is_version_compatible(self) -> bool:
return is_lnbits_version_ok(self.min_lnbits_version, self.max_lnbits_version)
@@ -195,6 +201,8 @@ class ExtensionRelease(BaseModel):
cost_sats: int | None = None
paid_sats: int | None = 0
payment_hash: str | None = None
admin_only: bool = False
super_user_only: bool = False
@property
def archive_url(self) -> str:
@@ -263,6 +271,8 @@ class ExtensionRelease(BaseModel):
paid_features=e.paid_features,
repo=e.repo,
icon=e.icon,
admin_only=e.admin_only,
super_user_only=e.super_user_only,
)
@classmethod
@@ -289,6 +299,8 @@ class ExtensionRelease(BaseModel):
release.min_lnbits_version = config.min_lnbits_version
release.max_lnbits_version = config.max_lnbits_version
release.is_version_compatible = config.is_version_compatible()
release.admin_only = config.admin_only
release.super_user_only = config.super_user_only
release.icon = icon_to_github_url(f"{org}/{repo}", config.tile)
@@ -336,6 +348,8 @@ class ExtensionMeta(BaseModel):
paid_features: str | None = None
has_paid_release: bool = False
has_free_release: bool = False
admin_only: bool = False
super_user_only: bool = False
class InstallableExtension(BaseModel):
@@ -399,6 +413,16 @@ class InstallableExtension(BaseModel):
return False
return self.meta.pay_to_enable.required is True
@property
def is_admin_only(self) -> bool:
return settings.is_admin_extension(self.id) or bool(
self.meta and self.meta.admin_only
)
@property
def is_super_user_only(self) -> bool:
return bool(self.meta and self.meta.super_user_only)
async def download_archive(self):
logger.info(f"Downloading extension {self.name} ({self.installed_version}).")
ext_zip_file = self.zip_path
@@ -454,6 +478,16 @@ class InstallableExtension(BaseModel):
self.name = config_json.get("name")
self.short_description = config_json.get("short_description")
if self.meta:
self.meta.admin_only = config_json.get("admin_only", False)
self.meta.super_user_only = config_json.get("super_user_only", False)
if self.meta.installed_release:
self.meta.installed_release.admin_only = config_json.get(
"admin_only", False
)
self.meta.installed_release.super_user_only = config_json.get(
"super_user_only", False
)
if (
self.meta
@@ -496,6 +530,10 @@ class InstallableExtension(BaseModel):
return
if not release.is_version_compatible:
return
if not self.meta:
self.meta = ExtensionMeta()
self.meta.admin_only = release.admin_only
self.meta.super_user_only = release.super_user_only
if not self.meta or not self.meta.latest_release:
meta = self.meta or ExtensionMeta()
meta.latest_release = release
@@ -558,6 +596,13 @@ class InstallableExtension(BaseModel):
github_release.organisation, github_release.repository
)
source_repo = f"{github_release.organisation}/{github_release.repository}"
admin_only = github_release.admin_only or config.admin_only
super_user_only = github_release.super_user_only or config.super_user_only
latest_extension_release = ExtensionRelease.from_github_release(
source_repo, latest_release
)
latest_extension_release.admin_only = admin_only
latest_extension_release.super_user_only = super_user_only
return InstallableExtension(
id=github_release.id,
name=config.name,
@@ -569,9 +614,9 @@ class InstallableExtension(BaseModel):
config.tile,
),
meta=ExtensionMeta(
latest_release=ExtensionRelease.from_github_release(
source_repo, latest_release
),
admin_only=admin_only,
super_user_only=super_user_only,
latest_release=latest_extension_release,
),
)
except Exception as e:
@@ -580,7 +625,12 @@ class InstallableExtension(BaseModel):
@classmethod
def from_explicit_release(cls, e: ExplicitRelease) -> InstallableExtension:
meta = ExtensionMeta(archive=e.archive, dependencies=e.dependencies)
meta = ExtensionMeta(
archive=e.archive,
dependencies=e.dependencies,
admin_only=e.admin_only,
super_user_only=e.super_user_only,
)
return InstallableExtension(
id=e.id,
name=e.name,
@@ -610,6 +660,8 @@ class InstallableExtension(BaseModel):
short_description=config_json.get("short_description"),
icon=config_json.get("tile"),
meta=ExtensionMeta(
admin_only=config_json.get("admin_only", False),
super_user_only=config_json.get("super_user_only", False),
installed_release=ExtensionRelease(
name=ext_id,
version=version,
@@ -617,7 +669,9 @@ class InstallableExtension(BaseModel):
source_repo=f"{conf_path}",
min_lnbits_version=config_json.get("min_lnbits_version"),
max_lnbits_version=config_json.get("max_lnbits_version"),
)
admin_only=config_json.get("admin_only", False),
super_user_only=config_json.get("super_user_only", False),
),
),
)
@@ -719,6 +773,13 @@ class InstallableExtension(BaseModel):
repo_releases = await ExtensionRelease.get_github_releases(
r.organisation, r.repository
)
for repo_release in repo_releases:
repo_release.admin_only = (
repo_release.admin_only or r.admin_only
)
repo_release.super_user_only = (
repo_release.super_user_only or r.super_user_only
)
extension_releases += repo_releases
for e in manifest.extensions:
+23 -2
View File
@@ -79,7 +79,11 @@ async def api_install_extension(data: CreateExtension):
raise HTTPException(HTTPStatus.BAD_REQUEST, "Incompatible extension version.")
release.payment_hash = data.payment_hash
ext_meta = ExtensionMeta(installed_release=release)
ext_meta = ExtensionMeta(
installed_release=release,
admin_only=release.admin_only,
super_user_only=release.super_user_only,
)
ext_info = InstallableExtension(
id=data.ext_id,
name=data.ext_id,
@@ -176,6 +180,19 @@ async def api_update_pay_to_enable(
)
def _check_enable_extension_access(
ext_id: str, ext: InstallableExtension, account_id: AccountId
) -> None:
if ext.is_super_user_only and not settings.is_super_user(account_id.id):
raise HTTPException(
HTTPStatus.FORBIDDEN, f"User not authorized for extension '{ext_id}'."
)
if ext.is_admin_only and not settings.is_admin_user(account_id.id):
raise HTTPException(
HTTPStatus.FORBIDDEN, f"User not authorized for extension '{ext_id}'."
)
@extension_router.put("/{ext_id}/enable")
async def api_enable_extension(
ext_id: str, account_id: AccountId = Depends(check_account_id_exists)
@@ -191,6 +208,7 @@ async def api_enable_extension(
raise ValueError(f"Extension '{ext_id}' is not installed.")
if not ext.active:
raise ValueError(f"Extension '{ext_id}' is not activated.")
_check_enable_extension_access(ext_id, ext, account_id)
user_ext = await get_user_extension(account_id.id, ext_id)
if not user_ext:
@@ -464,6 +482,8 @@ async def get_extension_release(org: str, repo: str, tag_name: str):
"min_lnbits_version": config.min_lnbits_version,
"is_version_compatible": config.is_version_compatible(),
"warning": config.warning,
"admin_only": config.admin_only,
"super_user_only": config.super_user_only,
}
except Exception as exc:
raise HTTPException(
@@ -573,7 +593,8 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
(True for version in db_versions if version.db == ext.id), False
),
"isAvailable": ext.id in all_ext_ids,
"isAdminOnly": ext.id in settings.lnbits_admin_extensions,
"isAdminOnly": ext.is_admin_only,
"isSuperUserOnly": ext.is_super_user_only,
"isActive": ext.id not in inactive_extensions,
"latestRelease": (
dict(ext.meta.latest_release)
+2 -4
View File
@@ -35,7 +35,7 @@ if settings.lnbits_database_url:
else:
if not database_uri.startswith("postgres://"):
raise ValueError(
"Please use the 'postgres://...' format for the database URL."
"Please use the 'postgres://...' " "format for the database URL."
)
DB_TYPE = POSTGRES
@@ -560,9 +560,7 @@ class Filters(BaseModel, Generic[TFilterModel]):
def pagination(self) -> str:
stmt = ""
if self.limit == 0:
self.limit = 1000
self.limit = 10 if self.limit is None else self.limit
self.limit = self.limit or 10
stmt += f"LIMIT {min(1000, self.limit)} "
if self.offset:
stmt += f"OFFSET {self.offset}"
+14 -1
View File
@@ -14,6 +14,7 @@ from lnbits.core.crud import (
get_account,
get_account_by_email,
get_account_by_username,
get_installed_extension,
get_user_active_extensions_ids,
get_user_from_account,
get_wallet_for_key,
@@ -424,7 +425,19 @@ async def check_user_extension_access(
Check if the user has access to a particular extension.
Raises HTTP Forbidden if the user is not allowed.
"""
if settings.is_admin_extension(ext_id) and not settings.is_admin_user(user_id):
ext = await get_installed_extension(ext_id, conn=conn)
is_admin_only = settings.is_admin_extension(ext_id) or bool(
ext and ext.meta and ext.meta.admin_only
)
is_super_user_only = bool(ext and ext.meta and ext.meta.super_user_only)
if is_super_user_only and not settings.is_super_user(user_id):
return SimpleStatus(
success=False,
message=f"User not authorized for extension '{ext_id}'.",
)
if is_admin_only and not settings.is_admin_user(user_id):
return SimpleStatus(
success=False, message=f"User not authorized for extension '{ext_id}'."
)
+1 -1
View File
@@ -284,7 +284,7 @@ class ThemesSettings(LNbitsSettings):
lnbits_custom_image: str | None = Field(default="/static/images/logos/lnbits.svg")
lnbits_ad_space_title: str = Field(default="Supported by")
lnbits_ad_space: str = Field(
default="https://shop.lnbits.com/;/static/images/bitcoin-shop-banner.png;/static/images/bitcoin-shop-banner.png,https://affil.trezor.io/aff_c?offer_id=169&aff_id=33845;/static/images/bitcoin-hardware-wallet.png;/static/images/bitcoin-hardware-wallet.png,https://firefish.io/?ref=lnbits;/static/images/firefish.png;/static/images/firefish.png"
default="https://shop.lnbits.com/;/static/images/bitcoin-shop-banner.png;/static/images/bitcoin-shop-banner.png,https://affil.trezor.io/aff_c?offer_id=169&aff_id=33845;/static/images/bitcoin-hardware-wallet.png;/static/images/bitcoin-hardware-wallet.png,https://firefish.io/?ref=lnbits;/static/images/firefish.png;/static/images/firefish.png,https://opensats.org/;/static/images/open-sats.png;/static/images/open-sats.png"
) # sneaky sneaky
lnbits_ad_space_enabled: bool = Field(default=False)
lnbits_allowed_currencies: list[str] = Field(default=[])
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -191,6 +191,7 @@ window.localisation.br = {
only_admins_can_create_extensions:
'Apenas contas de administrador podem criar extensões',
admin_only: 'Apenas para Administração',
super_user_only: 'Apenas para superusuário',
make_user_admin: 'Tornar usuário administrador',
revoke_admin: 'Revogar Admin',
new_version: 'Nova Versão',
+1
View File
@@ -135,6 +135,7 @@ window.localisation.cn = {
all: '全部',
only_admins_can_install: '(只有管理员账户可以安装扩展)',
admin_only: '仅限管理员',
super_user_only: '仅限超级用户',
new_version: '新版本',
extension_depends_on: '依赖于:',
extension_rating_soon: '即将推出评分',
+1
View File
@@ -140,6 +140,7 @@ window.localisation.cs = {
only_admins_can_install:
'(Pouze administrátorské účty mohou instalovat rozšíření)',
admin_only: 'Pouze pro adminy',
super_user_only: 'Pouze pro superuživatele',
new_version: 'Nová verze',
extension_depends_on: 'Závisí na:',
extension_rating_soon: 'Hodnocení brzy dostupné',
+1
View File
@@ -143,6 +143,7 @@ window.localisation.de = {
only_admins_can_install:
'(Nur Administratorkonten können Erweiterungen installieren)',
admin_only: 'Nur für Admins',
super_user_only: 'Nur für Superuser',
new_version: 'Neue Version',
extension_depends_on: 'Hängt ab von:',
extension_rating_soon: 'Bewertungen sind bald verfügbar',
+1
View File
@@ -189,6 +189,7 @@ window.localisation.en = {
only_admins_can_create_extensions:
'Only admin accounts can create extensions',
admin_only: 'Admin Only',
super_user_only: 'Super User Only',
make_user_admin: 'Make User Admin',
revoke_admin: 'Revoke Admin',
new_version: 'New Version',
+1
View File
@@ -142,6 +142,7 @@ window.localisation.es = {
only_admins_can_install:
'(Solo las cuentas de administrador pueden instalar extensiones)',
admin_only: 'Solo administradores',
super_user_only: 'Solo superusuario',
new_version: 'Nueva Versión',
extension_depends_on: 'Depende de:',
extension_rating_soon: 'Calificaciones próximamente',
+1
View File
@@ -154,6 +154,7 @@ window.localisation.fi = {
all: 'Kaikki',
only_admins_can_install: '(Vain pääkäyttäjät voivat asentaa laajennuksia)',
admin_only: 'Pääkäyttäjille',
super_user_only: 'Vain superkäyttäjälle',
new_version: 'Uusi versio',
extension_depends_on: 'Edellyttää:',
extension_rating_soon: 'Arvostelut on tulossa pian',
+1
View File
@@ -145,6 +145,7 @@ window.localisation.fr = {
only_admins_can_install:
'Seuls les comptes administrateurs peuvent installer des extensions',
admin_only: 'Réservé aux administrateurs',
super_user_only: 'Réservé au super utilisateur',
new_version: 'Nouvelle version',
extension_depends_on: 'Dépend de :',
extension_rating_soon: 'Notes des utilisateurs à venir bientôt',
+1
View File
@@ -142,6 +142,7 @@ window.localisation.it = {
only_admins_can_install:
'Solo gli account amministratore possono installare estensioni.',
admin_only: 'Solo amministratore',
super_user_only: 'Solo superutente',
new_version: 'Nuova Versione',
extension_depends_on: 'Dipende da:',
extension_rating_soon: 'Valutazioni in arrivo',
+1
View File
@@ -137,6 +137,7 @@ window.localisation.jp = {
only_admins_can_install:
'(管理者アカウントのみが拡張機能をインストールできます)',
admin_only: '管理者のみ',
super_user_only: 'スーパー ユーザーのみ',
new_version: '新しいバージョン',
extension_depends_on: '依存先:',
extension_rating_soon: '評価は近日公開',
+1
View File
@@ -139,6 +139,7 @@ window.localisation.kr = {
all: '전체',
only_admins_can_install: '(관리자 계정만이 확장 기능을 설치할 수 있습니다)',
admin_only: '관리자 전용',
super_user_only: '슈퍼유저 전용',
new_version: '새로운 버전',
extension_depends_on: '의존성 존재:',
extension_rating_soon: '평점 기능도 곧 구현됩니다',
+1
View File
@@ -143,6 +143,7 @@ window.localisation.nl = {
only_admins_can_install:
'Alleen beheerdersaccounts kunnen extensies installeren',
admin_only: 'Alleen beheerder',
super_user_only: 'Alleen supergebruiker',
new_version: 'Nieuwe Versie',
extension_depends_on: 'Afhankelijk van:',
extension_rating_soon: 'Beoordelingen binnenkort beschikbaar',
+1
View File
@@ -141,6 +141,7 @@ window.localisation.pi = {
all: 'Arr',
only_admins_can_install: '(Only admin accounts can install extensions)',
admin_only: "Cap'n Only",
super_user_only: "Big Cap'n Only",
new_version: 'New Version',
extension_depends_on: 'Depends on:',
extension_rating_soon: "Ratings a'comin' soon",
+1
View File
@@ -140,6 +140,7 @@ window.localisation.pl = {
only_admins_can_install:
'Tylko konta administratorów mogą instalować rozszerzenia',
admin_only: 'Tylko dla administratora',
super_user_only: 'Tylko dla superużytkownika',
new_version: 'Nowa wersja',
extension_depends_on: 'Zależy od:',
extension_rating_soon: 'Oceny będą dostępne wkrótce',
+1
View File
@@ -141,6 +141,7 @@ window.localisation.pt = {
only_admins_can_install:
'Apenas contas de administrador podem instalar extensões.',
admin_only: 'Apenas para administradores',
super_user_only: 'Apenas para superusuário',
new_version: 'Nova Versão',
extension_depends_on: 'Depende de:',
extension_rating_soon: 'Avaliações em breve',
+1
View File
@@ -138,6 +138,7 @@ window.localisation.sk = {
only_admins_can_install:
'(Iba administrátorské účty môžu inštalovať rozšírenia)',
admin_only: 'Iba pre administrátorov',
super_user_only: 'Iba pre superužívateľa',
new_version: 'Nová verzia',
extension_depends_on: 'Závisí na:',
extension_rating_soon: 'Hodnotenia budú čoskoro dostupné',
+1
View File
@@ -139,6 +139,7 @@ window.localisation.we = {
all: 'Pob',
only_admins_can_install: 'Dim ond cyfrifon gweinyddwr all osod estyniadau',
admin_only: 'Dim ond Gweinyddwr',
super_user_only: 'Dim ond Uwchddefnyddiwr',
new_version: 'Fersiwn Newydd',
extension_depends_on: 'Dibynnu ar:',
extension_rating_soon: 'Sgôr yn dod yn fuan',
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -21,10 +21,7 @@ window.app.component('lnbits-qrcode-lnurl', {
if (this.tab == 'bech32') {
const bytes = new TextEncoder().encode(this.url)
const bech32 = NostrTools.nip19.encodeBytes('lnurl', bytes)
this.lnurl =
this.prefix == 'lnurlw'
? `${new URL(this.url).origin}/?lightning=${bech32.toUpperCase()}`
: `lightning:${bech32.toUpperCase()}`
this.lnurl = `lightning:${bech32.toUpperCase()}`
} else if (this.tab == 'lud17') {
if (this.url.startsWith('http://')) {
this.lnurl = this.url.replace('http://', this.prefix + '://')
+1 -1
View File
@@ -21,7 +21,7 @@ window.PageHome = {
return (
this.lnurl !== '' &&
this.g.settings.allowRegister &&
this.g.settings.authMethods.includes('user-id-only')
'user-id-only' in this.g.settings.authMethods
)
},
formatDescription() {
+10 -2
View File
@@ -307,7 +307,13 @@
:label="$t('disable')"
></q-btn>
<q-badge
v-if="extension.isAdminOnly && !g.user.admin"
v-if="extension.isSuperUserOnly && !g.user.super_user"
v-text="$t('super_user_only')"
>
</q-badge>
<q-badge
v-else-if="extension.isAdminOnly && !g.user.admin"
v-text="$t('admin_only')"
>
</q-badge>
@@ -316,7 +322,9 @@
v-else-if="
extension.isInstalled &&
extension.isActive &&
!g.user.extensions.includes(extension.id)
!g.user.extensions.includes(extension.id) &&
(!extension.isAdminOnly || g.user.admin) &&
(!extension.isSuperUserOnly || g.user.super_user)
"
flat
color="primary"
+71
View File
@@ -20,7 +20,9 @@ from lnbits.core.models.extensions import (
Extension,
ExtensionConfig,
ExtensionRelease,
GitHubRelease,
InstallableExtension,
Manifest,
PayToEnableInfo,
ReleasePaymentInfo,
UserExtensionInfo,
@@ -152,6 +154,8 @@ async def test_extension_api_install_details_and_release_endpoints(mocker):
short_description="Config",
min_lnbits_version="0.1.0",
max_lnbits_version=None,
admin_only=True,
super_user_only=True,
)
mocker.patch.object(
ExtensionConfig,
@@ -160,6 +164,8 @@ async def test_extension_api_install_details_and_release_endpoints(mocker):
)
release_info = await get_extension_release("org", ext_id, "v1.0.0")
assert release_info["is_version_compatible"] is True
assert release_info["admin_only"] is True
assert release_info["super_user_only"] is True
@pytest.mark.anyio
@@ -258,6 +264,8 @@ async def test_extension_api_pay_to_enable_and_catalog_views(mocker, admin_user)
catalog_entry = make_installable_extension(
ext_id,
pay_to_enable=PayToEnableInfo(required=True, amount=21, wallet=admin_wallet.id),
admin_only=True,
super_user_only=True,
)
mocker.patch.object(
InstallableExtension,
@@ -267,6 +275,8 @@ async def test_extension_api_pay_to_enable_and_catalog_views(mocker, admin_user)
catalog = await extensions(AccountId(id=regular_user.id))
catalog_item = next(item for item in catalog if item["id"] == ext_id)
assert catalog_item["payToEnable"]["wallet"] is None
assert catalog_item["isAdminOnly"] is True
assert catalog_item["isSuperUserOnly"] is True
@pytest.mark.anyio
@@ -428,3 +438,64 @@ async def test_extension_api_review_endpoints(mocker):
CreateExtensionReview(tag=ext_id, name="Alice", rating=900, comment="Great")
)
assert payment_request.payment_hash.startswith("hash_")
@pytest.mark.anyio
async def test_extension_api_enable_rejects_admin_and_super_only_extensions(
admin_user,
):
regular_user = await create_user_account(
Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
email=f"user_{uuid4().hex[:8]}@lnbits.com",
)
)
admin_only_ext = f"admin_{uuid4().hex[:8]}"
super_only_ext = f"super_{uuid4().hex[:8]}"
await create_installed_extension(
make_installable_extension(admin_only_ext, admin_only=True)
)
await create_installed_extension(
make_installable_extension(super_only_ext, super_user_only=True)
)
with pytest.raises(HTTPException, match="User not authorized"):
await api_enable_extension(admin_only_ext, AccountId(id=regular_user.id))
with pytest.raises(HTTPException, match="User not authorized"):
await api_enable_extension(super_only_ext, AccountId(id=admin_user.id))
@pytest.mark.anyio
async def test_repo_manifest_flags_apply_to_repo_releases(mocker):
ext_id = f"repo_{uuid4().hex[:8]}"
release = make_extension_release(ext_id)
manifest = Manifest(
repos=[
GitHubRelease(
id=ext_id,
organisation="lnbits",
repository="tunnel_me_out",
admin_only=True,
super_user_only=True,
)
]
)
mocker.patch.object(
InstallableExtension,
"fetch_manifest",
mocker.AsyncMock(return_value=manifest),
)
mocker.patch.object(
ExtensionRelease,
"get_github_releases",
mocker.AsyncMock(return_value=[release]),
)
releases = await InstallableExtension.get_extension_releases(ext_id)
assert len(releases) == 1
assert releases[0].admin_only is True
assert releases[0].super_user_only is True
+6
View File
@@ -141,9 +141,13 @@ def make_installable_extension(
pay_to_enable: PayToEnableInfo | None = None,
dependencies: list[str] | None = None,
payments: list[ReleasePaymentInfo] | None = None,
admin_only: bool = False,
super_user_only: bool = False,
) -> InstallableExtension:
release = make_extension_release(ext_id, version)
release.is_version_compatible = compatible
release.admin_only = admin_only
release.super_user_only = super_user_only
return InstallableExtension(
id=ext_id,
name=f"Extension {ext_id}",
@@ -156,6 +160,8 @@ def make_installable_extension(
pay_to_enable=pay_to_enable,
dependencies=dependencies or [],
payments=payments or [],
admin_only=admin_only,
super_user_only=super_user_only,
),
)
+12 -66
View File
@@ -1,32 +1,7 @@
import pytest
from lnbits.db import Filters
from tests.helpers import DbTestModel
TEST_DB_FETCH_PAGE_ROWS: tuple[dict[str, str], ...] = (
{"id": "1", "name": "Alice", "value": "foo"},
{"id": "2", "name": "Bob", "value": "bar"},
{"id": "3", "name": "Carol", "value": "bar"},
{"id": "4", "name": "Dave", "value": "bar"},
{"id": "5", "name": "Dave", "value": "foo"},
{"id": "6", "name": "Eve", "value": "foo"},
{"id": "7", "name": "Frank", "value": "bar"},
{"id": "8", "name": "Grace", "value": "foo"},
{"id": "9", "name": "Heidi", "value": "bar"},
{"id": "10", "name": "Ivan", "value": "foo"},
{"id": "11", "name": "Judy", "value": "bar"},
{"id": "12", "name": "Mallory", "value": "foo"},
{"id": "13", "name": "Niaj", "value": "bar"},
{"id": "14", "name": "Olivia", "value": "foo"},
{"id": "15", "name": "Peggy", "value": "bar"},
{"id": "16", "name": "Rupert", "value": "foo"},
{"id": "17", "name": "Sybil", "value": "bar"},
{"id": "18", "name": "Trent", "value": "foo"},
{"id": "19", "name": "Victor", "value": "bar"},
{"id": "20", "name": "Walter", "value": "foo"},
{"id": "21", "name": "Zoe", "value": "bar"},
)
@pytest.fixture(scope="session")
async def fetch_page(db):
@@ -38,14 +13,14 @@ async def fetch_page(db):
name TEXT NOT NULL
)
""")
for row in TEST_DB_FETCH_PAGE_ROWS:
await db.execute(
"""
INSERT INTO test_db_fetch_page (id, name, value)
VALUES (:id, :name, :value)
""",
row,
)
await db.execute("""
INSERT INTO test_db_fetch_page (id, name, value) VALUES
('1', 'Alice', 'foo'),
('2', 'Bob', 'bar'),
('3', 'Carol', 'bar'),
('4', 'Dave', 'bar'),
('5', 'Dave', 'foo')
""")
yield
await db.execute("DROP TABLE test_db_fetch_page")
@@ -58,35 +33,8 @@ async def test_db_fetch_page_simple(fetch_page, db):
)
assert row
assert row.total == len(TEST_DB_FETCH_PAGE_ROWS)
assert len(row.data) == Filters().limit
@pytest.mark.anyio
async def test_db_fetch_page_limit_zero_returns_all(fetch_page, db):
row = await db.fetch_page(
query="select * from test_db_fetch_page",
filters=Filters(limit=0),
model=DbTestModel,
)
assert row
assert row.total == len(TEST_DB_FETCH_PAGE_ROWS)
assert len(row.data) == len(TEST_DB_FETCH_PAGE_ROWS)
@pytest.mark.anyio
async def test_db_fetch_page_limit(fetch_page, db):
limit = 5
row = await db.fetch_page(
query="select * from test_db_fetch_page",
filters=Filters(limit=limit),
model=DbTestModel,
)
assert row
assert row.total == len(TEST_DB_FETCH_PAGE_ROWS)
assert len(row.data) == limit
assert row.total == 5
assert len(row.data) == 5
@pytest.mark.anyio
@@ -97,7 +45,7 @@ async def test_db_fetch_page_group_by(fetch_page, db):
group_by=["name"],
)
assert row
assert row.total == len({test_row["name"] for test_row in TEST_DB_FETCH_PAGE_ROWS})
assert row.total == 4
@pytest.mark.anyio
@@ -108,9 +56,7 @@ async def test_db_fetch_page_group_by_multiple(fetch_page, db):
group_by=["value", "name"],
)
assert row
assert row.total == len(
{(test_row["value"], test_row["name"]) for test_row in TEST_DB_FETCH_PAGE_ROWS}
)
assert row.total == 5
@pytest.mark.anyio
+37
View File
@@ -8,6 +8,7 @@ from fastapi.exceptions import HTTPException
from httpx import AsyncClient
from pydantic.types import UUID4
from lnbits.core.crud.extensions import create_installed_extension
from lnbits.core.crud.users import delete_account
from lnbits.core.models import User
from lnbits.core.models.users import AccessTokenPayload
@@ -18,10 +19,12 @@ from lnbits.decorators import (
check_extension_builder,
check_first_install,
check_user_exists,
check_user_extension_access,
optional_user_id,
)
from lnbits.helpers import create_access_token
from lnbits.settings import AuthMethods, Settings, settings
from tests.helpers import make_installable_extension
@pytest.mark.anyio
@@ -225,3 +228,37 @@ async def test_check_extension_builder_requires_admin_when_disabled_for_users(
admin_user = user_alan.copy(deep=True)
admin_user.admin = True
await check_extension_builder(admin_user)
@pytest.mark.anyio
async def test_check_user_extension_access_honors_extension_metadata(
settings: Settings, user_alan: User, admin_user: User
):
admin_only_ext = f"admin_{uuid4().hex[:8]}"
super_only_ext = f"super_{uuid4().hex[:8]}"
await create_installed_extension(
make_installable_extension(admin_only_ext, admin_only=True)
)
await create_installed_extension(
make_installable_extension(super_only_ext, super_user_only=True)
)
regular_status = await check_user_extension_access(user_alan.id, admin_only_ext)
assert regular_status.success is False
admin_status = await check_user_extension_access(admin_user.id, admin_only_ext)
assert admin_status.success is True
admin_super_status = await check_user_extension_access(
admin_user.id, super_only_ext
)
assert admin_super_status.success is False
previous_super_user = settings.super_user
settings.super_user = admin_user.id
try:
super_status = await check_user_extension_access(admin_user.id, super_only_ext)
assert super_status.success is True
finally:
settings.super_user = previous_super_user