Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87d76bb4a8 | ||
|
|
57434681f9 |
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "plebeian-signer",
|
"name": "plebeian-signer",
|
||||||
"version": "v1.0.11",
|
"version": "v1.1.1",
|
||||||
"custom": {
|
"custom": {
|
||||||
"chrome": {
|
"chrome": {
|
||||||
"version": "v1.0.11"
|
"version": "v1.1.1"
|
||||||
},
|
},
|
||||||
"firefox": {
|
"firefox": {
|
||||||
"version": "v1.0.11"
|
"version": "v1.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Plebeian Signer - Nostr Identity Manager & Signer",
|
"name": "Plebeian Signer - Nostr Identity Manager & Signer",
|
||||||
"description": "Manage and switch between multiple identities while interacting with Nostr apps",
|
"description": "Manage and switch between multiple identities while interacting with Nostr apps",
|
||||||
"version": "1.0.11",
|
"version": "1.1.1",
|
||||||
"homepage_url": "https://github.com/PlebeianApp/plebeian-signer",
|
"homepage_url": "https://github.com/PlebeianApp/plebeian-signer",
|
||||||
"options_page": "options.html",
|
"options_page": "options.html",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
|||||||
@@ -211,6 +211,53 @@
|
|||||||
<div id="card2Nip44Decrypt" class="card sam-mt sam-ml sam-mr">
|
<div id="card2Nip44Decrypt" class="card sam-mt sam-ml sam-mr">
|
||||||
<div id="card2Nip44Decrypt_text" class="text"></div>
|
<div id="card2Nip44Decrypt_text" class="text"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Card for webln.enable -->
|
||||||
|
<div id="cardWeblnEnable" class="card sam-mt sam-ml sam-mr">
|
||||||
|
<p class="description">
|
||||||
|
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||||
|
<b class="color-primary">connect to your Lightning wallet</b>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card for webln.getInfo -->
|
||||||
|
<div id="cardWeblnGetInfo" class="card sam-mt sam-ml sam-mr">
|
||||||
|
<p class="description">
|
||||||
|
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||||
|
<b class="color-primary">read your wallet info</b>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card for webln.sendPayment -->
|
||||||
|
<div id="cardWeblnSendPayment" class="card sam-mt sam-ml sam-mr">
|
||||||
|
<p class="description">
|
||||||
|
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||||
|
<b class="color-primary">send a Lightning payment</b> of
|
||||||
|
<b id="paymentAmountSpan" class="color-primary"></b>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card2 for webln.sendPayment (shows invoice) -->
|
||||||
|
<div id="card2WeblnSendPayment" class="card sam-mt sam-ml sam-mr">
|
||||||
|
<div id="card2WeblnSendPayment_json" class="json"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card for webln.makeInvoice -->
|
||||||
|
<div id="cardWeblnMakeInvoice" class="card sam-mt sam-ml sam-mr">
|
||||||
|
<p class="description">
|
||||||
|
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||||
|
<b class="color-primary">create a Lightning invoice</b>
|
||||||
|
<span id="invoiceAmountSpan"></span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card for webln.keysend -->
|
||||||
|
<div id="cardWeblnKeysend" class="card sam-mt sam-ml sam-mr">
|
||||||
|
<p class="description">
|
||||||
|
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||||
|
<b class="color-primary">send a keysend payment</b>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!------------->
|
<!------------->
|
||||||
@@ -231,6 +278,13 @@
|
|||||||
<button id="approveAlwaysButton" type="button" class="btn-accept">Always</button>
|
<button id="approveAlwaysButton" type="button" class="btn-accept">Always</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="action-row" id="allQueuedRow">
|
||||||
|
<span class="action-label">All Queued</span>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button id="rejectAllButton" type="button" class="btn-reject">Reject All</button>
|
||||||
|
<button id="approveAllButton" type="button" class="btn-accept">Approve All</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="prompt.js"></script>
|
<script src="prompt.js"></script>
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
CashuMint_DECRYPTED,
|
CashuMint_DECRYPTED,
|
||||||
CashuMint_ENCRYPTED,
|
CashuMint_ENCRYPTED,
|
||||||
deriveKeyArgon2,
|
deriveKeyArgon2,
|
||||||
|
ExtensionMethod,
|
||||||
|
WeblnMethod,
|
||||||
} from '@common';
|
} from '@common';
|
||||||
import { ChromeMetaHandler } from './app/common/data/chrome-meta-handler';
|
import { ChromeMetaHandler } from './app/common/data/chrome-meta-handler';
|
||||||
import { Event, EventTemplate, finalizeEvent, nip04, nip44 } from 'nostr-tools';
|
import { Event, EventTemplate, finalizeEvent, nip04, nip44 } from 'nostr-tools';
|
||||||
@@ -46,8 +48,10 @@ export const debug = function (message: any) {
|
|||||||
export type PromptResponse =
|
export type PromptResponse =
|
||||||
| 'reject'
|
| 'reject'
|
||||||
| 'reject-once'
|
| 'reject-once'
|
||||||
|
| 'reject-all' // P2: Reject all requests of this type from this host
|
||||||
| 'approve'
|
| 'approve'
|
||||||
| 'approve-once';
|
| 'approve-once'
|
||||||
|
| 'approve-all'; // P2: Approve all requests of this type from this host
|
||||||
|
|
||||||
export interface PromptResponseMessage {
|
export interface PromptResponseMessage {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -55,7 +59,7 @@ export interface PromptResponseMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BackgroundRequestMessage {
|
export interface BackgroundRequestMessage {
|
||||||
method: Nip07Method;
|
method: ExtensionMethod;
|
||||||
params: any;
|
params: any;
|
||||||
host: string;
|
host: string;
|
||||||
}
|
}
|
||||||
@@ -218,11 +222,51 @@ export const checkPermissions = function (
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a method is a WebLN method
|
||||||
|
*/
|
||||||
|
export const isWeblnMethod = function (method: ExtensionMethod): method is WeblnMethod {
|
||||||
|
return method.startsWith('webln.');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check WebLN permissions for a host.
|
||||||
|
* Note: WebLN permissions are NOT tied to identities since the wallet is global.
|
||||||
|
* For sendPayment, always returns undefined (require user prompt for security).
|
||||||
|
*/
|
||||||
|
export const checkWeblnPermissions = function (
|
||||||
|
browserSessionData: BrowserSessionData,
|
||||||
|
host: string,
|
||||||
|
method: WeblnMethod
|
||||||
|
): boolean | undefined {
|
||||||
|
// sendPayment ALWAYS requires user approval (security-critical, irreversible)
|
||||||
|
if (method === 'webln.sendPayment') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// keysend also requires approval
|
||||||
|
if (method === 'webln.keysend') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other WebLN methods, check stored permissions
|
||||||
|
// WebLN permissions use 'webln' as the identityId
|
||||||
|
const permissions = browserSessionData.permissions.filter(
|
||||||
|
(x) => x.identityId === 'webln' && x.host === host && x.method === method
|
||||||
|
);
|
||||||
|
|
||||||
|
if (permissions.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return permissions.every((x) => x.methodPolicy === 'allow');
|
||||||
|
};
|
||||||
|
|
||||||
export const storePermission = async function (
|
export const storePermission = async function (
|
||||||
browserSessionData: BrowserSessionData,
|
browserSessionData: BrowserSessionData,
|
||||||
identity: Identity_DECRYPTED,
|
identity: Identity_DECRYPTED | null,
|
||||||
host: string,
|
host: string,
|
||||||
method: Nip07Method,
|
method: ExtensionMethod,
|
||||||
methodPolicy: Nip07MethodPolicy,
|
methodPolicy: Nip07MethodPolicy,
|
||||||
kind?: number
|
kind?: number
|
||||||
) {
|
) {
|
||||||
@@ -231,11 +275,14 @@ export const storePermission = async function (
|
|||||||
throw new Error(`Could not retrieve sync data`);
|
throw new Error(`Could not retrieve sync data`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For WebLN methods, use 'webln' as identityId since wallet is global
|
||||||
|
const identityId = identity?.id ?? 'webln';
|
||||||
|
|
||||||
const permission: Permission_DECRYPTED = {
|
const permission: Permission_DECRYPTED = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
identityId: identity.id,
|
identityId,
|
||||||
host,
|
host,
|
||||||
method,
|
method: method as Nip07Method, // Cast for storage compatibility
|
||||||
methodPolicy,
|
methodPolicy,
|
||||||
kind,
|
kind,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,14 +3,23 @@ import {
|
|||||||
backgroundLogNip07Action,
|
backgroundLogNip07Action,
|
||||||
backgroundLogPermissionStored,
|
backgroundLogPermissionStored,
|
||||||
NostrHelper,
|
NostrHelper,
|
||||||
|
NwcClient,
|
||||||
|
NwcConnection_DECRYPTED,
|
||||||
|
WeblnMethod,
|
||||||
|
Nip07Method,
|
||||||
|
GetInfoResponse,
|
||||||
|
SendPaymentResponse,
|
||||||
|
RequestInvoiceResponse,
|
||||||
} from '@common';
|
} from '@common';
|
||||||
import {
|
import {
|
||||||
BackgroundRequestMessage,
|
BackgroundRequestMessage,
|
||||||
checkPermissions,
|
checkPermissions,
|
||||||
|
checkWeblnPermissions,
|
||||||
debug,
|
debug,
|
||||||
getBrowserSessionData,
|
getBrowserSessionData,
|
||||||
getPosition,
|
getPosition,
|
||||||
handleUnlockRequest,
|
handleUnlockRequest,
|
||||||
|
isWeblnMethod,
|
||||||
nip04Decrypt,
|
nip04Decrypt,
|
||||||
nip04Encrypt,
|
nip04Encrypt,
|
||||||
nip44Decrypt,
|
nip44Decrypt,
|
||||||
@@ -27,13 +36,93 @@ import {
|
|||||||
import browser from 'webextension-polyfill';
|
import browser from 'webextension-polyfill';
|
||||||
import { Buffer } from 'buffer';
|
import { Buffer } from 'buffer';
|
||||||
|
|
||||||
|
// Cache for NWC clients to avoid reconnecting for each request
|
||||||
|
const nwcClientCache = new Map<string, NwcClient>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create an NWC client for a connection
|
||||||
|
*/
|
||||||
|
async function getNwcClient(connection: NwcConnection_DECRYPTED): Promise<NwcClient> {
|
||||||
|
const cached = nwcClientCache.get(connection.id);
|
||||||
|
if (cached && cached.isConnected()) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new NwcClient({
|
||||||
|
walletPubkey: connection.walletPubkey,
|
||||||
|
relayUrl: connection.relayUrl,
|
||||||
|
secret: connection.secret,
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.connect();
|
||||||
|
nwcClientCache.set(connection.id, client);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse invoice amount from a BOLT11 invoice string
|
||||||
|
* Returns amount in satoshis, or undefined if no amount specified
|
||||||
|
*/
|
||||||
|
function parseInvoiceAmount(invoice: string): number | undefined {
|
||||||
|
try {
|
||||||
|
// BOLT11 invoices start with 'ln' followed by network prefix and amount
|
||||||
|
// Format: ln[network][amount][multiplier]1[data]
|
||||||
|
// Examples: lnbc1500n1... (1500 sat), lnbc1m1... (0.001 BTC = 100000 sat)
|
||||||
|
const match = invoice.toLowerCase().match(/^ln(bc|tb|tbs|bcrt)(\d+)([munp])?1/);
|
||||||
|
if (!match) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const amountStr = match[2];
|
||||||
|
const multiplier = match[3];
|
||||||
|
|
||||||
|
let amount = parseInt(amountStr, 10);
|
||||||
|
|
||||||
|
// Apply multiplier (amount is in BTC by default)
|
||||||
|
switch (multiplier) {
|
||||||
|
case 'm': // milli-bitcoin (0.001 BTC)
|
||||||
|
amount = amount * 100000;
|
||||||
|
break;
|
||||||
|
case 'u': // micro-bitcoin (0.000001 BTC)
|
||||||
|
amount = amount * 100;
|
||||||
|
break;
|
||||||
|
case 'n': // nano-bitcoin (0.000000001 BTC) = 0.1 sat
|
||||||
|
amount = Math.floor(amount / 10);
|
||||||
|
break;
|
||||||
|
case 'p': // pico-bitcoin (0.000000000001 BTC) = 0.0001 sat
|
||||||
|
amount = Math.floor(amount / 10000);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// No multiplier means BTC
|
||||||
|
amount = amount * 100000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
return amount;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Relays = Record<string, { read: boolean; write: boolean }>;
|
type Relays = Record<string, { read: boolean; write: boolean }>;
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Permission Prompt Queue System (P0)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Timeout for permission prompts (30 seconds)
|
||||||
|
const PROMPT_TIMEOUT_MS = 30000;
|
||||||
|
|
||||||
|
// Maximum number of queued permission requests (prevent DoS)
|
||||||
|
const MAX_PERMISSION_QUEUE_SIZE = 100;
|
||||||
|
|
||||||
|
// Track open prompts with metadata for cleanup
|
||||||
const openPrompts = new Map<
|
const openPrompts = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
resolve: (response: PromptResponse) => void;
|
resolve: (response: PromptResponse) => void;
|
||||||
reject: (reason?: any) => void;
|
reject: (reason?: any) => void;
|
||||||
|
windowId?: number;
|
||||||
|
timeoutId?: ReturnType<typeof setTimeout>;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
|
|
||||||
@@ -47,6 +136,170 @@ const pendingRequests: {
|
|||||||
reject: (error: any) => void;
|
reject: (error: any) => void;
|
||||||
}[] = [];
|
}[] = [];
|
||||||
|
|
||||||
|
// Queue for permission requests (only one prompt shown at a time)
|
||||||
|
interface PermissionQueueItem {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
resolve: (response: PromptResponse) => void;
|
||||||
|
reject: (reason?: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionQueue: PermissionQueueItem[] = [];
|
||||||
|
let activePromptId: string | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the next permission prompt from the queue
|
||||||
|
*/
|
||||||
|
async function showNextPermissionPrompt(): Promise<void> {
|
||||||
|
if (activePromptId || permissionQueue.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = permissionQueue[0];
|
||||||
|
activePromptId = next.id;
|
||||||
|
|
||||||
|
const { top, left } = await getPosition(next.width, next.height);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const window = await browser.windows.create({
|
||||||
|
type: 'popup',
|
||||||
|
url: next.url,
|
||||||
|
height: next.height,
|
||||||
|
width: next.width,
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
});
|
||||||
|
|
||||||
|
const promptData = openPrompts.get(next.id);
|
||||||
|
if (promptData && window.id) {
|
||||||
|
promptData.windowId = window.id;
|
||||||
|
promptData.timeoutId = setTimeout(() => {
|
||||||
|
debug(`Prompt ${next.id} timed out after ${PROMPT_TIMEOUT_MS}ms`);
|
||||||
|
cleanupPrompt(next.id, 'timeout');
|
||||||
|
}, PROMPT_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debug(`Failed to create prompt window: ${error}`);
|
||||||
|
cleanupPrompt(next.id, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up a prompt and process the next one in queue
|
||||||
|
*/
|
||||||
|
function cleanupPrompt(promptId: string, reason: 'response' | 'timeout' | 'closed' | 'error'): void {
|
||||||
|
const promptData = openPrompts.get(promptId);
|
||||||
|
|
||||||
|
if (promptData) {
|
||||||
|
if (promptData.timeoutId) {
|
||||||
|
clearTimeout(promptData.timeoutId);
|
||||||
|
}
|
||||||
|
if (reason !== 'response') {
|
||||||
|
promptData.reject(new Error(`Permission prompt ${reason}`));
|
||||||
|
}
|
||||||
|
openPrompts.delete(promptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueIndex = permissionQueue.findIndex(item => item.id === promptId);
|
||||||
|
if (queueIndex !== -1) {
|
||||||
|
permissionQueue.splice(queueIndex, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activePromptId === promptId) {
|
||||||
|
activePromptId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
showNextPermissionPrompt();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue a permission prompt request
|
||||||
|
*/
|
||||||
|
function queuePermissionPrompt(
|
||||||
|
urlWithoutId: string,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): Promise<PromptResponse> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (permissionQueue.length >= MAX_PERMISSION_QUEUE_SIZE) {
|
||||||
|
reject(new Error('Too many pending permission requests. Please try again later.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const separator = urlWithoutId.includes('?') ? '&' : '?';
|
||||||
|
const url = `${urlWithoutId}${separator}id=${id}`;
|
||||||
|
|
||||||
|
openPrompts.set(id, { resolve, reject });
|
||||||
|
permissionQueue.push({ id, url, width, height, resolve, reject });
|
||||||
|
|
||||||
|
debug(`Queued permission prompt ${id}. Queue size: ${permissionQueue.length}`);
|
||||||
|
showNextPermissionPrompt();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for window close events to clean up orphaned prompts
|
||||||
|
browser.windows.onRemoved.addListener((windowId: number) => {
|
||||||
|
for (const [promptId, promptData] of openPrompts.entries()) {
|
||||||
|
if (promptData.windowId === windowId) {
|
||||||
|
debug(`Prompt window ${windowId} closed without response`);
|
||||||
|
cleanupPrompt(promptId, 'closed');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Request Deduplication (P1)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const pendingRequestPromises = new Map<string, Promise<PromptResponse>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a hash key for request deduplication
|
||||||
|
*/
|
||||||
|
function getRequestHash(host: string, method: string, params: any): string {
|
||||||
|
if (method === 'signEvent' && params?.kind !== undefined) {
|
||||||
|
return `${host}:${method}:kind${params.kind}`;
|
||||||
|
}
|
||||||
|
if ((method.includes('encrypt') || method.includes('decrypt')) && params?.peerPubkey) {
|
||||||
|
return `${host}:${method}:${params.peerPubkey}`;
|
||||||
|
}
|
||||||
|
return `${host}:${method}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue a permission prompt with deduplication
|
||||||
|
*/
|
||||||
|
function queuePermissionPromptDeduped(
|
||||||
|
host: string,
|
||||||
|
method: string,
|
||||||
|
params: any,
|
||||||
|
urlWithoutId: string,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): Promise<PromptResponse> {
|
||||||
|
const hash = getRequestHash(host, method, params);
|
||||||
|
|
||||||
|
const existingPromise = pendingRequestPromises.get(hash);
|
||||||
|
if (existingPromise) {
|
||||||
|
debug(`Deduplicating request: ${hash}`);
|
||||||
|
return existingPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = queuePermissionPrompt(urlWithoutId, width, height)
|
||||||
|
.finally(() => {
|
||||||
|
pendingRequestPromises.delete(hash);
|
||||||
|
});
|
||||||
|
|
||||||
|
pendingRequestPromises.set(hash, promise);
|
||||||
|
debug(`New permission request: ${hash}`);
|
||||||
|
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
||||||
debug('Message received');
|
debug('Message received');
|
||||||
|
|
||||||
@@ -88,13 +341,12 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
|||||||
const promptResponse = request as PromptResponseMessage;
|
const promptResponse = request as PromptResponseMessage;
|
||||||
const openPrompt = openPrompts.get(promptResponse.id);
|
const openPrompt = openPrompts.get(promptResponse.id);
|
||||||
if (!openPrompt) {
|
if (!openPrompt) {
|
||||||
throw new Error(
|
debug('Prompt response could not be matched (may have timed out)');
|
||||||
'Prompt response could not be matched to any previous request.'
|
return;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
openPrompt.resolve(promptResponse.response);
|
openPrompt.resolve(promptResponse.response);
|
||||||
openPrompts.delete(promptResponse.id);
|
cleanupPrompt(promptResponse.id, 'response');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,8 +368,12 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process the NIP-07 request
|
// Process the request (NIP-07 or WebLN)
|
||||||
return processNip07Request(request as BackgroundRequestMessage);
|
const req = request as BackgroundRequestMessage;
|
||||||
|
if (isWeblnMethod(req.method)) {
|
||||||
|
return processWeblnRequest(req);
|
||||||
|
}
|
||||||
|
return processNip07Request(req);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,7 +405,7 @@ async function processNip07Request(req: BackgroundRequestMessage): Promise<any>
|
|||||||
browserSessionData,
|
browserSessionData,
|
||||||
currentIdentity,
|
currentIdentity,
|
||||||
req.host,
|
req.host,
|
||||||
req.method,
|
req.method as Nip07Method,
|
||||||
req.params
|
req.params
|
||||||
);
|
);
|
||||||
debug(`permissionState result: ${permissionState}`);
|
debug(`permissionState result: ${permissionState}`);
|
||||||
@@ -159,29 +415,23 @@ async function processNip07Request(req: BackgroundRequestMessage): Promise<any>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (permissionState === undefined) {
|
if (permissionState === undefined) {
|
||||||
// Ask user for permission.
|
// Ask user for permission (queued + deduplicated)
|
||||||
const width = 375;
|
const width = 375;
|
||||||
const height = 600;
|
const height = 600;
|
||||||
const { top, left } = await getPosition(width, height);
|
|
||||||
|
|
||||||
const base64Event = Buffer.from(
|
const base64Event = Buffer.from(
|
||||||
JSON.stringify(req.params ?? {}, undefined, 2)
|
JSON.stringify(req.params ?? {}, undefined, 2)
|
||||||
).toString('base64');
|
).toString('base64');
|
||||||
|
|
||||||
const response = await new Promise<PromptResponse>((resolve, reject) => {
|
// Include queue info for user awareness
|
||||||
const id = crypto.randomUUID();
|
const queueSize = permissionQueue.length;
|
||||||
openPrompts.set(id, { resolve, reject });
|
const promptUrl = `prompt.html?method=${req.method}&host=${req.host}&nick=${encodeURIComponent(currentIdentity.nick)}&event=${base64Event}&queue=${queueSize}`;
|
||||||
browser.windows.create({
|
const response = await queuePermissionPromptDeduped(req.host, req.method, req.params, promptUrl, width, height);
|
||||||
type: 'popup',
|
|
||||||
url: `prompt.html?method=${req.method}&host=${req.host}&id=${id}&nick=${currentIdentity.nick}&event=${base64Event}`,
|
|
||||||
height,
|
|
||||||
width,
|
|
||||||
top,
|
|
||||||
left,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
debug(response);
|
debug(response);
|
||||||
|
|
||||||
|
// Handle permission storage based on response type
|
||||||
if (response === 'approve' || response === 'reject') {
|
if (response === 'approve' || response === 'reject') {
|
||||||
|
// Store permission for this specific kind (if signEvent) or method
|
||||||
const policy = response === 'approve' ? 'allow' : 'deny';
|
const policy = response === 'approve' ? 'allow' : 'deny';
|
||||||
await storePermission(
|
await storePermission(
|
||||||
browserSessionData,
|
browserSessionData,
|
||||||
@@ -191,15 +441,34 @@ async function processNip07Request(req: BackgroundRequestMessage): Promise<any>
|
|||||||
policy,
|
policy,
|
||||||
req.params?.kind
|
req.params?.kind
|
||||||
);
|
);
|
||||||
await backgroundLogPermissionStored(
|
await backgroundLogPermissionStored(req.host, req.method, policy, req.params?.kind);
|
||||||
|
} else if (response === 'approve-all') {
|
||||||
|
// P2: Store permission for ALL kinds/uses of this method from this host
|
||||||
|
await storePermission(
|
||||||
|
browserSessionData,
|
||||||
|
currentIdentity,
|
||||||
req.host,
|
req.host,
|
||||||
req.method,
|
req.method,
|
||||||
policy,
|
'allow',
|
||||||
req.params?.kind
|
undefined // undefined kind = allow all kinds for signEvent
|
||||||
);
|
);
|
||||||
|
await backgroundLogPermissionStored(req.host, req.method, 'allow', undefined);
|
||||||
|
debug(`Stored approve-all permission for ${req.method} from ${req.host}`);
|
||||||
|
} else if (response === 'reject-all') {
|
||||||
|
// P2: Store deny permission for ALL uses of this method from this host
|
||||||
|
await storePermission(
|
||||||
|
browserSessionData,
|
||||||
|
currentIdentity,
|
||||||
|
req.host,
|
||||||
|
req.method,
|
||||||
|
'deny',
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
await backgroundLogPermissionStored(req.host, req.method, 'deny', undefined);
|
||||||
|
debug(`Stored reject-all permission for ${req.method} from ${req.host}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (['reject', 'reject-once'].includes(response)) {
|
if (['reject', 'reject-once', 'reject-all'].includes(response)) {
|
||||||
await backgroundLogNip07Action(req.method, req.host, false, false, {
|
await backgroundLogNip07Action(req.method, req.host, false, false, {
|
||||||
kind: req.params?.kind,
|
kind: req.params?.kind,
|
||||||
peerPubkey: req.params?.peerPubkey,
|
peerPubkey: req.params?.peerPubkey,
|
||||||
@@ -282,3 +551,148 @@ async function processNip07Request(req: BackgroundRequestMessage): Promise<any>
|
|||||||
throw new Error(`Not supported request method '${req.method}'.`);
|
throw new Error(`Not supported request method '${req.method}'.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a WebLN request after vault is unlocked
|
||||||
|
*/
|
||||||
|
async function processWeblnRequest(req: BackgroundRequestMessage): Promise<any> {
|
||||||
|
const browserSessionData = await getBrowserSessionData();
|
||||||
|
|
||||||
|
if (!browserSessionData) {
|
||||||
|
throw new Error('Plebeian Signer vault not unlocked by the user.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nwcConnections = browserSessionData.nwcConnections ?? [];
|
||||||
|
const method = req.method as WeblnMethod;
|
||||||
|
|
||||||
|
// webln.enable just checks if NWC is configured
|
||||||
|
if (method === 'webln.enable') {
|
||||||
|
if (nwcConnections.length === 0) {
|
||||||
|
throw new Error('No wallet configured. Please add an NWC connection in Plebeian Signer settings.');
|
||||||
|
}
|
||||||
|
debug('WebLN enabled');
|
||||||
|
return { enabled: true }; // Return explicit value (undefined gets filtered by content script)
|
||||||
|
}
|
||||||
|
|
||||||
|
// All other methods require an NWC connection
|
||||||
|
const defaultConnection = nwcConnections[0];
|
||||||
|
if (!defaultConnection) {
|
||||||
|
throw new Error('No wallet configured. Please add an NWC connection in Plebeian Signer settings.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check reckless mode (but still prompt for payments)
|
||||||
|
const recklessApprove = await shouldRecklessModeApprove(req.host);
|
||||||
|
|
||||||
|
// Check WebLN permissions
|
||||||
|
const permissionState = recklessApprove && method !== 'webln.sendPayment' && method !== 'webln.keysend'
|
||||||
|
? true
|
||||||
|
: checkWeblnPermissions(browserSessionData, req.host, method);
|
||||||
|
|
||||||
|
if (permissionState === false) {
|
||||||
|
throw new Error('Permission denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissionState === undefined) {
|
||||||
|
// Ask user for permission (queued + deduplicated)
|
||||||
|
const width = 375;
|
||||||
|
const height = 600;
|
||||||
|
|
||||||
|
// For sendPayment, include the invoice amount in the prompt data
|
||||||
|
let promptParams = req.params ?? {};
|
||||||
|
if (method === 'webln.sendPayment' && req.params?.paymentRequest) {
|
||||||
|
const amountSats = parseInvoiceAmount(req.params.paymentRequest);
|
||||||
|
promptParams = { ...promptParams, amountSats };
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64Event = Buffer.from(
|
||||||
|
JSON.stringify(promptParams, undefined, 2)
|
||||||
|
).toString('base64');
|
||||||
|
|
||||||
|
// Include queue info for user awareness
|
||||||
|
const queueSize = permissionQueue.length;
|
||||||
|
const promptUrl = `prompt.html?method=${method}&host=${req.host}&nick=WebLN&event=${base64Event}&queue=${queueSize}`;
|
||||||
|
const response = await queuePermissionPromptDeduped(req.host, method, req.params, promptUrl, width, height);
|
||||||
|
|
||||||
|
debug(response);
|
||||||
|
|
||||||
|
// Store permission for non-payment methods
|
||||||
|
if ((response === 'approve' || response === 'reject') && method !== 'webln.sendPayment' && method !== 'webln.keysend') {
|
||||||
|
const policy = response === 'approve' ? 'allow' : 'deny';
|
||||||
|
await storePermission(
|
||||||
|
browserSessionData,
|
||||||
|
null, // WebLN has no identity
|
||||||
|
req.host,
|
||||||
|
method,
|
||||||
|
policy
|
||||||
|
);
|
||||||
|
await backgroundLogPermissionStored(req.host, method, policy);
|
||||||
|
} else if (response === 'approve-all' && method !== 'webln.sendPayment' && method !== 'webln.keysend') {
|
||||||
|
// P2: Store permission for all uses of this WebLN method
|
||||||
|
await storePermission(
|
||||||
|
browserSessionData,
|
||||||
|
null,
|
||||||
|
req.host,
|
||||||
|
method,
|
||||||
|
'allow'
|
||||||
|
);
|
||||||
|
await backgroundLogPermissionStored(req.host, method, 'allow');
|
||||||
|
debug(`Stored approve-all permission for ${method} from ${req.host}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['reject', 'reject-once', 'reject-all'].includes(response)) {
|
||||||
|
throw new Error('Permission denied');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the WebLN method
|
||||||
|
let result: any;
|
||||||
|
const client = await getNwcClient(defaultConnection);
|
||||||
|
|
||||||
|
switch (method) {
|
||||||
|
case 'webln.getInfo': {
|
||||||
|
const info = await client.getInfo();
|
||||||
|
result = {
|
||||||
|
node: {
|
||||||
|
alias: info.alias,
|
||||||
|
pubkey: info.pubkey,
|
||||||
|
color: info.color,
|
||||||
|
},
|
||||||
|
} as GetInfoResponse;
|
||||||
|
debug('webln.getInfo result:');
|
||||||
|
debug(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'webln.sendPayment': {
|
||||||
|
const invoice = req.params.paymentRequest;
|
||||||
|
const payResult = await client.payInvoice({ invoice });
|
||||||
|
result = { preimage: payResult.preimage } as SendPaymentResponse;
|
||||||
|
debug('webln.sendPayment result:');
|
||||||
|
debug(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'webln.makeInvoice': {
|
||||||
|
// Convert sats to millisats (NWC uses millisats)
|
||||||
|
const amountSats = typeof req.params.amount === 'string'
|
||||||
|
? parseInt(req.params.amount, 10)
|
||||||
|
: req.params.amount ?? req.params.defaultAmount ?? 0;
|
||||||
|
const amountMsat = amountSats * 1000;
|
||||||
|
|
||||||
|
const invoiceResult = await client.makeInvoice({
|
||||||
|
amount: amountMsat,
|
||||||
|
description: req.params.defaultMemo,
|
||||||
|
});
|
||||||
|
result = { paymentRequest: invoiceResult.invoice } as RequestInvoiceResponse;
|
||||||
|
debug('webln.makeInvoice result:');
|
||||||
|
debug(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'webln.keysend':
|
||||||
|
throw new Error('keysend is not yet supported');
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Not supported WebLN method '${method}'.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { Event, EventTemplate } from 'nostr-tools';
|
import { Event as NostrEvent, EventTemplate } from 'nostr-tools';
|
||||||
import { Nip07Method } from '@common';
|
import { ExtensionMethod } from '@common';
|
||||||
|
|
||||||
// Extend Window interface for NIP-07
|
// Extend Window interface for NIP-07 and WebLN
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
nostr?: any;
|
nostr?: any;
|
||||||
|
webln?: any;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ class Messenger {
|
|||||||
window.addEventListener('message', this.#handleCallResponse.bind(this));
|
window.addEventListener('message', this.#handleCallResponse.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
async request(method: Nip07Method, params: any): Promise<any> {
|
async request(method: ExtensionMethod, params: any): Promise<any> {
|
||||||
const id = generateUUID();
|
const id = generateUUID();
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -89,7 +90,7 @@ const nostr = {
|
|||||||
return pubkey;
|
return pubkey;
|
||||||
},
|
},
|
||||||
|
|
||||||
async signEvent(event: EventTemplate): Promise<Event> {
|
async signEvent(event: EventTemplate): Promise<NostrEvent> {
|
||||||
debug('signEvent received');
|
debug('signEvent received');
|
||||||
const signedEvent = await this.messenger.request('signEvent', event);
|
const signedEvent = await this.messenger.request('signEvent', event);
|
||||||
debug('signEvent response:');
|
debug('signEvent response:');
|
||||||
@@ -158,6 +159,92 @@ const nostr = {
|
|||||||
|
|
||||||
window.nostr = nostr as any;
|
window.nostr = nostr as any;
|
||||||
|
|
||||||
|
// WebLN types (inline to avoid build issues with @common types in injected script)
|
||||||
|
interface RequestInvoiceArgs {
|
||||||
|
amount?: string | number;
|
||||||
|
defaultAmount?: string | number;
|
||||||
|
minimumAmount?: string | number;
|
||||||
|
maximumAmount?: string | number;
|
||||||
|
defaultMemo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KeysendArgs {
|
||||||
|
destination: string;
|
||||||
|
amount: string | number;
|
||||||
|
customRecords?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a shared messenger instance for WebLN
|
||||||
|
const weblnMessenger = nostr.messenger;
|
||||||
|
|
||||||
|
const webln = {
|
||||||
|
enabled: false,
|
||||||
|
|
||||||
|
async enable(): Promise<void> {
|
||||||
|
debug('webln.enable received');
|
||||||
|
await weblnMessenger.request('webln.enable', {});
|
||||||
|
this.enabled = true;
|
||||||
|
debug('webln.enable completed');
|
||||||
|
// Dispatch webln:enabled event as per WebLN spec
|
||||||
|
window.dispatchEvent(new Event('webln:enabled'));
|
||||||
|
},
|
||||||
|
|
||||||
|
async getInfo(): Promise<{ node: { alias?: string; pubkey?: string; color?: string } }> {
|
||||||
|
debug('webln.getInfo received');
|
||||||
|
const info = await weblnMessenger.request('webln.getInfo', {});
|
||||||
|
debug('webln.getInfo response:');
|
||||||
|
debug(info);
|
||||||
|
return info;
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendPayment(paymentRequest: string): Promise<{ preimage: string }> {
|
||||||
|
debug('webln.sendPayment received');
|
||||||
|
const result = await weblnMessenger.request('webln.sendPayment', { paymentRequest });
|
||||||
|
debug('webln.sendPayment response:');
|
||||||
|
debug(result);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
async keysend(args: KeysendArgs): Promise<{ preimage: string }> {
|
||||||
|
debug('webln.keysend received');
|
||||||
|
const result = await weblnMessenger.request('webln.keysend', args);
|
||||||
|
debug('webln.keysend response:');
|
||||||
|
debug(result);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
async makeInvoice(
|
||||||
|
args: string | number | RequestInvoiceArgs
|
||||||
|
): Promise<{ paymentRequest: string }> {
|
||||||
|
debug('webln.makeInvoice received');
|
||||||
|
// Normalize args to RequestInvoiceArgs
|
||||||
|
let normalizedArgs: RequestInvoiceArgs;
|
||||||
|
if (typeof args === 'string' || typeof args === 'number') {
|
||||||
|
normalizedArgs = { amount: args };
|
||||||
|
} else {
|
||||||
|
normalizedArgs = args;
|
||||||
|
}
|
||||||
|
const result = await weblnMessenger.request('webln.makeInvoice', normalizedArgs);
|
||||||
|
debug('webln.makeInvoice response:');
|
||||||
|
debug(result);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
signMessage(): Promise<{ message: string; signature: string }> {
|
||||||
|
throw new Error('signMessage is not supported - NWC does not provide node signing capabilities');
|
||||||
|
},
|
||||||
|
|
||||||
|
verifyMessage(): Promise<void> {
|
||||||
|
throw new Error('verifyMessage is not supported - NWC does not provide message verification');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
window.webln = webln as any;
|
||||||
|
|
||||||
|
// Dispatch webln:ready event to signal that webln is available
|
||||||
|
// This is dispatched on document as per the WebLN standard
|
||||||
|
document.dispatchEvent(new Event('webln:ready'));
|
||||||
|
|
||||||
const debug = function (value: any) {
|
const debug = function (value: any) {
|
||||||
console.log(JSON.stringify(value));
|
console.log(JSON.stringify(value));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import browser from 'webextension-polyfill';
|
import browser from 'webextension-polyfill';
|
||||||
import { Nip07Method } from '@common';
|
import { ExtensionMethod } from '@common';
|
||||||
import { PromptResponse, PromptResponseMessage } from './background-common';
|
import { PromptResponse, PromptResponseMessage } from './background-common';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,7 +14,7 @@ function base64ToUtf8(base64: string): string {
|
|||||||
|
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const id = params.get('id') as string;
|
const id = params.get('id') as string;
|
||||||
const method = params.get('method') as Nip07Method;
|
const method = params.get('method') as ExtensionMethod;
|
||||||
const host = params.get('host') as string;
|
const host = params.get('host') as string;
|
||||||
const nick = params.get('nick') as string;
|
const nick = params.get('nick') as string;
|
||||||
|
|
||||||
@@ -58,6 +58,26 @@ switch (method) {
|
|||||||
title = 'Get Relays';
|
title = 'Get Relays';
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'webln.enable':
|
||||||
|
title = 'Enable WebLN';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'webln.getInfo':
|
||||||
|
title = 'Wallet Info';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'webln.sendPayment':
|
||||||
|
title = 'Send Payment';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'webln.makeInvoice':
|
||||||
|
title = 'Create Invoice';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'webln.keysend':
|
||||||
|
title = 'Keysend Payment';
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -185,6 +205,65 @@ if (cardNip44DecryptElement && card2Nip44DecryptElement) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WebLN card visibility
|
||||||
|
const cardWeblnEnableElement = document.getElementById('cardWeblnEnable');
|
||||||
|
if (cardWeblnEnableElement) {
|
||||||
|
if (method !== 'webln.enable') {
|
||||||
|
cardWeblnEnableElement.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardWeblnGetInfoElement = document.getElementById('cardWeblnGetInfo');
|
||||||
|
if (cardWeblnGetInfoElement) {
|
||||||
|
if (method !== 'webln.getInfo') {
|
||||||
|
cardWeblnGetInfoElement.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardWeblnSendPaymentElement = document.getElementById('cardWeblnSendPayment');
|
||||||
|
const card2WeblnSendPaymentElement = document.getElementById('card2WeblnSendPayment');
|
||||||
|
if (cardWeblnSendPaymentElement && card2WeblnSendPaymentElement) {
|
||||||
|
if (method === 'webln.sendPayment') {
|
||||||
|
// Display amount in sats
|
||||||
|
const paymentAmountSpan = document.getElementById('paymentAmountSpan');
|
||||||
|
if (paymentAmountSpan && eventParsed.amountSats !== undefined) {
|
||||||
|
paymentAmountSpan.innerText = `${eventParsed.amountSats.toLocaleString()} sats`;
|
||||||
|
} else if (paymentAmountSpan) {
|
||||||
|
paymentAmountSpan.innerText = 'unknown amount';
|
||||||
|
}
|
||||||
|
// Show invoice in json card
|
||||||
|
const card2WeblnSendPayment_jsonElement = document.getElementById('card2WeblnSendPayment_json');
|
||||||
|
if (card2WeblnSendPayment_jsonElement && eventParsed.paymentRequest) {
|
||||||
|
card2WeblnSendPayment_jsonElement.innerText = eventParsed.paymentRequest;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cardWeblnSendPaymentElement.style.display = 'none';
|
||||||
|
card2WeblnSendPaymentElement.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardWeblnMakeInvoiceElement = document.getElementById('cardWeblnMakeInvoice');
|
||||||
|
if (cardWeblnMakeInvoiceElement) {
|
||||||
|
if (method === 'webln.makeInvoice') {
|
||||||
|
const invoiceAmountSpan = document.getElementById('invoiceAmountSpan');
|
||||||
|
if (invoiceAmountSpan) {
|
||||||
|
const amount = eventParsed.amount ?? eventParsed.defaultAmount;
|
||||||
|
if (amount) {
|
||||||
|
invoiceAmountSpan.innerText = ` for ${Number(amount).toLocaleString()} sats`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cardWeblnMakeInvoiceElement.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardWeblnKeysendElement = document.getElementById('cardWeblnKeysend');
|
||||||
|
if (cardWeblnKeysendElement) {
|
||||||
|
if (method !== 'webln.keysend') {
|
||||||
|
cardWeblnKeysendElement.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// Functions
|
// Functions
|
||||||
//
|
//
|
||||||
@@ -223,4 +302,21 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
approveAlwaysButton?.addEventListener('click', () => {
|
approveAlwaysButton?.addEventListener('click', () => {
|
||||||
deliver('approve');
|
deliver('approve');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const rejectAllButton = document.getElementById('rejectAllButton');
|
||||||
|
rejectAllButton?.addEventListener('click', () => {
|
||||||
|
deliver('reject-all');
|
||||||
|
});
|
||||||
|
|
||||||
|
const approveAllButton = document.getElementById('approveAllButton');
|
||||||
|
approveAllButton?.addEventListener('click', () => {
|
||||||
|
deliver('approve-all');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show/hide "All Queued" row based on queue size
|
||||||
|
const queueSize = parseInt(params.get('queueSize') || '0', 10);
|
||||||
|
const allQueuedRow = document.getElementById('allQueuedRow');
|
||||||
|
if (allQueuedRow && queueSize <= 1) {
|
||||||
|
allQueuedRow.style.display = 'none';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,3 +8,12 @@ export type Nip07Method =
|
|||||||
| 'nip44.decrypt';
|
| 'nip44.decrypt';
|
||||||
|
|
||||||
export type Nip07MethodPolicy = 'allow' | 'deny';
|
export type Nip07MethodPolicy = 'allow' | 'deny';
|
||||||
|
|
||||||
|
export type WeblnMethod =
|
||||||
|
| 'webln.enable'
|
||||||
|
| 'webln.getInfo'
|
||||||
|
| 'webln.sendPayment'
|
||||||
|
| 'webln.makeInvoice'
|
||||||
|
| 'webln.keysend';
|
||||||
|
|
||||||
|
export type ExtensionMethod = Nip07Method | WeblnMethod;
|
||||||
|
|||||||
41
projects/common/src/lib/models/webln.ts
Normal file
41
projects/common/src/lib/models/webln.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* WebLN API Types
|
||||||
|
* Based on the WebLN specification: https://webln.dev/
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface WebLNNode {
|
||||||
|
alias?: string;
|
||||||
|
pubkey?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetInfoResponse {
|
||||||
|
node: WebLNNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendPaymentResponse {
|
||||||
|
preimage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestInvoiceArgs {
|
||||||
|
amount?: string | number;
|
||||||
|
defaultAmount?: string | number;
|
||||||
|
minimumAmount?: string | number;
|
||||||
|
maximumAmount?: string | number;
|
||||||
|
defaultMemo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestInvoiceResponse {
|
||||||
|
paymentRequest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeysendArgs {
|
||||||
|
destination: string;
|
||||||
|
amount: string | number;
|
||||||
|
customRecords?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignMessageResponse {
|
||||||
|
message: string;
|
||||||
|
signature: string;
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Nip07Method, Nip07MethodPolicy } from '@common';
|
import { ExtensionMethod, Nip07MethodPolicy } from '@common';
|
||||||
|
|
||||||
export interface Permission_DECRYPTED {
|
export interface Permission_DECRYPTED {
|
||||||
id: string;
|
id: string;
|
||||||
identityId: string;
|
identityId: string;
|
||||||
host: string;
|
host: string;
|
||||||
method: Nip07Method;
|
method: ExtensionMethod;
|
||||||
methodPolicy: Nip07MethodPolicy;
|
methodPolicy: Nip07MethodPolicy;
|
||||||
kind?: number;
|
kind?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export * from './lib/helpers/nip05-validator';
|
|||||||
|
|
||||||
// Models
|
// Models
|
||||||
export * from './lib/models/nostr';
|
export * from './lib/models/nostr';
|
||||||
|
export * from './lib/models/webln';
|
||||||
|
|
||||||
// Services (and related)
|
// Services (and related)
|
||||||
export * from './lib/services/storage/storage.service';
|
export * from './lib/services/storage/storage.service';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Plebeian Signer",
|
"name": "Plebeian Signer",
|
||||||
"description": "Nostr Identity Manager & Signer",
|
"description": "Nostr Identity Manager & Signer",
|
||||||
"version": "1.0.11",
|
"version": "1.1.1",
|
||||||
"homepage_url": "https://github.com/PlebeianApp/plebeian-signer",
|
"homepage_url": "https://github.com/PlebeianApp/plebeian-signer",
|
||||||
"options_page": "options.html",
|
"options_page": "options.html",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
|
|||||||
@@ -211,6 +211,53 @@
|
|||||||
<div id="card2Nip44Decrypt" class="card sam-mt sam-ml sam-mr">
|
<div id="card2Nip44Decrypt" class="card sam-mt sam-ml sam-mr">
|
||||||
<div id="card2Nip44Decrypt_text" class="text"></div>
|
<div id="card2Nip44Decrypt_text" class="text"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Card for webln.enable -->
|
||||||
|
<div id="cardWeblnEnable" class="card sam-mt sam-ml sam-mr">
|
||||||
|
<p class="description">
|
||||||
|
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||||
|
<b class="color-primary">connect to your Lightning wallet</b>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card for webln.getInfo -->
|
||||||
|
<div id="cardWeblnGetInfo" class="card sam-mt sam-ml sam-mr">
|
||||||
|
<p class="description">
|
||||||
|
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||||
|
<b class="color-primary">read your wallet info</b>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card for webln.sendPayment -->
|
||||||
|
<div id="cardWeblnSendPayment" class="card sam-mt sam-ml sam-mr">
|
||||||
|
<p class="description">
|
||||||
|
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||||
|
<b class="color-primary">send a Lightning payment</b> of
|
||||||
|
<b id="paymentAmountSpan" class="color-primary"></b>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card2 for webln.sendPayment (shows invoice) -->
|
||||||
|
<div id="card2WeblnSendPayment" class="card sam-mt sam-ml sam-mr">
|
||||||
|
<div id="card2WeblnSendPayment_json" class="json"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card for webln.makeInvoice -->
|
||||||
|
<div id="cardWeblnMakeInvoice" class="card sam-mt sam-ml sam-mr">
|
||||||
|
<p class="description">
|
||||||
|
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||||
|
<b class="color-primary">create a Lightning invoice</b>
|
||||||
|
<span id="invoiceAmountSpan"></span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Card for webln.keysend -->
|
||||||
|
<div id="cardWeblnKeysend" class="card sam-mt sam-ml sam-mr">
|
||||||
|
<p class="description">
|
||||||
|
<b class="host-INSERT color-primary"></b> is requesting permission to
|
||||||
|
<b class="color-primary">send a keysend payment</b>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!------------->
|
<!------------->
|
||||||
@@ -231,6 +278,13 @@
|
|||||||
<button id="approveAlwaysButton" type="button" class="btn-accept">Always</button>
|
<button id="approveAlwaysButton" type="button" class="btn-accept">Always</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="action-row" id="allQueuedRow">
|
||||||
|
<span class="action-label">All Queued</span>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button id="rejectAllButton" type="button" class="btn-reject">Reject All</button>
|
||||||
|
<button id="approveAllButton" type="button" class="btn-accept">Approve All</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="prompt.js"></script>
|
<script src="prompt.js"></script>
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
CashuMint_DECRYPTED,
|
CashuMint_DECRYPTED,
|
||||||
CashuMint_ENCRYPTED,
|
CashuMint_ENCRYPTED,
|
||||||
deriveKeyArgon2,
|
deriveKeyArgon2,
|
||||||
|
ExtensionMethod,
|
||||||
|
WeblnMethod,
|
||||||
} from '@common';
|
} from '@common';
|
||||||
import { FirefoxMetaHandler } from './app/common/data/firefox-meta-handler';
|
import { FirefoxMetaHandler } from './app/common/data/firefox-meta-handler';
|
||||||
import { Event, EventTemplate, finalizeEvent, nip04, nip44 } from 'nostr-tools';
|
import { Event, EventTemplate, finalizeEvent, nip04, nip44 } from 'nostr-tools';
|
||||||
@@ -47,8 +49,10 @@ export const debug = function (message: any) {
|
|||||||
export type PromptResponse =
|
export type PromptResponse =
|
||||||
| 'reject'
|
| 'reject'
|
||||||
| 'reject-once'
|
| 'reject-once'
|
||||||
|
| 'reject-all' // P2: Reject all requests of this type from this host
|
||||||
| 'approve'
|
| 'approve'
|
||||||
| 'approve-once';
|
| 'approve-once'
|
||||||
|
| 'approve-all'; // P2: Approve all requests of this type from this host
|
||||||
|
|
||||||
export interface PromptResponseMessage {
|
export interface PromptResponseMessage {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -56,7 +60,7 @@ export interface PromptResponseMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BackgroundRequestMessage {
|
export interface BackgroundRequestMessage {
|
||||||
method: Nip07Method;
|
method: ExtensionMethod;
|
||||||
params: any;
|
params: any;
|
||||||
host: string;
|
host: string;
|
||||||
}
|
}
|
||||||
@@ -219,11 +223,51 @@ export const checkPermissions = function (
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a method is a WebLN method
|
||||||
|
*/
|
||||||
|
export const isWeblnMethod = function (method: ExtensionMethod): method is WeblnMethod {
|
||||||
|
return method.startsWith('webln.');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check WebLN permissions for a host.
|
||||||
|
* Note: WebLN permissions are NOT tied to identities since the wallet is global.
|
||||||
|
* For sendPayment, always returns undefined (require user prompt for security).
|
||||||
|
*/
|
||||||
|
export const checkWeblnPermissions = function (
|
||||||
|
browserSessionData: BrowserSessionData,
|
||||||
|
host: string,
|
||||||
|
method: WeblnMethod
|
||||||
|
): boolean | undefined {
|
||||||
|
// sendPayment ALWAYS requires user approval (security-critical, irreversible)
|
||||||
|
if (method === 'webln.sendPayment') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// keysend also requires approval
|
||||||
|
if (method === 'webln.keysend') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other WebLN methods, check stored permissions
|
||||||
|
// WebLN permissions use 'webln' as the identityId
|
||||||
|
const permissions = browserSessionData.permissions.filter(
|
||||||
|
(x) => x.identityId === 'webln' && x.host === host && x.method === method
|
||||||
|
);
|
||||||
|
|
||||||
|
if (permissions.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return permissions.every((x) => x.methodPolicy === 'allow');
|
||||||
|
};
|
||||||
|
|
||||||
export const storePermission = async function (
|
export const storePermission = async function (
|
||||||
browserSessionData: BrowserSessionData,
|
browserSessionData: BrowserSessionData,
|
||||||
identity: Identity_DECRYPTED,
|
identity: Identity_DECRYPTED | null,
|
||||||
host: string,
|
host: string,
|
||||||
method: Nip07Method,
|
method: ExtensionMethod,
|
||||||
methodPolicy: Nip07MethodPolicy,
|
methodPolicy: Nip07MethodPolicy,
|
||||||
kind?: number
|
kind?: number
|
||||||
) {
|
) {
|
||||||
@@ -232,11 +276,14 @@ export const storePermission = async function (
|
|||||||
throw new Error(`Could not retrieve sync data`);
|
throw new Error(`Could not retrieve sync data`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For WebLN methods, use 'webln' as identityId since wallet is global
|
||||||
|
const identityId = identity?.id ?? 'webln';
|
||||||
|
|
||||||
const permission: Permission_DECRYPTED = {
|
const permission: Permission_DECRYPTED = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
identityId: identity.id,
|
identityId,
|
||||||
host,
|
host,
|
||||||
method,
|
method: method as Nip07Method, // Cast for storage compatibility
|
||||||
methodPolicy,
|
methodPolicy,
|
||||||
kind,
|
kind,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,14 +3,23 @@ import {
|
|||||||
backgroundLogNip07Action,
|
backgroundLogNip07Action,
|
||||||
backgroundLogPermissionStored,
|
backgroundLogPermissionStored,
|
||||||
NostrHelper,
|
NostrHelper,
|
||||||
|
NwcClient,
|
||||||
|
NwcConnection_DECRYPTED,
|
||||||
|
WeblnMethod,
|
||||||
|
Nip07Method,
|
||||||
|
GetInfoResponse,
|
||||||
|
SendPaymentResponse,
|
||||||
|
RequestInvoiceResponse,
|
||||||
} from '@common';
|
} from '@common';
|
||||||
import {
|
import {
|
||||||
BackgroundRequestMessage,
|
BackgroundRequestMessage,
|
||||||
checkPermissions,
|
checkPermissions,
|
||||||
|
checkWeblnPermissions,
|
||||||
debug,
|
debug,
|
||||||
getBrowserSessionData,
|
getBrowserSessionData,
|
||||||
getPosition,
|
getPosition,
|
||||||
handleUnlockRequest,
|
handleUnlockRequest,
|
||||||
|
isWeblnMethod,
|
||||||
nip04Decrypt,
|
nip04Decrypt,
|
||||||
nip04Encrypt,
|
nip04Encrypt,
|
||||||
nip44Decrypt,
|
nip44Decrypt,
|
||||||
@@ -27,13 +36,93 @@ import {
|
|||||||
import browser from 'webextension-polyfill';
|
import browser from 'webextension-polyfill';
|
||||||
import { Buffer } from 'buffer';
|
import { Buffer } from 'buffer';
|
||||||
|
|
||||||
|
// Cache for NWC clients to avoid reconnecting for each request
|
||||||
|
const nwcClientCache = new Map<string, NwcClient>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create an NWC client for a connection
|
||||||
|
*/
|
||||||
|
async function getNwcClient(connection: NwcConnection_DECRYPTED): Promise<NwcClient> {
|
||||||
|
const cached = nwcClientCache.get(connection.id);
|
||||||
|
if (cached && cached.isConnected()) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new NwcClient({
|
||||||
|
walletPubkey: connection.walletPubkey,
|
||||||
|
relayUrl: connection.relayUrl,
|
||||||
|
secret: connection.secret,
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.connect();
|
||||||
|
nwcClientCache.set(connection.id, client);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse invoice amount from a BOLT11 invoice string
|
||||||
|
* Returns amount in satoshis, or undefined if no amount specified
|
||||||
|
*/
|
||||||
|
function parseInvoiceAmount(invoice: string): number | undefined {
|
||||||
|
try {
|
||||||
|
// BOLT11 invoices start with 'ln' followed by network prefix and amount
|
||||||
|
// Format: ln[network][amount][multiplier]1[data]
|
||||||
|
// Examples: lnbc1500n1... (1500 sat), lnbc1m1... (0.001 BTC = 100000 sat)
|
||||||
|
const match = invoice.toLowerCase().match(/^ln(bc|tb|tbs|bcrt)(\d+)([munp])?1/);
|
||||||
|
if (!match) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const amountStr = match[2];
|
||||||
|
const multiplier = match[3];
|
||||||
|
|
||||||
|
let amount = parseInt(amountStr, 10);
|
||||||
|
|
||||||
|
// Apply multiplier (amount is in BTC by default)
|
||||||
|
switch (multiplier) {
|
||||||
|
case 'm': // milli-bitcoin (0.001 BTC)
|
||||||
|
amount = amount * 100000;
|
||||||
|
break;
|
||||||
|
case 'u': // micro-bitcoin (0.000001 BTC)
|
||||||
|
amount = amount * 100;
|
||||||
|
break;
|
||||||
|
case 'n': // nano-bitcoin (0.000000001 BTC) = 0.1 sat
|
||||||
|
amount = Math.floor(amount / 10);
|
||||||
|
break;
|
||||||
|
case 'p': // pico-bitcoin (0.000000000001 BTC) = 0.0001 sat
|
||||||
|
amount = Math.floor(amount / 10000);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// No multiplier means BTC
|
||||||
|
amount = amount * 100000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
return amount;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Relays = Record<string, { read: boolean; write: boolean }>;
|
type Relays = Record<string, { read: boolean; write: boolean }>;
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Permission Prompt Queue System (P0)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Timeout for permission prompts (30 seconds)
|
||||||
|
const PROMPT_TIMEOUT_MS = 30000;
|
||||||
|
|
||||||
|
// Maximum number of queued permission requests (prevent DoS)
|
||||||
|
const MAX_PERMISSION_QUEUE_SIZE = 100;
|
||||||
|
|
||||||
|
// Track open prompts with metadata for cleanup
|
||||||
const openPrompts = new Map<
|
const openPrompts = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
resolve: (response: PromptResponse) => void;
|
resolve: (response: PromptResponse) => void;
|
||||||
reject: (reason?: any) => void;
|
reject: (reason?: any) => void;
|
||||||
|
windowId?: number;
|
||||||
|
timeoutId?: ReturnType<typeof setTimeout>;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
|
|
||||||
@@ -47,6 +136,170 @@ const pendingRequests: {
|
|||||||
reject: (error: any) => void;
|
reject: (error: any) => void;
|
||||||
}[] = [];
|
}[] = [];
|
||||||
|
|
||||||
|
// Queue for permission requests (only one prompt shown at a time)
|
||||||
|
interface PermissionQueueItem {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
resolve: (response: PromptResponse) => void;
|
||||||
|
reject: (reason?: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionQueue: PermissionQueueItem[] = [];
|
||||||
|
let activePromptId: string | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the next permission prompt from the queue
|
||||||
|
*/
|
||||||
|
async function showNextPermissionPrompt(): Promise<void> {
|
||||||
|
if (activePromptId || permissionQueue.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = permissionQueue[0];
|
||||||
|
activePromptId = next.id;
|
||||||
|
|
||||||
|
const { top, left } = await getPosition(next.width, next.height);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const window = await browser.windows.create({
|
||||||
|
type: 'popup',
|
||||||
|
url: next.url,
|
||||||
|
height: next.height,
|
||||||
|
width: next.width,
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
});
|
||||||
|
|
||||||
|
const promptData = openPrompts.get(next.id);
|
||||||
|
if (promptData && window.id) {
|
||||||
|
promptData.windowId = window.id;
|
||||||
|
promptData.timeoutId = setTimeout(() => {
|
||||||
|
debug(`Prompt ${next.id} timed out after ${PROMPT_TIMEOUT_MS}ms`);
|
||||||
|
cleanupPrompt(next.id, 'timeout');
|
||||||
|
}, PROMPT_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
debug(`Failed to create prompt window: ${error}`);
|
||||||
|
cleanupPrompt(next.id, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up a prompt and process the next one in queue
|
||||||
|
*/
|
||||||
|
function cleanupPrompt(promptId: string, reason: 'response' | 'timeout' | 'closed' | 'error'): void {
|
||||||
|
const promptData = openPrompts.get(promptId);
|
||||||
|
|
||||||
|
if (promptData) {
|
||||||
|
if (promptData.timeoutId) {
|
||||||
|
clearTimeout(promptData.timeoutId);
|
||||||
|
}
|
||||||
|
if (reason !== 'response') {
|
||||||
|
promptData.reject(new Error(`Permission prompt ${reason}`));
|
||||||
|
}
|
||||||
|
openPrompts.delete(promptId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueIndex = permissionQueue.findIndex(item => item.id === promptId);
|
||||||
|
if (queueIndex !== -1) {
|
||||||
|
permissionQueue.splice(queueIndex, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activePromptId === promptId) {
|
||||||
|
activePromptId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
showNextPermissionPrompt();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue a permission prompt request
|
||||||
|
*/
|
||||||
|
function queuePermissionPrompt(
|
||||||
|
urlWithoutId: string,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): Promise<PromptResponse> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (permissionQueue.length >= MAX_PERMISSION_QUEUE_SIZE) {
|
||||||
|
reject(new Error('Too many pending permission requests. Please try again later.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const separator = urlWithoutId.includes('?') ? '&' : '?';
|
||||||
|
const url = `${urlWithoutId}${separator}id=${id}`;
|
||||||
|
|
||||||
|
openPrompts.set(id, { resolve, reject });
|
||||||
|
permissionQueue.push({ id, url, width, height, resolve, reject });
|
||||||
|
|
||||||
|
debug(`Queued permission prompt ${id}. Queue size: ${permissionQueue.length}`);
|
||||||
|
showNextPermissionPrompt();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for window close events to clean up orphaned prompts
|
||||||
|
browser.windows.onRemoved.addListener((windowId: number) => {
|
||||||
|
for (const [promptId, promptData] of openPrompts.entries()) {
|
||||||
|
if (promptData.windowId === windowId) {
|
||||||
|
debug(`Prompt window ${windowId} closed without response`);
|
||||||
|
cleanupPrompt(promptId, 'closed');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Request Deduplication (P1)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const pendingRequestPromises = new Map<string, Promise<PromptResponse>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a hash key for request deduplication
|
||||||
|
*/
|
||||||
|
function getRequestHash(host: string, method: string, params: any): string {
|
||||||
|
if (method === 'signEvent' && params?.kind !== undefined) {
|
||||||
|
return `${host}:${method}:kind${params.kind}`;
|
||||||
|
}
|
||||||
|
if ((method.includes('encrypt') || method.includes('decrypt')) && params?.peerPubkey) {
|
||||||
|
return `${host}:${method}:${params.peerPubkey}`;
|
||||||
|
}
|
||||||
|
return `${host}:${method}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue a permission prompt with deduplication
|
||||||
|
*/
|
||||||
|
function queuePermissionPromptDeduped(
|
||||||
|
host: string,
|
||||||
|
method: string,
|
||||||
|
params: any,
|
||||||
|
urlWithoutId: string,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
): Promise<PromptResponse> {
|
||||||
|
const hash = getRequestHash(host, method, params);
|
||||||
|
|
||||||
|
const existingPromise = pendingRequestPromises.get(hash);
|
||||||
|
if (existingPromise) {
|
||||||
|
debug(`Deduplicating request: ${hash}`);
|
||||||
|
return existingPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = queuePermissionPrompt(urlWithoutId, width, height)
|
||||||
|
.finally(() => {
|
||||||
|
pendingRequestPromises.delete(hash);
|
||||||
|
});
|
||||||
|
|
||||||
|
pendingRequestPromises.set(hash, promise);
|
||||||
|
debug(`New permission request: ${hash}`);
|
||||||
|
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
||||||
debug('Message received');
|
debug('Message received');
|
||||||
|
|
||||||
@@ -88,13 +341,12 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
|||||||
const promptResponse = request as PromptResponseMessage;
|
const promptResponse = request as PromptResponseMessage;
|
||||||
const openPrompt = openPrompts.get(promptResponse.id);
|
const openPrompt = openPrompts.get(promptResponse.id);
|
||||||
if (!openPrompt) {
|
if (!openPrompt) {
|
||||||
throw new Error(
|
debug('Prompt response could not be matched (may have timed out)');
|
||||||
'Prompt response could not be matched to any previous request.'
|
return;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
openPrompt.resolve(promptResponse.response);
|
openPrompt.resolve(promptResponse.response);
|
||||||
openPrompts.delete(promptResponse.id);
|
cleanupPrompt(promptResponse.id, 'response');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,8 +368,12 @@ browser.runtime.onMessage.addListener(async (message /*, sender*/) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process the NIP-07 request
|
// Process the request (NIP-07 or WebLN)
|
||||||
return processNip07Request(request as BackgroundRequestMessage);
|
const req = request as BackgroundRequestMessage;
|
||||||
|
if (isWeblnMethod(req.method)) {
|
||||||
|
return processWeblnRequest(req);
|
||||||
|
}
|
||||||
|
return processNip07Request(req);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,7 +405,7 @@ async function processNip07Request(req: BackgroundRequestMessage): Promise<any>
|
|||||||
browserSessionData,
|
browserSessionData,
|
||||||
currentIdentity,
|
currentIdentity,
|
||||||
req.host,
|
req.host,
|
||||||
req.method,
|
req.method as Nip07Method,
|
||||||
req.params
|
req.params
|
||||||
);
|
);
|
||||||
debug(`permissionState result: ${permissionState}`);
|
debug(`permissionState result: ${permissionState}`);
|
||||||
@@ -159,29 +415,23 @@ async function processNip07Request(req: BackgroundRequestMessage): Promise<any>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (permissionState === undefined) {
|
if (permissionState === undefined) {
|
||||||
// Ask user for permission.
|
// Ask user for permission (queued + deduplicated)
|
||||||
const width = 375;
|
const width = 375;
|
||||||
const height = 600;
|
const height = 600;
|
||||||
const { top, left } = await getPosition(width, height);
|
|
||||||
|
|
||||||
const base64Event = Buffer.from(
|
const base64Event = Buffer.from(
|
||||||
JSON.stringify(req.params ?? {}, undefined, 2)
|
JSON.stringify(req.params ?? {}, undefined, 2)
|
||||||
).toString('base64');
|
).toString('base64');
|
||||||
|
|
||||||
const response = await new Promise<PromptResponse>((resolve, reject) => {
|
// Include queue info for user awareness
|
||||||
const id = crypto.randomUUID();
|
const queueSize = permissionQueue.length;
|
||||||
openPrompts.set(id, { resolve, reject });
|
const promptUrl = `prompt.html?method=${req.method}&host=${req.host}&nick=${encodeURIComponent(currentIdentity.nick)}&event=${base64Event}&queue=${queueSize}`;
|
||||||
browser.windows.create({
|
const response = await queuePermissionPromptDeduped(req.host, req.method, req.params, promptUrl, width, height);
|
||||||
type: 'popup',
|
|
||||||
url: `prompt.html?method=${req.method}&host=${req.host}&id=${id}&nick=${currentIdentity.nick}&event=${base64Event}`,
|
|
||||||
height,
|
|
||||||
width,
|
|
||||||
top,
|
|
||||||
left,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
debug(response);
|
debug(response);
|
||||||
|
|
||||||
|
// Handle permission storage based on response type
|
||||||
if (response === 'approve' || response === 'reject') {
|
if (response === 'approve' || response === 'reject') {
|
||||||
|
// Store permission for this specific kind (if signEvent) or method
|
||||||
const policy = response === 'approve' ? 'allow' : 'deny';
|
const policy = response === 'approve' ? 'allow' : 'deny';
|
||||||
await storePermission(
|
await storePermission(
|
||||||
browserSessionData,
|
browserSessionData,
|
||||||
@@ -191,15 +441,34 @@ async function processNip07Request(req: BackgroundRequestMessage): Promise<any>
|
|||||||
policy,
|
policy,
|
||||||
req.params?.kind
|
req.params?.kind
|
||||||
);
|
);
|
||||||
await backgroundLogPermissionStored(
|
await backgroundLogPermissionStored(req.host, req.method, policy, req.params?.kind);
|
||||||
|
} else if (response === 'approve-all') {
|
||||||
|
// P2: Store permission for ALL kinds/uses of this method from this host
|
||||||
|
await storePermission(
|
||||||
|
browserSessionData,
|
||||||
|
currentIdentity,
|
||||||
req.host,
|
req.host,
|
||||||
req.method,
|
req.method,
|
||||||
policy,
|
'allow',
|
||||||
req.params?.kind
|
undefined // undefined kind = allow all kinds for signEvent
|
||||||
);
|
);
|
||||||
|
await backgroundLogPermissionStored(req.host, req.method, 'allow', undefined);
|
||||||
|
debug(`Stored approve-all permission for ${req.method} from ${req.host}`);
|
||||||
|
} else if (response === 'reject-all') {
|
||||||
|
// P2: Store deny permission for ALL uses of this method from this host
|
||||||
|
await storePermission(
|
||||||
|
browserSessionData,
|
||||||
|
currentIdentity,
|
||||||
|
req.host,
|
||||||
|
req.method,
|
||||||
|
'deny',
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
await backgroundLogPermissionStored(req.host, req.method, 'deny', undefined);
|
||||||
|
debug(`Stored reject-all permission for ${req.method} from ${req.host}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (['reject', 'reject-once'].includes(response)) {
|
if (['reject', 'reject-once', 'reject-all'].includes(response)) {
|
||||||
await backgroundLogNip07Action(req.method, req.host, false, false, {
|
await backgroundLogNip07Action(req.method, req.host, false, false, {
|
||||||
kind: req.params?.kind,
|
kind: req.params?.kind,
|
||||||
peerPubkey: req.params?.peerPubkey,
|
peerPubkey: req.params?.peerPubkey,
|
||||||
@@ -282,3 +551,148 @@ async function processNip07Request(req: BackgroundRequestMessage): Promise<any>
|
|||||||
throw new Error(`Not supported request method '${req.method}'.`);
|
throw new Error(`Not supported request method '${req.method}'.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a WebLN request after vault is unlocked
|
||||||
|
*/
|
||||||
|
async function processWeblnRequest(req: BackgroundRequestMessage): Promise<any> {
|
||||||
|
const browserSessionData = await getBrowserSessionData();
|
||||||
|
|
||||||
|
if (!browserSessionData) {
|
||||||
|
throw new Error('Plebeian Signer vault not unlocked by the user.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nwcConnections = browserSessionData.nwcConnections ?? [];
|
||||||
|
const method = req.method as WeblnMethod;
|
||||||
|
|
||||||
|
// webln.enable just checks if NWC is configured
|
||||||
|
if (method === 'webln.enable') {
|
||||||
|
if (nwcConnections.length === 0) {
|
||||||
|
throw new Error('No wallet configured. Please add an NWC connection in Plebeian Signer settings.');
|
||||||
|
}
|
||||||
|
debug('WebLN enabled');
|
||||||
|
return { enabled: true }; // Return explicit value (undefined gets filtered by content script)
|
||||||
|
}
|
||||||
|
|
||||||
|
// All other methods require an NWC connection
|
||||||
|
const defaultConnection = nwcConnections[0];
|
||||||
|
if (!defaultConnection) {
|
||||||
|
throw new Error('No wallet configured. Please add an NWC connection in Plebeian Signer settings.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check reckless mode (but still prompt for payments)
|
||||||
|
const recklessApprove = await shouldRecklessModeApprove(req.host);
|
||||||
|
|
||||||
|
// Check WebLN permissions
|
||||||
|
const permissionState = recklessApprove && method !== 'webln.sendPayment' && method !== 'webln.keysend'
|
||||||
|
? true
|
||||||
|
: checkWeblnPermissions(browserSessionData, req.host, method);
|
||||||
|
|
||||||
|
if (permissionState === false) {
|
||||||
|
throw new Error('Permission denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permissionState === undefined) {
|
||||||
|
// Ask user for permission (queued + deduplicated)
|
||||||
|
const width = 375;
|
||||||
|
const height = 600;
|
||||||
|
|
||||||
|
// For sendPayment, include the invoice amount in the prompt data
|
||||||
|
let promptParams = req.params ?? {};
|
||||||
|
if (method === 'webln.sendPayment' && req.params?.paymentRequest) {
|
||||||
|
const amountSats = parseInvoiceAmount(req.params.paymentRequest);
|
||||||
|
promptParams = { ...promptParams, amountSats };
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64Event = Buffer.from(
|
||||||
|
JSON.stringify(promptParams, undefined, 2)
|
||||||
|
).toString('base64');
|
||||||
|
|
||||||
|
// Include queue info for user awareness
|
||||||
|
const queueSize = permissionQueue.length;
|
||||||
|
const promptUrl = `prompt.html?method=${method}&host=${req.host}&nick=WebLN&event=${base64Event}&queue=${queueSize}`;
|
||||||
|
const response = await queuePermissionPromptDeduped(req.host, method, req.params, promptUrl, width, height);
|
||||||
|
|
||||||
|
debug(response);
|
||||||
|
|
||||||
|
// Store permission for non-payment methods
|
||||||
|
if ((response === 'approve' || response === 'reject') && method !== 'webln.sendPayment' && method !== 'webln.keysend') {
|
||||||
|
const policy = response === 'approve' ? 'allow' : 'deny';
|
||||||
|
await storePermission(
|
||||||
|
browserSessionData,
|
||||||
|
null, // WebLN has no identity
|
||||||
|
req.host,
|
||||||
|
method,
|
||||||
|
policy
|
||||||
|
);
|
||||||
|
await backgroundLogPermissionStored(req.host, method, policy);
|
||||||
|
} else if (response === 'approve-all' && method !== 'webln.sendPayment' && method !== 'webln.keysend') {
|
||||||
|
// P2: Store permission for all uses of this WebLN method
|
||||||
|
await storePermission(
|
||||||
|
browserSessionData,
|
||||||
|
null,
|
||||||
|
req.host,
|
||||||
|
method,
|
||||||
|
'allow'
|
||||||
|
);
|
||||||
|
await backgroundLogPermissionStored(req.host, method, 'allow');
|
||||||
|
debug(`Stored approve-all permission for ${method} from ${req.host}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['reject', 'reject-once', 'reject-all'].includes(response)) {
|
||||||
|
throw new Error('Permission denied');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the WebLN method
|
||||||
|
let result: any;
|
||||||
|
const client = await getNwcClient(defaultConnection);
|
||||||
|
|
||||||
|
switch (method) {
|
||||||
|
case 'webln.getInfo': {
|
||||||
|
const info = await client.getInfo();
|
||||||
|
result = {
|
||||||
|
node: {
|
||||||
|
alias: info.alias,
|
||||||
|
pubkey: info.pubkey,
|
||||||
|
color: info.color,
|
||||||
|
},
|
||||||
|
} as GetInfoResponse;
|
||||||
|
debug('webln.getInfo result:');
|
||||||
|
debug(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'webln.sendPayment': {
|
||||||
|
const invoice = req.params.paymentRequest;
|
||||||
|
const payResult = await client.payInvoice({ invoice });
|
||||||
|
result = { preimage: payResult.preimage } as SendPaymentResponse;
|
||||||
|
debug('webln.sendPayment result:');
|
||||||
|
debug(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'webln.makeInvoice': {
|
||||||
|
// Convert sats to millisats (NWC uses millisats)
|
||||||
|
const amountSats = typeof req.params.amount === 'string'
|
||||||
|
? parseInt(req.params.amount, 10)
|
||||||
|
: req.params.amount ?? req.params.defaultAmount ?? 0;
|
||||||
|
const amountMsat = amountSats * 1000;
|
||||||
|
|
||||||
|
const invoiceResult = await client.makeInvoice({
|
||||||
|
amount: amountMsat,
|
||||||
|
description: req.params.defaultMemo,
|
||||||
|
});
|
||||||
|
result = { paymentRequest: invoiceResult.invoice } as RequestInvoiceResponse;
|
||||||
|
debug('webln.makeInvoice result:');
|
||||||
|
debug(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'webln.keysend':
|
||||||
|
throw new Error('keysend is not yet supported');
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Not supported WebLN method '${method}'.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { Event, EventTemplate } from 'nostr-tools';
|
import { Event as NostrEvent, EventTemplate } from 'nostr-tools';
|
||||||
import { Nip07Method } from '@common';
|
import { ExtensionMethod } from '@common';
|
||||||
|
|
||||||
// Extend Window interface for NIP-07
|
// Extend Window interface for NIP-07 and WebLN
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
nostr?: any;
|
nostr?: any;
|
||||||
|
webln?: any;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ class Messenger {
|
|||||||
window.addEventListener('message', this.#handleCallResponse.bind(this));
|
window.addEventListener('message', this.#handleCallResponse.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
async request(method: Nip07Method, params: any): Promise<any> {
|
async request(method: ExtensionMethod, params: any): Promise<any> {
|
||||||
const id = generateUUID();
|
const id = generateUUID();
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -89,7 +90,7 @@ const nostr = {
|
|||||||
return pubkey;
|
return pubkey;
|
||||||
},
|
},
|
||||||
|
|
||||||
async signEvent(event: EventTemplate): Promise<Event> {
|
async signEvent(event: EventTemplate): Promise<NostrEvent> {
|
||||||
debug('signEvent received');
|
debug('signEvent received');
|
||||||
const signedEvent = await this.messenger.request('signEvent', event);
|
const signedEvent = await this.messenger.request('signEvent', event);
|
||||||
debug('signEvent response:');
|
debug('signEvent response:');
|
||||||
@@ -158,6 +159,92 @@ const nostr = {
|
|||||||
|
|
||||||
window.nostr = nostr as any;
|
window.nostr = nostr as any;
|
||||||
|
|
||||||
|
// WebLN types (inline to avoid build issues with @common types in injected script)
|
||||||
|
interface RequestInvoiceArgs {
|
||||||
|
amount?: string | number;
|
||||||
|
defaultAmount?: string | number;
|
||||||
|
minimumAmount?: string | number;
|
||||||
|
maximumAmount?: string | number;
|
||||||
|
defaultMemo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KeysendArgs {
|
||||||
|
destination: string;
|
||||||
|
amount: string | number;
|
||||||
|
customRecords?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a shared messenger instance for WebLN
|
||||||
|
const weblnMessenger = nostr.messenger;
|
||||||
|
|
||||||
|
const webln = {
|
||||||
|
enabled: false,
|
||||||
|
|
||||||
|
async enable(): Promise<void> {
|
||||||
|
debug('webln.enable received');
|
||||||
|
await weblnMessenger.request('webln.enable', {});
|
||||||
|
this.enabled = true;
|
||||||
|
debug('webln.enable completed');
|
||||||
|
// Dispatch webln:enabled event as per WebLN spec
|
||||||
|
window.dispatchEvent(new Event('webln:enabled'));
|
||||||
|
},
|
||||||
|
|
||||||
|
async getInfo(): Promise<{ node: { alias?: string; pubkey?: string; color?: string } }> {
|
||||||
|
debug('webln.getInfo received');
|
||||||
|
const info = await weblnMessenger.request('webln.getInfo', {});
|
||||||
|
debug('webln.getInfo response:');
|
||||||
|
debug(info);
|
||||||
|
return info;
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendPayment(paymentRequest: string): Promise<{ preimage: string }> {
|
||||||
|
debug('webln.sendPayment received');
|
||||||
|
const result = await weblnMessenger.request('webln.sendPayment', { paymentRequest });
|
||||||
|
debug('webln.sendPayment response:');
|
||||||
|
debug(result);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
async keysend(args: KeysendArgs): Promise<{ preimage: string }> {
|
||||||
|
debug('webln.keysend received');
|
||||||
|
const result = await weblnMessenger.request('webln.keysend', args);
|
||||||
|
debug('webln.keysend response:');
|
||||||
|
debug(result);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
async makeInvoice(
|
||||||
|
args: string | number | RequestInvoiceArgs
|
||||||
|
): Promise<{ paymentRequest: string }> {
|
||||||
|
debug('webln.makeInvoice received');
|
||||||
|
// Normalize args to RequestInvoiceArgs
|
||||||
|
let normalizedArgs: RequestInvoiceArgs;
|
||||||
|
if (typeof args === 'string' || typeof args === 'number') {
|
||||||
|
normalizedArgs = { amount: args };
|
||||||
|
} else {
|
||||||
|
normalizedArgs = args;
|
||||||
|
}
|
||||||
|
const result = await weblnMessenger.request('webln.makeInvoice', normalizedArgs);
|
||||||
|
debug('webln.makeInvoice response:');
|
||||||
|
debug(result);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
signMessage(): Promise<{ message: string; signature: string }> {
|
||||||
|
throw new Error('signMessage is not supported - NWC does not provide node signing capabilities');
|
||||||
|
},
|
||||||
|
|
||||||
|
verifyMessage(): Promise<void> {
|
||||||
|
throw new Error('verifyMessage is not supported - NWC does not provide message verification');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
window.webln = webln as any;
|
||||||
|
|
||||||
|
// Dispatch webln:ready event to signal that webln is available
|
||||||
|
// This is dispatched on document as per the WebLN standard
|
||||||
|
document.dispatchEvent(new Event('webln:ready'));
|
||||||
|
|
||||||
const debug = function (value: any) {
|
const debug = function (value: any) {
|
||||||
console.log(JSON.stringify(value));
|
console.log(JSON.stringify(value));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import browser from 'webextension-polyfill';
|
import browser from 'webextension-polyfill';
|
||||||
import { Nip07Method } from '@common';
|
import { ExtensionMethod } from '@common';
|
||||||
import { PromptResponse, PromptResponseMessage } from './background-common';
|
import { PromptResponse, PromptResponseMessage } from './background-common';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,7 +14,7 @@ function base64ToUtf8(base64: string): string {
|
|||||||
|
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const id = params.get('id') as string;
|
const id = params.get('id') as string;
|
||||||
const method = params.get('method') as Nip07Method;
|
const method = params.get('method') as ExtensionMethod;
|
||||||
const host = params.get('host') as string;
|
const host = params.get('host') as string;
|
||||||
const nick = params.get('nick') as string;
|
const nick = params.get('nick') as string;
|
||||||
|
|
||||||
@@ -58,6 +58,26 @@ switch (method) {
|
|||||||
title = 'Get Relays';
|
title = 'Get Relays';
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'webln.enable':
|
||||||
|
title = 'Enable WebLN';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'webln.getInfo':
|
||||||
|
title = 'Wallet Info';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'webln.sendPayment':
|
||||||
|
title = 'Send Payment';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'webln.makeInvoice':
|
||||||
|
title = 'Create Invoice';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'webln.keysend':
|
||||||
|
title = 'Keysend Payment';
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -186,6 +206,65 @@ if (cardNip44DecryptElement && card2Nip44DecryptElement) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WebLN card visibility
|
||||||
|
const cardWeblnEnableElement = document.getElementById('cardWeblnEnable');
|
||||||
|
if (cardWeblnEnableElement) {
|
||||||
|
if (method !== 'webln.enable') {
|
||||||
|
cardWeblnEnableElement.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardWeblnGetInfoElement = document.getElementById('cardWeblnGetInfo');
|
||||||
|
if (cardWeblnGetInfoElement) {
|
||||||
|
if (method !== 'webln.getInfo') {
|
||||||
|
cardWeblnGetInfoElement.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardWeblnSendPaymentElement = document.getElementById('cardWeblnSendPayment');
|
||||||
|
const card2WeblnSendPaymentElement = document.getElementById('card2WeblnSendPayment');
|
||||||
|
if (cardWeblnSendPaymentElement && card2WeblnSendPaymentElement) {
|
||||||
|
if (method === 'webln.sendPayment') {
|
||||||
|
// Display amount in sats
|
||||||
|
const paymentAmountSpan = document.getElementById('paymentAmountSpan');
|
||||||
|
if (paymentAmountSpan && eventParsed.amountSats !== undefined) {
|
||||||
|
paymentAmountSpan.innerText = `${eventParsed.amountSats.toLocaleString()} sats`;
|
||||||
|
} else if (paymentAmountSpan) {
|
||||||
|
paymentAmountSpan.innerText = 'unknown amount';
|
||||||
|
}
|
||||||
|
// Show invoice in json card
|
||||||
|
const card2WeblnSendPayment_jsonElement = document.getElementById('card2WeblnSendPayment_json');
|
||||||
|
if (card2WeblnSendPayment_jsonElement && eventParsed.paymentRequest) {
|
||||||
|
card2WeblnSendPayment_jsonElement.innerText = eventParsed.paymentRequest;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cardWeblnSendPaymentElement.style.display = 'none';
|
||||||
|
card2WeblnSendPaymentElement.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardWeblnMakeInvoiceElement = document.getElementById('cardWeblnMakeInvoice');
|
||||||
|
if (cardWeblnMakeInvoiceElement) {
|
||||||
|
if (method === 'webln.makeInvoice') {
|
||||||
|
const invoiceAmountSpan = document.getElementById('invoiceAmountSpan');
|
||||||
|
if (invoiceAmountSpan) {
|
||||||
|
const amount = eventParsed.amount ?? eventParsed.defaultAmount;
|
||||||
|
if (amount) {
|
||||||
|
invoiceAmountSpan.innerText = ` for ${Number(amount).toLocaleString()} sats`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cardWeblnMakeInvoiceElement.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardWeblnKeysendElement = document.getElementById('cardWeblnKeysend');
|
||||||
|
if (cardWeblnKeysendElement) {
|
||||||
|
if (method !== 'webln.keysend') {
|
||||||
|
cardWeblnKeysendElement.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// Functions
|
// Functions
|
||||||
//
|
//
|
||||||
@@ -224,4 +303,21 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
approveAlwaysButton?.addEventListener('click', () => {
|
approveAlwaysButton?.addEventListener('click', () => {
|
||||||
deliver('approve');
|
deliver('approve');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const rejectAllButton = document.getElementById('rejectAllButton');
|
||||||
|
rejectAllButton?.addEventListener('click', () => {
|
||||||
|
deliver('reject-all');
|
||||||
|
});
|
||||||
|
|
||||||
|
const approveAllButton = document.getElementById('approveAllButton');
|
||||||
|
approveAllButton?.addEventListener('click', () => {
|
||||||
|
deliver('approve-all');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show/hide "All Queued" row based on queue size
|
||||||
|
const queueSize = parseInt(params.get('queueSize') || '0', 10);
|
||||||
|
const allQueuedRow = document.getElementById('allQueuedRow');
|
||||||
|
if (allQueuedRow && queueSize <= 1) {
|
||||||
|
allQueuedRow.style.display = 'none';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user