diff --git a/.gitignore b/.gitignore index acd0773..f92a874 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ data .vscode dump .venv +.claude diff --git a/crud.py b/crud.py index 872d0d6..a9e82b1 100644 --- a/crud.py +++ b/crud.py @@ -18,6 +18,7 @@ from .paranoia import ( assert_sane_string, assert_valid_expiration_seconds, + assert_valid_lud16, assert_valid_msats, assert_valid_positive_int, assert_valid_pubkey, @@ -35,6 +36,8 @@ async def create_nwc(data: CreateNWCKey) -> NWCKey: assert_valid_wallet_id(data.wallet) assert_sane_string(data.description) assert_valid_expiration_seconds(data.expires_at) + if data.lud16: + assert_valid_lud16(data.lud16) for permission in data.permissions: assert_sane_string(permission) @@ -53,6 +56,7 @@ async def create_nwc(data: CreateNWCKey) -> NWCKey: permissions=" ".join(data.permissions), created_at=int(time.time()), last_used=int(time.time()), + lud16=data.lud16, ) await db.insert("nwcprovider.keys", nwckey_entry) if data.budgets: diff --git a/migrations.py b/migrations.py index 1a3a9b7..0afa062 100644 --- a/migrations.py +++ b/migrations.py @@ -119,3 +119,10 @@ async def m006_default_config3(db): """, {"value": "0"}, ) + + +async def m007_add_lud16(db): + """ + Add lud16 column to keys table for lightning address support + """ + await db.execute("ALTER TABLE nwcprovider.keys ADD COLUMN lud16 TEXT") diff --git a/models.py b/models.py index fa52baa..93f7fb2 100644 --- a/models.py +++ b/models.py @@ -15,6 +15,7 @@ class NWCKey(BaseModel): permissions: str created_at: int last_used: int + lud16: str | None = None def get_permissions(self) -> list[str]: try: @@ -67,6 +68,7 @@ class CreateNWCKey(BaseModel): expires_at: int permissions: list[str] budgets: list[NWCNewBudget] | None = None + lud16: str | None = None class DeleteNWC(BaseModel): @@ -102,6 +104,7 @@ class NWCRegistrationRequest(BaseModel): description: str expires_at: int budgets: list[NWCNewBudget] + lud16: str | None = None class NWCGetResponse(BaseModel): @@ -109,6 +112,13 @@ class NWCGetResponse(BaseModel): budgets: list[NWCBudget] +class NWCGetAllResponse(BaseModel): + data: NWCKey + budgets: list[NWCBudget] + wallet_id: str + wallet_name: str + + class NWCConfig(BaseModel): key: str value: str diff --git a/paranoia.py b/paranoia.py index 205debe..b5a5908 100644 --- a/paranoia.py +++ b/paranoia.py @@ -157,6 +157,26 @@ def assert_non_empty_string(v: str): panic("string is empty") +# Check if string is a valid lud16 lightning address (eg. name@domain) +def assert_valid_lud16(v: str): + if not ENABLE_HARDENING: + return + assert_non_empty_string(v) + if len(v) > 255: + panic("lud16 is too long") + name, sep, domain = v.partition("@") + if not (sep and name and domain): + panic("lud16 must be in the form name@domain") + # LUD-16 restricts the name to a-z0-9-_. + if not all(c.isascii() and (c.isalnum() or c in "-_.") for c in name): + panic("lud16 name contains invalid characters") + # domain must look like a clearnet host (eg. example.com) + if "." not in domain or not all( + c.isascii() and (c.isalnum() or c in "-.") for c in domain + ): + panic("lud16 domain is invalid") + + # Assert valid json def assert_valid_json(v: str): if not ENABLE_HARDENING: diff --git a/static/image/Bildschirmfoto 2026-02-03 um 17.25.24.png b/static/image/Bildschirmfoto 2026-02-03 um 17.25.24.png new file mode 100644 index 0000000..fd79541 Binary files /dev/null and b/static/image/Bildschirmfoto 2026-02-03 um 17.25.24.png differ diff --git a/static/image/NWC_Banner_Extension.png b/static/image/NWC_Banner_Extension.png new file mode 100644 index 0000000..824036a Binary files /dev/null and b/static/image/NWC_Banner_Extension.png differ diff --git a/static/js/admin.js b/static/js/admin.js index e9a405c..56ae919 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -1,7 +1,6 @@ window.app = Vue.createApp({ el: '#vue', mixins: [windowMixin], - delimiters: ['${', '}'], data: function () { return { config: {}, diff --git a/static/js/index.js b/static/js/index.js index d593469..12a9897 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,20 +1,32 @@ window.app = Vue.createApp({ el: '#vue', mixins: [windowMixin], - delimiters: ['${', '}'], data: function () { return { - selectedWallet: null, + selectedWallet: 'all', + dialogWallet: null, nodePermissions: [], nwcEntries: [], nwcsTable: { columns: [ + { + name: 'wallet_name', + align: 'left', + label: 'Wallet', + field: 'wallet_name' + }, { name: 'description', align: 'left', label: 'Description', field: 'description' }, + { + name: 'lud16', + align: 'left', + label: 'Lightning Address', + field: 'lud16' + }, {name: 'status', align: 'left', label: 'Status', field: 'status'}, { name: 'last_used', @@ -36,7 +48,7 @@ window.app = Vue.createApp({ } ], pagination: { - rowsPerPage: 10 + rowsPerPage: 0 } }, connectDialog: { @@ -58,22 +70,58 @@ window.app = Vue.createApp({ connectionInfoDialog: { show: false, data: {} - } + }, + lud16OptionsAll: [], + lud16Loading: false } }, + computed: { + walletOptions() { + // Count connections per wallet + const counts = {} + for (const entry of this.nwcEntries) { + if (entry.wallet_id) { + counts[entry.wallet_id] = (counts[entry.wallet_id] || 0) + 1 + } + } + + const options = [{label: 'All Wallets', value: 'all', count: null}] + for (const wallet of this.g.user.wallets) { + options.push({ + label: wallet.name, + value: wallet.id, + count: counts[wallet.id] || 0 + }) + } + return options + }, + visibleColumns() { + if (this.selectedWallet === 'all') { + return this.nwcsTable.columns.map(c => c.name) + } + return this.nwcsTable.columns + .filter(c => c.name !== 'wallet_name') + .map(c => c.name) + } + }, methods: { showConnectDialog() { - const wallet = this.getWallet() - if (!wallet) { - Quasar.Notify.create({ - type: 'negative', - message: 'Please select a wallet first' - }) - return + // When "All Wallets" is selected, default dialog wallet to first wallet + if (this.selectedWallet === 'all') { + if (this.g.user.wallets && this.g.user.wallets.length > 0) { + this.dialogWallet = this.g.user.wallets[0].id + } else { + Quasar.Notify.create({ + type: 'negative', + message: 'No wallets found' + }) + return + } } else { - this.connectDialog.show = true + this.dialogWallet = this.selectedWallet } + this.connectDialog.show = true }, openConnectionInfoDialog(data) { this.connectionInfoDialog.data = data @@ -89,6 +137,10 @@ window.app = Vue.createApp({ go(url) { window.open(url, '_blank') }, + switchToWallet(walletId) { + // Switch to the selected wallet in the dropdown + this.selectedWallet = walletId + }, async copyPairingUrl() { const url = this.pairingDialog.data.pairingUrl if (url) { @@ -119,7 +171,8 @@ window.app = Vue.createApp({ expires_at: Date.now() + 1000 * 60 * 60 * 24 * 7, neverExpires: true, permissions: [], - budgets: [] + budgets: [], + lud16: '' } for (const permission of this.nodePermissions) { this.connectDialog.data.permissions.push({ @@ -128,6 +181,47 @@ window.app = Vue.createApp({ value: permission.value }) } + this.lud16OptionsAll = [] + this.loadLightningAddresses() + }, + getDialogWallet() { + for (let i = 0; i < this.g.user.wallets.length; i++) { + if (this.g.user.wallets[i].id === this.dialogWallet) { + return this.g.user.wallets[i] + } + } + return null + }, + async loadLightningAddresses() { + const wallet = this.getDialogWallet() + if (!wallet) { + this.lud16OptionsAll = [] + return + } + this.lud16Loading = true + try { + const response = await LNbits.api.request( + 'GET', + '/lnurlp/api/v1/links', + wallet.adminkey + ) + if (response.data && response.data.length > 0) { + const host = window.location.host + this.lud16OptionsAll = response.data + .filter(link => link.username) + .map(link => ({ + label: link.description || link.username, + value: `${link.username}@${host}` + })) + } else { + this.lud16OptionsAll = [] + } + } catch (error) { + console.warn('Could not load lightning addresses:', error) + this.lud16OptionsAll = [] + } finally { + this.lud16Loading = false + } }, deleteBudget(index) { this.connectDialog.data.budgets.splice(index, 1) @@ -168,25 +262,42 @@ window.app = Vue.createApp({ } return out }, - deleteNWC: async function (pubkey) { + deleteNWC: async function (row) { Quasar.Dialog.create({ title: 'Confirm Deletion', - message: 'Are you sure you want to delete this connection?', + message: 'This will disconnect the app. Are you sure?', cancel: true, persistent: true }) .onOk(async () => { try { - const wallet = this.getWallet() + // When in "All Wallets" view, use the wallet_id from the row + let adminkey + if (this.selectedWallet === 'all') { + const wallet = this.g.user.wallets.find( + w => w.id === row.wallet_id + ) + if (!wallet) { + Quasar.Notify.create({ + type: 'negative', + message: 'Could not find wallet for this connection' + }) + return + } + adminkey = wallet.adminkey + } else { + const wallet = this.getWallet() + adminkey = wallet.adminkey + } const response = await LNbits.api.request( 'DELETE', - `/nwcprovider/api/v1/nwc/${pubkey}`, - wallet.adminkey + `/nwcprovider/api/v1/nwc/${row.pubkey}`, + adminkey ) this.loadNwcs() Quasar.Notify.create({ type: 'positive', - message: 'Deleted successfully' + message: 'Connection removed' }) } catch (error) { LNbits.utils.notifyApiError(error) @@ -197,17 +308,32 @@ window.app = Vue.createApp({ }) }, loadNwcs: async function () { - const wallet = this.getWallet() - if (!wallet) { - this.nwcs = [] - return + // Get any wallet's adminkey for API calls (needed for "all" view) + let adminkey + if (this.selectedWallet === 'all') { + if (this.g.user.wallets && this.g.user.wallets.length > 0) { + adminkey = this.g.user.wallets[0].adminkey + } else { + this.nwcs = [] + this.nwcEntries = [] + return + } + } else { + const wallet = this.getWallet() + if (!wallet) { + this.nwcs = [] + this.nwcEntries = [] + return + } + adminkey = wallet.adminkey } + try { - const response = await LNbits.api.request( - 'GET', - '/nwcprovider/api/v1/nwc?include_expired=true&calculate_spent_budget=true', - wallet.adminkey - ) + const endpoint = + this.selectedWallet === 'all' + ? '/nwcprovider/api/v1/nwc/all?include_expired=true&calculate_spent_budget=true' + : '/nwcprovider/api/v1/nwc?include_expired=true&calculate_spent_budget=true' + const response = await LNbits.api.request('GET', endpoint, adminkey) this.nwcs = response.data } catch (error) { this.nwcs = [] @@ -216,7 +342,7 @@ window.app = Vue.createApp({ const response = await LNbits.api.request( 'GET', '/nwcprovider/api/v1/permissions', - wallet.adminkey + adminkey ) const permissions = [] for (const [key, value] of Object.entries(response.data)) { @@ -228,7 +354,7 @@ window.app = Vue.createApp({ } this.nodePermissions = permissions } catch (error) { - Lnbits.utils.notifyApiError(error) + LNbits.utils.notifyApiError(error) } this.loadConnectDialogData() const newTableEntries = [] @@ -250,13 +376,16 @@ window.app = Vue.createApp({ ) const nwcTableEntry = { description: nwc.data.description, + lud16: nwc.data.lud16 || '-', created_at: t, expires_at: e, last_used: l, pubkey: nwc.data.pubkey, permissions: nwc.data.permissions, budgets: [], - status: 'Active' + status: 'Active', + wallet_id: nwc.wallet_id || null, + wallet_name: nwc.wallet_name || '' } if ( nwc.data.expires_at > 0 && @@ -290,23 +419,39 @@ window.app = Vue.createApp({ } newTableEntries.push(nwcTableEntry) } + // Sort by wallet name when showing all wallets + if (this.selectedWallet === 'all') { + newTableEntries.sort((a, b) => + a.wallet_name.localeCompare(b.wallet_name) + ) + } this.nwcEntries = newTableEntries }, closePairingDialog() { this.pairingDialog.show = false }, - async showPairingDialog(secret) { - let response = await LNbits.api.request( - 'GET', - '/nwcprovider/api/v1/pairing/{SECRET}' + getLud16Value() { + const lud16 = this.connectDialog.data.lud16 + return lud16 && lud16.trim() ? lud16.trim() : null + }, + async showPairingDialog(secret, lud16) { + // Request the pairing url with a placeholder and substitute the secret + // locally, so the connection secret is never sent to the server. + let url = '/nwcprovider/api/v1/pairing/{SECRET}' + if (lud16) { + url += `?lud16=${encodeURIComponent(lud16)}` + } + const response = await LNbits.api.request('GET', url) + this.pairingDialog.data.pairingUrl = response.data.replace( + '{SECRET}', + secret ) - response = response.data - response = response.replace('{SECRET}', secret) - this.pairingDialog.data.pairingUrl = response this.pairingDialog.show = true }, async confirmConnectDialog() { const keyPair = await this.generateKeyPair() + // Save lud16 before dialog closes (closeConnectDialog resets it) + const lud16 = this.getLud16Value() // timestamp let expires_at = 0 if (!this.connectDialog.data.neverExpires) { @@ -317,7 +462,8 @@ window.app = Vue.createApp({ permissions: [], description: this.connectDialog.data.description, expires_at: expires_at, - budgets: [] + budgets: [], + lud16: lud16 } for (const permission of this.connectDialog.data.permissions) { if (permission.value) data.permissions.push(permission.key) @@ -348,7 +494,15 @@ window.app = Vue.createApp({ created_at: new Date(new Date().setHours(0, 0, 0, 0)).getTime() / 1000 }) } - const wallet = this.getWallet() + // Use dialogWallet for creating connections (handles "All Wallets" case) + const wallet = this.getDialogWallet() + if (!wallet) { + Quasar.Notify.create({ + type: 'negative', + message: 'Select a wallet first' + }) + return + } try { const response = await LNbits.api.request( @@ -366,7 +520,7 @@ window.app = Vue.createApp({ LNbits.utils.notifyApiError('Error creating nwc pairing') return } - this.showPairingDialog(keyPair.privKey) + this.showPairingDialog(keyPair.privKey, lud16) } catch (error) { LNbits.utils.notifyApiError(error) } @@ -375,11 +529,20 @@ window.app = Vue.createApp({ }, created: function () { + // Default to "All Wallets" to show all connections + this.selectedWallet = 'all' + // Load connections on initial page load this.loadNwcs() }, watch: { selectedWallet(newValue, oldValue) { this.loadNwcs() + }, + dialogWallet(newValue, oldValue) { + // Reload lightning addresses when dialog wallet changes + if (newValue && this.connectDialog.show) { + this.loadLightningAddresses() + } } } }) diff --git a/tasks.py b/tasks.py index d7e9015..873580e 100644 --- a/tasks.py +++ b/tasks.py @@ -352,8 +352,12 @@ async def _on_lookup_invoice( res: dict = { "type": "outgoing" if payment.is_out else "incoming", "invoice": payment.bolt11, + # Fallback chain so a human-readable description reaches the + # NWC client. Mirror of _on_list_transactions. "description": ( - invoice_data.description if invoice_data.description else payment.memo + (payment.extra or {}).get("comment") + or invoice_data.description + or payment.memo ), "preimage": preimage if is_settled or payment.is_in else None, "payment_hash": payment.payment_hash, diff --git a/templates/nwcprovider/admin.html b/templates/nwcprovider/admin.html index 81576b4..0ff4709 100644 --- a/templates/nwcprovider/admin.html +++ b/templates/nwcprovider/admin.html @@ -22,7 +22,7 @@
label="Nostr Relay URL" filled wrap - :hint="'URL of the Nostr relay for dispatching and receiving NWC events. Use public relays or a custom one. Specify `nostrclient` to use the Nostr Client extension'" + :hint="'URL of the Nostr relay for dispatching and receiving NWC events. Use public relays or a custom one. Enter nostrclient to use the Nostr Client extension'" > @@ -41,7 +41,7 @@
@@ -51,7 +51,7 @@
+ > + + Connected Apps
round /> + +
+ +
+ Connect apps to enable automated payments within your budget limits. +
+ +
+ + - +