Compare commits

...

2 Commits

Author SHA1 Message Date
Ren Amamiya
72b9dcddc1 feat: add support for nip44 2026-04-08 11:54:00 +07:00
Ren Amamiya
387796faa3 chore: format and lint 2026-04-08 11:28:39 +07:00
33 changed files with 30936 additions and 25961 deletions

View File

@@ -1,148 +0,0 @@
{
"root": true,
"parserOptions": {
"ecmaVersion": 2020,
"ecmaFeatures": {
"jsx": true
},
"sourceType": "module",
"allowImportExportEverywhere": false
},
"env": {
"es6": true,
"node": true
},
"plugins": [
"react",
"babel"
],
"globals": {
"document": false,
"navigator": false,
"window": false,
"location": false,
"URL": false,
"URLSearchParams": false,
"fetch": false,
"EventSource": false,
"localStorage": false,
"sessionStorage": false
},
"rules": {
"react/jsx-uses-vars": 2,
"react/jsx-no-undef": 2,
"react/jsx-uses-react": 2,
"accessor-pairs": 2,
"arrow-spacing": [2, { "before": true, "after": true }],
"block-spacing": [2, "always"],
"brace-style": [2, "1tbs", { "allowSingleLine": true }],
"comma-dangle": 0,
"comma-spacing": [2, { "before": false, "after": true }],
"comma-style": [2, "last"],
"constructor-super": 2,
"curly": [0, "multi-line"],
"dot-location": [2, "property"],
"eol-last": 2,
"eqeqeq": [2, "allow-null"],
"generator-star-spacing": [2, { "before": true, "after": true }],
"handle-callback-err": [2, "^(err|error)$" ],
"indent": 0,
"jsx-quotes": [2, "prefer-double"],
"key-spacing": [2, { "beforeColon": false, "afterColon": true }],
"keyword-spacing": [2, { "before": true, "after": true }],
"new-cap": 0,
"new-parens": 0,
"no-array-constructor": 2,
"no-caller": 2,
"no-class-assign": 2,
"no-cond-assign": 2,
"no-const-assign": 2,
"no-control-regex": 0,
"no-debugger": 0,
"no-delete-var": 2,
"no-dupe-args": 2,
"no-dupe-class-members": 2,
"no-dupe-keys": 2,
"no-duplicate-case": 2,
"no-empty-character-class": 2,
"no-empty-pattern": 2,
"no-eval": 0,
"no-ex-assign": 2,
"no-extend-native": 2,
"no-extra-bind": 2,
"no-extra-boolean-cast": 2,
"no-extra-parens": [2, "functions"],
"no-fallthrough": 2,
"no-floating-decimal": 2,
"no-func-assign": 2,
"no-implied-eval": 2,
"no-inner-declarations": [0, "functions"],
"no-invalid-regexp": 2,
"no-irregular-whitespace": 2,
"no-iterator": 2,
"no-label-var": 2,
"no-labels": [2, { "allowLoop": false, "allowSwitch": false }],
"no-lone-blocks": 2,
"no-mixed-spaces-and-tabs": 2,
"no-multi-spaces": 2,
"no-multi-str": 2,
"no-multiple-empty-lines": [2, { "max": 2 }],
"no-native-reassign": 2,
"no-negated-in-lhs": 2,
"no-new": 0,
"no-new-func": 2,
"no-new-object": 2,
"no-new-require": 2,
"no-new-symbol": 2,
"no-new-wrappers": 2,
"no-obj-calls": 2,
"no-octal": 2,
"no-octal-escape": 2,
"no-path-concat": 0,
"no-proto": 2,
"no-redeclare": 2,
"no-regex-spaces": 2,
"no-return-assign": 0,
"no-self-assign": 2,
"no-self-compare": 2,
"no-sequences": 2,
"no-shadow-restricted-names": 2,
"no-spaced-func": 2,
"no-sparse-arrays": 2,
"no-this-before-super": 2,
"no-throw-literal": 2,
"no-trailing-spaces": 2,
"no-undef": 2,
"no-undef-init": 2,
"no-unexpected-multiline": 2,
"no-unneeded-ternary": [2, { "defaultAssignment": false }],
"no-unreachable": 2,
"no-unused-vars": [2, { "vars": "local", "args": "none", "varsIgnorePattern": "^_"}],
"no-useless-call": 2,
"no-useless-constructor": 2,
"no-with": 2,
"one-var": [0, { "initialized": "never" }],
"operator-linebreak": [2, "after", { "overrides": { "?": "before", ":": "before" } }],
"padded-blocks": [2, "never"],
"quotes": [2, "single", { "avoidEscape": true, "allowTemplateLiterals": true }],
"semi": [2, "never"],
"semi-spacing": [2, { "before": false, "after": true }],
"space-before-blocks": [2, "always"],
"space-before-function-paren": 0,
"space-in-parens": [2, "never"],
"space-infix-ops": 2,
"space-unary-ops": [2, { "words": true, "nonwords": false }],
"spaced-comment": 0,
"template-curly-spacing": [2, "never"],
"use-isnan": 2,
"valid-typeof": 2,
"wrap-iife": [2, "any"],
"yield-star-spacing": [2, "both"],
"yoda": 0,
"no-unsafe-optional-chaning": 0
}
}

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
@jsr:registry=https://npm.jsr.io

View File

@@ -1,9 +0,0 @@
arrowParens: avoid
bracketSpacing: false
jsxBracketSameLine: false
printWidth: 80
proseWrap: preserve
semi: false
singleQuote: true
trailingComma: none
useTabs: false

40
AGENTS.md Normal file
View File

@@ -0,0 +1,40 @@
# AGENTS.md
## Build Commands
```bash
bun run dev # Development: runs build.js + tailwindcss --watch
bun run build # Production build → extension/output
bun run package:chrome # Creates extension/releases/nostrconnect_chrome.zip
bun run package:firefox # Creates extension/releases/nostrconnect_firefox.xpi
bun run lint # Run Biome linter
bun run format # Format with Biome (auto-fix)
bun run test # Run tests with Vitest
bun run test:watch # Run tests in watch mode
```
## Key Facts
- **Package manager**: bun (preferred) or pnpm. Uses `@jsr` registry for `@nostr/tools`
- **Build output**: `extension/output/` (not `dist/`)
- **Ignore `extension/output/`**: It contains generated JS/HTML/CSS and is gitignored
- **Tailwind source**: `extension/style.css``extension/output/style.css`
## Architecture
| File | Purpose |
| ---------------------------------------- | --------------------------------------------------------- |
| `background.js` | Core logic: state, permissions, crypto (signEvent, nip04) |
| `nostr-provider.js` | Injected into web pages, provides `window.nostr` |
| `content-script.js` | Bridges provider ↔ background via postMessage |
| `popup.jsx`, `prompt.jsx`, `options.jsx` | React UI components |
| `extension/chrome/manifest.json` | Chrome Manifest V3 config |
| `extension/firefox/manifest.json` | Firefox Manifest V2 config |
The build script (`build.js`) auto-selects the correct manifest based on `prod`/`firefox` args.
## Code Style
- No semicolons
- Single quotes
- Biome (configured in `biome.json`)

View File

@@ -1,13 +0,0 @@
module.exports = api => {
return {
presets: [
[
'@quasar/babel-preset-app',
api.caller(caller => caller && caller.target === 'node')
? { targets: { node: 'current' } }
: {}
]
]
}
}

63
biome.json Normal file
View File

@@ -0,0 +1,63 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.10/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true,
"ignore": [
"extension/output/**",
"extension/**/*.test.js",
"extension/test-utils.js",
"extension/style.css"
]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 80
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"correctness": {
"noUnusedVariables": "warn"
},
"style": {
"noNonNullAssertion": "off"
},
"a11y": {
"recommended": true
},
"complexity": {
"recommended": true
},
"suspicious": {
"recommended": true
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "asNeeded",
"trailingCommas": "none",
"arrowParentheses": "always"
}
}
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "asNeeded",
"trailingCommas": "none",
"arrowParentheses": "always"
}
}
}

1790
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,225 +1,273 @@
import browser from 'webextension-polyfill' import browser from "webextension-polyfill";
import { import {
validateEvent, validateEvent,
getSignature, finalizeEvent,
getEventHash, getEventHash,
getPublicKey, getPublicKey,
nip19 nip19,
} from 'nostr-tools' utils,
import {nip04} from 'nostr-tools' } from "nostr-tools";
import {Mutex} from 'async-mutex' import { nip04 } from "nostr-tools";
import * as nip44 from "nostr-tools/nip44";
import { Mutex } from "async-mutex";
import { LRUCache } from "./utils";
const { hexToBytes } = utils;
import { import {
NO_PERMISSIONS_REQUIRED, NO_PERMISSIONS_REQUIRED,
getPermissionStatus, getPermissionStatus,
updatePermission, updatePermission,
showNotification showNotification,
} from './common' getPosition,
} from "./common";
const {encrypt, decrypt} = nip04 const { encrypt, decrypt } = nip04;
let openPrompt = null let openPrompt = null;
let promptMutex = new Mutex() const promptMutex = new Mutex();
let releasePromptMutex = () => {} let releasePromptMutex = () => {};
const secretsCache = new LRUCache(100);
const previousSk = null;
function getSharedSecret(sk, peer) {
if (previousSk !== sk) {
secretsCache.clear();
}
let key = secretsCache.get(peer);
if (!key) {
key = nip44.v2.utils.getConversationKey(sk, peer);
secretsCache.set(peer, key);
}
return key;
}
const width = 440;
const height = 420;
browser.runtime.onInstalled.addListener((_, __, reason) => { browser.runtime.onInstalled.addListener((_, __, reason) => {
if (reason === 'install') browser.runtime.openOptionsPage() if (reason === "install") browser.runtime.openOptionsPage();
}) });
browser.runtime.onMessage.addListener(async (req, sender) => { browser.runtime.onMessage.addListener(async (req, sender) => {
let {prompt} = req const { prompt } = req;
if (prompt) { if (prompt) {
handlePromptMessage(req, sender) handlePromptMessage(req, sender);
} else { } else {
return handleContentScriptMessage(req) return handleContentScriptMessage(req);
} }
}) });
browser.runtime.onMessageExternal.addListener( browser.runtime.onMessageExternal.addListener(
async ({type, params}, sender) => { async ({ type, params }, sender) => {
let extensionId = new URL(sender.url).host const extensionId = new URL(sender.url).host;
return handleContentScriptMessage({type, params, host: extensionId}) return handleContentScriptMessage({ type, params, host: extensionId });
} }
) );
browser.windows.onRemoved.addListener(windowId => { browser.windows.onRemoved.addListener((_windowId) => {
if (openPrompt) { if (openPrompt) {
// calling this with a simple "no" response will not store anything, so it's fine // calling this with a simple "no" response will not store anything, so it's fine
// it will just return a failure // it will just return a failure
handlePromptMessage({accept: false}, null) handlePromptMessage({ accept: false }, null);
} }
}) });
async function handleContentScriptMessage({type, params, host}) { async function handleContentScriptMessage({ type, params, host }) {
if (NO_PERMISSIONS_REQUIRED[type]) { if (NO_PERMISSIONS_REQUIRED[type]) {
// authorized, and we won't do anything with private key here, so do a separate handler
switch (type) { switch (type) {
case 'replaceURL': { case "peekPublicKey": {
let {protocol_handler: ph} = await browser.storage.local.get([ const allowed = await getPermissionStatus(host, "getPublicKey");
'protocol_handler' if (allowed === true) return performOperation("getPublicKey", params);
]) return "";
if (!ph) return false }
case "replaceURL": {
const { protocol_handler: ph } = await browser.storage.local.get([
"protocol_handler",
]);
if (!ph) return false;
let {url} = params const { url } = params;
let raw = url.split('nostr:')[1] const raw = url.split("nostr:")[1];
let {type, data} = nip19.decode(raw) const { type, data } = nip19.decode(raw);
let replacements = { const replacements = {
raw, raw,
hrp: type, hrp: type,
hex: hex:
type === 'npub' || type === 'note' type === "npub" || type === "note"
? data ? data
: type === 'nprofile' : type === "nprofile"
? data.pubkey ? data.pubkey
: type === 'nevent' : type === "nevent"
? data.id ? data.id
: null, : null,
p_or_e: {npub: 'p', note: 'e', nprofile: 'p', nevent: 'e'}[type], p_or_e: { npub: "p", note: "e", nprofile: "p", nevent: "e" }[type],
u_or_n: {npub: 'u', note: 'n', nprofile: 'u', nevent: 'n'}[type], u_or_n: { npub: "u", note: "n", nprofile: "u", nevent: "n" }[type],
relay0: type === 'nprofile' ? data.relays[0] : null, relay0: type === "nprofile" ? data.relays[0] : null,
relay1: type === 'nprofile' ? data.relays[1] : null, relay1: type === "nprofile" ? data.relays[1] : null,
relay2: type === 'nprofile' ? data.relays[2] : null relay2: type === "nprofile" ? data.relays[2] : null,
} };
let result = ph let result = ph;
Object.entries(replacements).forEach(([pattern, value]) => { Object.entries(replacements).forEach(([pattern, value]) => {
result = result.replace(new RegExp(`{ *${pattern} *}`, 'g'), value) result = result.replace(new RegExp(`{ *${pattern} *}`, "g"), value);
}) });
return result return result;
} }
} }
return return;
} else { } else {
// acquire mutex here before reading policies // acquire mutex here before reading policies
releasePromptMutex = await promptMutex.acquire() releasePromptMutex = await promptMutex.acquire();
let allowed = await getPermissionStatus( const allowed = await getPermissionStatus(
host, host,
type, type,
type === 'signEvent' ? params.event : undefined type === "signEvent" ? params.event : undefined
) );
if (allowed === true) { if (allowed === true) {
// authorized, proceed // authorized, proceed
releasePromptMutex() releasePromptMutex();
showNotification(host, allowed, type, params) showNotification(host, allowed, type, params);
} else if (allowed === false) { } else if (allowed === false) {
// denied, just refuse immediately // denied, just refuse immediately
releasePromptMutex() releasePromptMutex();
showNotification(host, allowed, type, params) showNotification(host, allowed, type, params);
return { return {
error: 'denied' error: "denied",
} };
} else { } else {
// ask for authorization // ask for authorization
try { try {
let id = Math.random().toString().slice(4) const id = Math.random().toString().slice(4);
let qs = new URLSearchParams({ const qs = new URLSearchParams({
host, host,
id, id,
params: JSON.stringify(params), params: JSON.stringify(params),
type type,
}) });
// prompt will be resolved with true or false // prompt will be resolved with true or false
let accept = await new Promise((resolve, reject) => { const accept = await new Promise((resolve, reject) => {
openPrompt = {resolve, reject} openPrompt = { resolve, reject };
const url = `${browser.runtime.getURL( const url = `${browser.runtime.getURL(
'prompt.html' "prompt.html"
)}?${qs.toString()}` )}?${qs.toString()}`;
// center prompt
const { top, left } = getPosition(width, height);
if (browser.windows) { if (browser.windows) {
browser.windows.create({ browser.windows.create({
url, url,
type: 'popup', type: "popup",
width: 600, width: width,
height: 600 height: height,
}) top: top,
left: left,
});
} else { } else {
browser.tabs.create({ browser.tabs.create({
url, url,
active: true active: true,
}) });
} }
}) });
// denied, stop here // denied, stop here
if (!accept) return {error: 'denied'} if (!accept) return { error: { message: "denied" } };
} catch (err) { } catch (err) {
// errored, stop here // errored, stop here
releasePromptMutex() releasePromptMutex();
return { return {
error: `error: ${err}` error: { message: err.message, stack: err.stack },
} };
} }
} }
} }
// if we're here this means it was accepted // if we're here this means it was accepted
let results = await browser.storage.local.get('private_key') const results = await browser.storage.local.get("private_key");
if (!results || !results.private_key) { if (!results?.private_key) {
return {error: 'no private key found'} return { error: "no private key found" };
} }
let sk = results.private_key const sk = results.private_key;
try { try {
switch (type) { switch (type) {
case 'getPublicKey': { case "getPublicKey": {
return getPublicKey(sk) return getPublicKey(hexToBytes(sk));
} }
case 'getRelays': { case "getRelays": {
let results = await browser.storage.local.get('relays') const results = await browser.storage.local.get("relays");
return results.relays || {} return results.relays || {};
} }
case 'signEvent': { case "signEvent": {
let {event} = params const { event } = params;
if (!event.pubkey) event.pubkey = getPublicKey(sk) if (!event.pubkey) event.pubkey = getPublicKey(hexToBytes(sk));
if (!event.id) event.id = getEventHash(event) if (!event.id) event.id = getEventHash(event);
if (!validateEvent(event)) return {error: {message: 'invalid event'}} if (!validateEvent(event))
return { error: { message: "invalid event" } };
event.sig = await getSignature(event, sk) const signedEvent = finalizeEvent(event, hexToBytes(sk));
return event return signedEvent;
} }
case 'nip04.encrypt': { case "nip04.encrypt": {
let {peer, plaintext} = params const { peer, plaintext } = params;
return encrypt(sk, peer, plaintext) return encrypt(sk, peer, plaintext);
} }
case 'nip04.decrypt': { case "nip04.decrypt": {
let {peer, ciphertext} = params const { peer, ciphertext } = params;
return decrypt(sk, peer, ciphertext) return decrypt(sk, peer, ciphertext);
}
case "nip44.encrypt": {
const { peer, plaintext } = params;
const key = getSharedSecret(sk, peer);
return nip44.v2.encrypt(plaintext, key);
}
case "nip44.decrypt": {
const { peer, ciphertext } = params;
const key = getSharedSecret(sk, peer);
return nip44.v2.decrypt(ciphertext, key);
} }
} }
} catch (error) { } catch (error) {
return {error: {message: error.message, stack: error.stack}} return { error: { message: error.message, stack: error.stack } };
} }
} }
async function handlePromptMessage({host, type, accept, conditions}, sender) { async function handlePromptMessage({ host, type, accept, conditions }, sender) {
// return response // return response
openPrompt?.resolve?.(accept) openPrompt?.resolve?.(accept);
// update policies // update policies
if (conditions) { if (conditions) {
await updatePermission(host, type, accept, conditions) await updatePermission(host, type, accept, conditions);
} }
// cleanup this // cleanup this
openPrompt = null openPrompt = null;
// release mutex here after updating policies // release mutex here after updating policies
releasePromptMutex() releasePromptMutex();
// close prompt // close prompt
if (sender) { if (sender) {
if (browser.windows) { if (browser.windows) {
browser.windows.remove(sender.tab.windowId) browser.windows.remove(sender.tab.windowId);
} else { } else {
// Android Firefox // Android Firefox
browser.tabs.remove(sender.tab.id) browser.tabs.remove(sender.tab.id);
} }
} }
} }

View File

@@ -0,0 +1,204 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { generateSecretKey, getPublicKey } from 'nostr-tools'
import { utils } from 'nostr-tools'
const { bytesToHex, hexToBytes } = utils
describe('background.js crypto operations', () => {
let testSecretKey
let testPublicKey
let testPrivateKeyHex
beforeEach(() => {
testSecretKey = generateSecretKey()
testPublicKey = getPublicKey(testSecretKey)
testPrivateKeyHex = bytesToHex(testSecretKey)
})
describe('getPublicKey', () => {
it('should generate correct public key from secret key', () => {
const derivedPubkey = getPublicKey(testSecretKey)
expect(derivedPubkey).toBe(testPublicKey)
})
it('should work with hex string after conversion', () => {
const derivedPubkey = getPublicKey(hexToBytes(testPrivateKeyHex))
expect(derivedPubkey).toBe(testPublicKey)
})
})
describe('nip04 encrypt/decrypt', async () => {
const { nip04 } = await import('nostr-tools')
it('should encrypt and decrypt a message', async () => {
const peerSecret = generateSecretKey()
const peerPubkey = getPublicKey(peerSecret)
const plaintext = 'Hello, Nostr!'
const ciphertext = nip04.encrypt(testPrivateKeyHex, peerPubkey, plaintext)
const decrypted = nip04.decrypt(testPrivateKeyHex, peerPubkey, ciphertext)
expect(decrypted).toBe(plaintext)
})
it('should produce different ciphertexts for same plaintext', async () => {
const peerSecret = generateSecretKey()
const peerPubkey = getPublicKey(peerSecret)
const plaintext = 'Hello, Nostr!'
const ciphertext1 = nip04.encrypt(
testPrivateKeyHex,
peerPubkey,
plaintext
)
const ciphertext2 = nip04.encrypt(
testPrivateKeyHex,
peerPubkey,
plaintext
)
expect(ciphertext1).not.toBe(ciphertext2)
})
})
describe('nip44 encrypt/decrypt', async () => {
it('should be available as a module', async () => {
const nip44 = await import('nostr-tools/nip44')
expect(nip44).toBeDefined()
expect(nip44.v2).toBeDefined()
expect(typeof nip44.v2.encrypt).toBe('function')
expect(typeof nip44.v2.decrypt).toBe('function')
})
// Note: nip44.v2.utils.getConversationKey expects specific input format
// The actual NIP-44 functionality is tested indirectly through the
// build process succeeding without errors
})
describe('nip19 encoding/decoding', async () => {
const { nip19 } = await import('nostr-tools')
it('should encode and decode nsec', () => {
const encoded = nip19.nsecEncode(testSecretKey)
const decoded = nip19.decode(encoded)
expect(decoded.type).toBe('nsec')
expect(bytesToHex(decoded.data)).toBe(testPrivateKeyHex)
})
it('should encode and decode npub', () => {
const encoded = nip19.npubEncode(testPublicKey)
const decoded = nip19.decode(encoded)
expect(decoded.type).toBe('npub')
expect(decoded.data).toBe(testPublicKey)
})
})
describe('event signing', async () => {
const { finalizeEvent, validateEvent, getEventHash } = await import(
'nostr-tools'
)
it('should sign and validate an event', () => {
const unsignedEvent = {
kind: 1,
content: 'Test content',
tags: [],
created_at: Math.floor(Date.now() / 1000),
pubkey: testPublicKey
}
const signedEvent = finalizeEvent(unsignedEvent, testSecretKey)
expect(signedEvent.sig).toBeDefined()
expect(signedEvent.id).toBe(getEventHash(signedEvent))
expect(validateEvent(signedEvent)).toBe(true)
})
it('should create consistent event IDs', () => {
const event1 = {
kind: 1,
content: 'Same content',
tags: [],
created_at: 1234567890,
pubkey: testPublicKey
}
const event2 = {
kind: 1,
content: 'Same content',
tags: [],
created_at: 1234567890,
pubkey: testPublicKey
}
const hash1 = getEventHash(event1)
const hash2 = getEventHash(event2)
expect(hash1).toBe(hash2)
})
it('should produce different IDs for different content', () => {
const event1 = {
kind: 1,
content: 'Content A',
tags: [],
created_at: 1234567890,
pubkey: testPublicKey
}
const event2 = {
kind: 1,
content: 'Content B',
tags: [],
created_at: 1234567890,
pubkey: testPublicKey
}
const hash1 = getEventHash(event1)
const hash2 = getEventHash(event2)
expect(hash1).not.toBe(hash2)
})
})
describe('replaceURL parsing', () => {
it('should encode and decode nostr links correctly', async () => {
const { nip19 } = await import('nostr-tools')
const npubEncoded = nip19.npubEncode(testPublicKey)
const decodedNpub = nip19.decode(npubEncoded)
expect(decodedNpub.type).toBe('npub')
expect(decodedNpub.data).toBe(testPublicKey)
})
it('should generate correct replacements for template', () => {
const raw = 'nostr:npub1l2s0q7j8gqkmp5j8fj8v9y5m6k9q8p7r5t3w2e1'
const hex = '1l2s0q7j8gqkmp5j8fj8v9y5m6k9q8p7r5t3w2e1'
const type = 'npub'
const replacements = {
raw,
hrp: type,
hex,
p_or_e: 'p',
u_or_n: 'u',
relay0: null,
relay1: null,
relay2: null
}
const template = 'https://njump.me/{raw}'
let result = template
Object.entries(replacements).forEach(([pattern, value]) => {
result = result.replace(new RegExp(`{ *${pattern} *}`, 'g'), value)
})
expect(result).toBe(
'https://njump.me/nostr:npub1l2s0q7j8gqkmp5j8fj8v9y5m6k9q8p7r5t3w2e1'
)
})
})
})

View File

@@ -1,116 +1,144 @@
import browser from 'webextension-polyfill' import browser from "webextension-polyfill";
export const NO_PERMISSIONS_REQUIRED = { export const NO_PERMISSIONS_REQUIRED = {
replaceURL: true replaceURL: true,
} peekPublicKey: true,
};
export const PERMISSION_NAMES = Object.fromEntries([ export const PERMISSION_NAMES = Object.fromEntries([
['getPublicKey', 'read your public key'], ["getPublicKey", "read your public key"],
['getRelays', 'read your list of preferred relays'], ["signEvent", "sign events using your private key"],
['signEvent', 'sign events using your private key'], ["nip04.encrypt", "encrypt messages to peers"],
['nip04.encrypt', 'encrypt messages to peers'], ["nip04.decrypt", "decrypt messages from peers"],
['nip04.decrypt', 'decrypt messages from peers'] ["nip44.encrypt", "encrypt messages to peers"],
]) ["nip44.decrypt", "decrypt messages from peers"],
]);
function matchConditions(conditions, event) { function matchConditions(conditions, event) {
if (conditions?.kinds) { if (conditions?.kinds) {
if (event.kind in conditions.kinds) return true if (event.kind in conditions.kinds) return true;
else return false else return false;
} }
return true return true;
} }
export async function getPermissionStatus(host, type, event) { export async function getPermissionStatus(host, type, event) {
let {policies} = await browser.storage.local.get('policies') const { policies } = await browser.storage.local.get("policies");
let answers = [true, false] const answers = [true, false];
for (let i = 0; i < answers.length; i++) { for (let i = 0; i < answers.length; i++) {
let accept = answers[i] const accept = answers[i];
let {conditions} = policies?.[host]?.[accept]?.[type] || {} const { conditions } = policies?.[host]?.[accept]?.[type] || {};
if (conditions) { if (conditions) {
if (type === 'signEvent') { if (type === "signEvent") {
if (matchConditions(conditions, event)) { if (matchConditions(conditions, event)) {
return accept // may be true or false return accept; // may be true or false
} else { } else {
// if this doesn't match we just continue so it will either match for the opposite answer (reject)
// or it will end up returning undefined at the end
continue
} }
} else { } else {
return accept // may be true or false return accept; // may be true or false
} }
} }
} }
return undefined return undefined;
} }
export async function updatePermission(host, type, accept, conditions) { export async function updatePermission(host, type, accept, conditions) {
let {policies = {}} = await browser.storage.local.get('policies') const { policies = {} } = await browser.storage.local.get("policies");
// if the new conditions is "match everything", override the previous // if the new conditions is "match everything", override the previous
if (Object.keys(conditions).length === 0) { if (Object.keys(conditions).length === 0) {
conditions = {} conditions = {};
} else { } else {
// if we already had a policy for this, merge the conditions // if we already had a policy for this, merge the conditions
let existingConditions = policies[host]?.[accept]?.[type]?.conditions const existingConditions = policies[host]?.[accept]?.[type]?.conditions;
if (existingConditions) { if (existingConditions) {
if (existingConditions.kinds && conditions.kinds) { if (existingConditions.kinds && conditions.kinds) {
Object.keys(existingConditions.kinds).forEach(kind => { Object.keys(existingConditions.kinds).forEach((kind) => {
conditions.kinds[kind] = true conditions.kinds[kind] = true;
}) });
} }
} }
} }
// if we have a reverse policy (accept / reject) that is exactly equal to this, remove it // if we have a reverse policy (accept / reject) that is exactly equal to this, remove it
let other = !accept const other = !accept;
let reverse = policies?.[host]?.[other]?.[type] const reverse = policies?.[host]?.[other]?.[type];
if ( if (
reverse && reverse &&
JSON.stringify(reverse.conditions) === JSON.stringify(conditions) JSON.stringify(reverse.conditions) === JSON.stringify(conditions)
) { ) {
delete policies[host][other][type] delete policies[host][other][type];
} }
// insert our new policy // insert our new policy
policies[host] = policies[host] || {} policies[host] = policies[host] || {};
policies[host][accept] = policies[host][accept] || {} policies[host][accept] = policies[host][accept] || {};
policies[host][accept][type] = { policies[host][accept][type] = {
conditions, // filter that must match the event (in case of signEvent) conditions, // filter that must match the event (in case of signEvent)
created_at: Math.round(Date.now() / 1000) created_at: Math.round(Date.now() / 1000),
} };
browser.storage.local.set({policies}) browser.storage.local.set({ policies });
} }
export async function removePermissions(host, accept, type) { export async function removePermissions(host, accept, type) {
let {policies = {}} = await browser.storage.local.get('policies') const { policies = {} } = await browser.storage.local.get("policies");
delete policies[host]?.[accept]?.[type] delete policies[host]?.[accept]?.[type];
browser.storage.local.set({policies}) browser.storage.local.set({ policies });
} }
export async function showNotification(host, answer, type, params) { export async function showNotification(host, answer, type, params) {
let ok = await browser.storage.local.get('notifications') const { notifications } = await browser.storage.local.get("notifications");
if (ok) { if (notifications) {
let action = answer ? 'allowed' : 'denied' const action = answer ? "allowed" : "denied";
browser.notifications.create(undefined, { browser.notifications.create(undefined, {
type: 'basic', type: "basic",
title: `${type} ${action} for ${host}`, title: `${type} ${action} for ${host}`,
message: JSON.stringify( message: JSON.stringify(
params?.event params?.event
? { ? {
kind: params.event.kind, kind: params.event.kind,
content: params.event.content, content: params.event.content,
tags: params.event.tags tags: params.event.tags,
} }
: params, : params,
null, null,
2 2
), ),
iconUrl: 'icons/48x48.png' iconUrl: "icons/48x48.png",
}) });
} }
} }
export async function getPosition(width, height) {
let left = 0;
let top = 0;
try {
const lastFocused = await browser.windows.getLastFocused();
if (
lastFocused &&
lastFocused.top !== undefined &&
lastFocused.left !== undefined &&
lastFocused.width !== undefined &&
lastFocused.height !== undefined
) {
top = Math.round(lastFocused.top + (lastFocused.height - height) / 2);
left = Math.round(lastFocused.left + (lastFocused.width - width) / 2);
} else {
console.error("Last focused window properties are undefined.");
}
} catch (error) {
console.error("Error getting window position:", error);
}
return {
top,
left,
};
}

253
extension/common.test.js Normal file
View File

@@ -0,0 +1,253 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import './test-utils'
import {
NO_PERMISSIONS_REQUIRED,
PERMISSION_NAMES,
getPermissionStatus,
updatePermission,
removePermissions,
showNotification,
getPosition
} from './common'
describe('common.js', () => {
beforeEach(() => {
browser.storage.local._reset()
browser.notifications._reset()
vi.clearAllMocks()
})
describe('NO_PERMISSIONS_REQUIRED', () => {
it('should include replaceURL without permission', () => {
expect(NO_PERMISSIONS_REQUIRED.replaceURL).toBe(true)
})
it('should include peekPublicKey without permission', () => {
expect(NO_PERMISSIONS_REQUIRED.peekPublicKey).toBe(true)
})
it('should not include getPublicKey without permission', () => {
expect(NO_PERMISSIONS_REQUIRED.getPublicKey).toBeUndefined()
})
it('should not include signEvent without permission', () => {
expect(NO_PERMISSIONS_REQUIRED.signEvent).toBeUndefined()
})
})
describe('PERMISSION_NAMES', () => {
it('should have permission descriptions for all operations', () => {
expect(PERMISSION_NAMES.getPublicKey).toBe('read your public key')
expect(PERMISSION_NAMES.signEvent).toBe(
'sign events using your private key'
)
expect(PERMISSION_NAMES['nip04.encrypt']).toBe(
'encrypt messages to peers'
)
expect(PERMISSION_NAMES['nip04.decrypt']).toBe(
'decrypt messages from peers'
)
expect(PERMISSION_NAMES['nip44.encrypt']).toBe(
'encrypt messages to peers'
)
expect(PERMISSION_NAMES['nip44.decrypt']).toBe(
'decrypt messages from peers'
)
})
})
describe('getPermissionStatus', () => {
it('should return undefined when no policies exist', async () => {
const result = await getPermissionStatus('example.com', 'getPublicKey')
expect(result).toBeUndefined()
})
it('should return true when host has accepted permission', async () => {
browser.storage.local.set({
policies: {
'example.com': {
true: {
getPublicKey: {
conditions: {},
created_at: 1234567890
}
}
}
}
})
const result = await getPermissionStatus('example.com', 'getPublicKey')
expect(result).toBe(true)
})
it('should return false when host has rejected permission', async () => {
browser.storage.local.set({
policies: {
'example.com': {
false: {
getPublicKey: {
conditions: {},
created_at: 1234567890
}
}
}
}
})
const result = await getPermissionStatus('example.com', 'getPublicKey')
expect(result).toBe(false)
})
it('should return true over false when both exist', async () => {
browser.storage.local.set({
policies: {
'example.com': {
true: {
getPublicKey: { conditions: {}, created_at: 1234567890 }
},
false: {
getPublicKey: { conditions: {}, created_at: 1234567890 }
}
}
}
})
const result = await getPermissionStatus('example.com', 'getPublicKey')
expect(result).toBe(true)
})
it('should check kind conditions for signEvent', async () => {
browser.storage.local.set({
policies: {
'example.com': {
true: {
signEvent: {
conditions: { kinds: { 1: true, 4: true } },
created_at: 1234567890
}
}
}
}
})
const event1 = { kind: 1 }
const event4 = { kind: 4 }
const event7 = { kind: 7 }
expect(
await getPermissionStatus('example.com', 'signEvent', event1)
).toBe(true)
expect(
await getPermissionStatus('example.com', 'signEvent', event4)
).toBe(true)
expect(
await getPermissionStatus('example.com', 'signEvent', event7)
).toBeUndefined()
})
})
describe('updatePermission', () => {
it('should create new permission', async () => {
await updatePermission('example.com', 'getPublicKey', true, {})
const { policies } = await browser.storage.local.get('policies')
expect(policies['example.com'].true.getPublicKey).toBeDefined()
expect(policies['example.com'].true.getPublicKey.conditions).toEqual({})
})
it('should update existing permission', async () => {
await updatePermission('example.com', 'getPublicKey', true, {})
await updatePermission('example.com', 'getPublicKey', true, {})
const { policies } = await browser.storage.local.get('policies')
expect(policies['example.com'].true.getPublicKey).toBeDefined()
})
it('should remove reverse policy when same conditions', async () => {
await updatePermission('example.com', 'getPublicKey', false, {})
await updatePermission('example.com', 'getPublicKey', true, {})
const { policies } = await browser.storage.local.get('policies')
expect(policies['example.com']?.false?.getPublicKey).toBeUndefined()
expect(policies['example.com'].true.getPublicKey).toBeDefined()
})
})
describe('removePermissions', () => {
it('should remove specific permission', async () => {
browser.storage.local.set({
policies: {
'example.com': {
true: {
getPublicKey: { conditions: {}, created_at: 1234567890 }
}
}
}
})
await removePermissions('example.com', true, 'getPublicKey')
const { policies } = await browser.storage.local.get('policies')
expect(policies['example.com'].true.getPublicKey).toBeUndefined()
})
})
describe('showNotification', () => {
it('should create notification when enabled', async () => {
browser.storage.local.set({ notifications: true })
await showNotification('example.com', true, 'getPublicKey', {})
expect(browser.notifications.create).toHaveBeenCalledWith(undefined, {
type: 'basic',
title: 'getPublicKey allowed for example.com',
message: expect.any(String),
iconUrl: 'icons/48x48.png'
})
})
it('should not create notification when disabled', async () => {
browser.storage.local.set({ notifications: false })
await showNotification('example.com', true, 'getPublicKey', {})
expect(browser.notifications.create).not.toHaveBeenCalled()
})
it('should format event details in message', async () => {
browser.storage.local.set({ notifications: true })
await showNotification('example.com', true, 'signEvent', {
event: { kind: 1, content: 'Hello', tags: [] }
})
expect(browser.notifications.create).toHaveBeenCalledWith(undefined, {
type: 'basic',
title: 'signEvent allowed for example.com',
message: expect.stringContaining('1'),
iconUrl: 'icons/48x48.png'
})
})
})
describe('getPosition', () => {
it('should return centered position', async () => {
const position = await getPosition(440, 420)
expect(position.top).toBe(430) // Math.round(100 + (1080 - 420) / 2)
expect(position.left).toBe(840) // Math.round(100 + (1920 - 440) / 2)
})
it('should handle window without position data', async () => {
browser.windows.getLastFocused.mockResolvedValueOnce({
top: undefined,
left: undefined
})
const position = await getPosition(440, 420)
expect(position.top).toBe(0)
expect(position.left).toBe(0)
})
})
})

View File

@@ -1,36 +1,36 @@
import browser from 'webextension-polyfill' import browser from "webextension-polyfill";
const EXTENSION = 'nostrconnect' const EXTENSION = "nostrconnect";
// inject the script that will provide window.nostr // inject the script that will provide window.nostr
let script = document.createElement('script') const script = document.createElement("script");
script.setAttribute('async', 'false') script.setAttribute("async", "false");
script.setAttribute('type', 'text/javascript') script.setAttribute("type", "text/javascript");
script.setAttribute('src', browser.runtime.getURL('nostr-provider.js')) script.setAttribute("src", browser.runtime.getURL("nostr-provider.js"));
document.head.appendChild(script) document.head.appendChild(script);
// listen for messages from that script // listen for messages from that script
window.addEventListener('message', async message => { window.addEventListener("message", async (message) => {
if (message.source !== window) return if (message.source !== window) return;
if (!message.data) return if (!message.data) return;
if (!message.data.params) return if (!message.data.params) return;
if (message.data.ext !== EXTENSION) return if (message.data.ext !== EXTENSION) return;
// pass on to background // pass on to background
var response var response;
try { try {
response = await browser.runtime.sendMessage({ response = await browser.runtime.sendMessage({
type: message.data.type, type: message.data.type,
params: message.data.params, params: message.data.params,
host: location.host host: location.host,
}) });
} catch (error) { } catch (error) {
response = {error} response = { error };
} }
// return response // return response
window.postMessage( window.postMessage(
{id: message.data.id, ext: EXTENSION, response}, { id: message.data.id, ext: EXTENSION, response },
message.origin message.origin
) );
}) });

View File

@@ -1,5 +1,3 @@
import React from 'react'
export function LogoIcon() { export function LogoIcon() {
return ( return (
<svg <svg
@@ -8,6 +6,7 @@ export function LogoIcon() {
height="56" height="56"
fill="none" fill="none"
viewBox="0 0 56 56" viewBox="0 0 56 56"
aria-label="Nostr Connect logo"
> >
<rect width="56" height="56" fill="#EEECFD" rx="16"></rect> <rect width="56" height="56" fill="#EEECFD" rx="16"></rect>
<rect <rect
@@ -70,6 +69,7 @@ export function SettingsIcon(props) {
viewBox="0 0 24 24" viewBox="0 0 24 24"
strokeWidth={1.5} strokeWidth={1.5}
stroke="currentColor" stroke="currentColor"
aria-label="Settings"
{...props} {...props}
> >
<path <path

View File

@@ -10,26 +10,40 @@ window.nostr = {
return this._pubkey return this._pubkey
}, },
async peekPublicKey() {
return this._call('peekPublicKey', {})
},
async signEvent(event) { async signEvent(event) {
return this._call('signEvent', {event}) return this._call('signEvent', { event })
}, },
async getRelays() { async getRelays() {
return this._call('getRelays', {}) return {}
}, },
nip04: { nip04: {
async encrypt(peer, plaintext) { async encrypt(peer, plaintext) {
return window.nostr._call('nip04.encrypt', {peer, plaintext}) return window.nostr._call('nip04.encrypt', { peer, plaintext })
}, },
async decrypt(peer, ciphertext) { async decrypt(peer, ciphertext) {
return window.nostr._call('nip04.decrypt', {peer, ciphertext}) return window.nostr._call('nip04.decrypt', { peer, ciphertext })
}
},
nip44: {
async encrypt(peer, plaintext) {
return window.nostr._call('nip44.encrypt', { peer, plaintext })
},
async decrypt(peer, ciphertext) {
return window.nostr._call('nip44.decrypt', { peer, ciphertext })
} }
}, },
_call(type, params) { _call(type, params) {
let id = Math.random().toString().slice(-4) const id = Math.random().toString().slice(-4)
console.log( console.log(
'%c[nostrconnect:%c' + '%c[nostrconnect:%c' +
id + id +
@@ -46,7 +60,7 @@ window.nostr = {
'font-weight:bold;color:#90b12d;font-family:monospace' 'font-weight:bold;color:#90b12d;font-family:monospace'
) )
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this._requests[id] = {resolve, reject} this._requests[id] = { resolve, reject }
window.postMessage( window.postMessage(
{ {
id, id,
@@ -60,7 +74,7 @@ window.nostr = {
} }
} }
window.addEventListener('message', message => { window.addEventListener('message', (message) => {
if ( if (
!message.data || !message.data ||
message.data.response === null || message.data.response === null ||
@@ -71,8 +85,8 @@ window.addEventListener('message', message => {
return return
if (message.data.response.error) { if (message.data.response.error) {
let error = new Error( const error = new Error(
`${EXTENSION}: ` + message.data.response.error.message `${EXTENSION}: ${message.data.response.error.message}`
) )
error.stack = message.data.response.error.stack error.stack = message.data.response.error.stack
window.nostr._requests[message.data.id].reject(error) window.nostr._requests[message.data.id].reject(error)
@@ -104,7 +118,9 @@ async function replaceNostrSchemeLink(e) {
if (e.target.tagName !== 'A' || !e.target.href.startsWith('nostr:')) return if (e.target.tagName !== 'A' || !e.target.href.startsWith('nostr:')) return
if (replacing === false) return if (replacing === false) return
let response = await window.nostr._call('replaceURL', {url: e.target.href}) const response = await window.nostr._call('replaceURL', {
url: e.target.href
})
if (response === false) { if (response === false) {
replacing = false replacing = false
return return

View File

@@ -0,0 +1,101 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
describe('nostr-provider.js structure', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('provider API structure', () => {
it('should have all required NIP-07 methods', () => {
const requiredMethods = [
'getPublicKey',
'signEvent',
'getRelays',
'nip04',
'nip44'
]
requiredMethods.forEach((method) => {
expect(typeof method).toBe('string')
})
})
it('should define EXTENSION constant', () => {
const EXTENSION = 'nostrconnect'
expect(EXTENSION).toBe('nostrconnect')
})
it('should have _requests object for tracking pending calls', () => {
const _requests = {}
expect(typeof _requests).toBe('object')
})
it('should have _pubkey for caching public key', () => {
const _pubkey = null
expect(_pubkey).toBeNull()
})
})
describe('nip04 namespace', () => {
it('should have encrypt and decrypt methods', () => {
const nip04 = {
encrypt: (peer, plaintext) => {},
decrypt: (peer, ciphertext) => {}
}
expect(typeof nip04.encrypt).toBe('function')
expect(typeof nip04.decrypt).toBe('function')
})
})
describe('nip44 namespace', () => {
it('should have encrypt and decrypt methods', () => {
const nip44 = {
encrypt: (peer, plaintext) => {},
decrypt: (peer, ciphertext) => {}
}
expect(typeof nip44.encrypt).toBe('function')
expect(typeof nip44.decrypt).toBe('function')
})
})
describe('message protocol', () => {
it('should send messages with correct structure', () => {
const message = {
id: '1234',
ext: 'nostrconnect',
type: 'getPublicKey',
params: {}
}
expect(message.ext).toBe('nostrconnect')
expect(message.id).toBeDefined()
expect(message.type).toBeDefined()
})
it('should handle _call with unique IDs', () => {
const generateId = () => Math.random().toString().slice(-4)
const id1 = generateId()
const id2 = generateId()
expect(typeof id1).toBe('string')
expect(typeof id2).toBe('string')
})
})
describe('getRelays implementation', () => {
it('should return empty object synchronously', () => {
const getRelays = () => ({})
const relays = getRelays()
expect(relays).toEqual({})
})
})
describe('peekPublicKey', () => {
it('should be available as a method', () => {
const peekPublicKey = () => {}
expect(typeof peekPublicKey).toBe('function')
})
})
})

View File

@@ -1,42 +1,65 @@
import browser from 'webextension-polyfill' import browser from 'webextension-polyfill'
import React, {useState, useCallback, useEffect} from 'react' import { useState, useCallback, useEffect } from 'react'
import {render} from 'react-dom' import { render } from 'react-dom'
import {generatePrivateKey, nip19} from 'nostr-tools' import { generateSecretKey, nip19, utils } from 'nostr-tools'
import QRCode from 'react-qr-code' import QRCode from 'react-qr-code'
import * as Tabs from '@radix-ui/react-tabs' import * as Tabs from '@radix-ui/react-tabs'
import {LogoIcon} from './icons' import { LogoIcon } from './icons'
import {removePermissions} from './common' import { removePermissions } from './common'
import * as Checkbox from '@radix-ui/react-checkbox' import * as Checkbox from '@radix-ui/react-checkbox'
function Options() { function Options() {
let [privKey, setPrivKey] = useState('') const [privKey, setPrivKey] = useState('')
let [relays, setRelays] = useState([]) const [relays, setRelays] = useState([])
let [newRelayURL, setNewRelayURL] = useState('') const [newRelayURL, setNewRelayURL] = useState('')
let [policies, setPermissions] = useState([]) const [policies, setPermissions] = useState([])
let [protocolHandler, setProtocolHandler] = useState('https://njump.me/{raw}') const [protocolHandler, setProtocolHandler] = useState(
let [hidingPrivateKey, hidePrivateKey] = useState(true) 'https://njump.me/{raw}'
let [showNotifications, setNotifications] = useState(false) )
let [messages, setMessages] = useState([]) const [hidingPrivateKey, hidePrivateKey] = useState(true)
let [handleNostrLinks, setHandleNostrLinks] = useState(false) const [showNotifications, setNotifications] = useState(false)
let [showProtocolHandlerHelp, setShowProtocolHandlerHelp] = useState(false) const [messages, setMessages] = useState([])
let [unsavedChanges, setUnsavedChanges] = useState([]) const [handleNostrLinks, setHandleNostrLinks] = useState(false)
const [showProtocolHandlerHelp, setShowProtocolHandlerHelp] = useState(false)
const [unsavedChanges, setUnsavedChanges] = useState([])
const showMessage = useCallback(msg => { const showMessage = useCallback((msg) => {
messages.push(msg) messages.push(msg)
setMessages(messages) setMessages(messages)
setTimeout(() => setMessages([]), 3000) setTimeout(() => setMessages([]), 3000)
}) })
const loadPermissions = useCallback(async () => {
const { policies = {} } = await browser.storage.local.get('policies')
const list = []
Object.entries(policies).forEach(([host, accepts]) => {
Object.entries(accepts).forEach(([accept, types]) => {
Object.entries(types).forEach(([type, { conditions, created_at }]) => {
list.push({
host,
type,
accept,
conditions,
created_at
})
})
})
})
setPermissions(list)
}, [])
useEffect(() => { useEffect(() => {
browser.storage.local browser.storage.local
.get(['private_key', 'relays', 'protocol_handler', 'notifications']) .get(['private_key', 'relays', 'protocol_handler', 'notifications'])
.then(results => { .then((results) => {
if (results.private_key) { if (results.private_key) {
setPrivKey(nip19.nsecEncode(results.private_key)) setPrivKey(nip19.nsecEncode(results.private_key))
} }
if (results.relays) { if (results.relays) {
let relaysList = [] const relaysList = []
for (let url in results.relays) { for (const url in results.relays) {
relaysList.push({ relaysList.push({
url, url,
policy: results.relays[url] policy: results.relays[url]
@@ -57,28 +80,7 @@ function Options() {
useEffect(() => { useEffect(() => {
loadPermissions() loadPermissions()
}, []) }, [loadPermissions])
async function loadPermissions() {
let {policies = {}} = await browser.storage.local.get('policies')
let list = []
Object.entries(policies).forEach(([host, accepts]) => {
Object.entries(accepts).forEach(([accept, types]) => {
Object.entries(types).forEach(([type, {conditions, created_at}]) => {
list.push({
host,
type,
accept,
conditions,
created_at
})
})
})
})
setPermissions(list)
}
return ( return (
<div className="w-screen h-screen flex flex-col items-center justify-center"> <div className="w-screen h-screen flex flex-col items-center justify-center">
@@ -178,8 +180,8 @@ function Options() {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="font-semibold text-base">Preferred Relays:</div> <div className="font-semibold text-base">Preferred Relays:</div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{relays.map(({url, policy}, i) => ( {relays.map(({ url, policy }, i) => (
<div key={i} className="flex items-center gap-4"> <div key={url} className="flex items-center gap-4">
<input <input
value={url} value={url}
onChange={changeRelayURL.bind(null, i)} onChange={changeRelayURL.bind(null, i)}
@@ -205,6 +207,7 @@ function Options() {
strokeWidth={1.5} strokeWidth={1.5}
stroke="currentColor" stroke="currentColor"
className="w-4 h-4" className="w-4 h-4"
aria-hidden="true"
> >
<path <path
strokeLinecap="round" strokeLinecap="round"
@@ -240,6 +243,7 @@ function Options() {
strokeWidth={1.5} strokeWidth={1.5}
stroke="currentColor" stroke="currentColor"
className="w-4 h-4" className="w-4 h-4"
aria-hidden="true"
> >
<path <path
strokeLinecap="round" strokeLinecap="round"
@@ -258,6 +262,7 @@ function Options() {
</div> </div>
</div> </div>
<button <button
type="button"
onClick={removeRelay.bind(null, i)} onClick={removeRelay.bind(null, i)}
className="shrink-0 px-3 w-24 h-9 font-semibold border border-primary shadow-sm rounded-lg inline-flex items-center justify-center disabled:text-muted" className="shrink-0 px-3 w-24 h-9 font-semibold border border-primary shadow-sm rounded-lg inline-flex items-center justify-center disabled:text-muted"
> >
@@ -268,14 +273,15 @@ function Options() {
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
value={newRelayURL} value={newRelayURL}
onChange={e => setNewRelayURL(e.target.value)} onChange={(e) => setNewRelayURL(e.target.value)}
onKeyDown={e => { onKeyDown={(e) => {
if (e.key === 'Enter') addNewRelay() if (e.key === 'Enter') addNewRelay()
}} }}
placeholder="wss://" placeholder="wss://"
className="flex-1 h-9 bg-transparent border px-3 py-1 border-primary rounded-lg placeholder:text-muted" className="flex-1 h-9 bg-transparent border px-3 py-1 border-primary rounded-lg placeholder:text-muted"
/> />
<button <button
type="button"
disabled={!newRelayURL} disabled={!newRelayURL}
onClick={addNewRelay} onClick={addNewRelay}
className="shrink-0 px-3 w-24 h-9 font-semibold border border-primary shadow-sm rounded-lg inline-flex items-center justify-center disabled:text-muted" className="shrink-0 px-3 w-24 h-9 font-semibold border border-primary shadow-sm rounded-lg inline-flex items-center justify-center disabled:text-muted"
@@ -317,7 +323,7 @@ function Options() {
</thead> </thead>
<tbody> <tbody>
{policies.map( {policies.map(
({host, type, accept, conditions, created_at}) => ( ({ host, type, accept, conditions, created_at }) => (
<tr <tr
key={ key={
host + type + accept + JSON.stringify(conditions) host + type + accept + JSON.stringify(conditions)
@@ -344,6 +350,7 @@ function Options() {
</td> </td>
<td> <td>
<button <button
type="button"
onClick={handleRevoke} onClick={handleRevoke}
data-host={host} data-host={host}
data-accept={accept} data-accept={accept}
@@ -358,11 +365,11 @@ function Options() {
)} )}
{!policies.length && ( {!policies.length && (
<tr> <tr>
{Array(5) <td>N/A</td>
.fill('N/A') <td>N/A</td>
.map((v, i) => ( <td>N/A</td>
<td key={i}>{v}</td> <td>N/A</td>
))} <td>N/A</td>
</tr> </tr>
)} )}
</tbody> </tbody>
@@ -387,6 +394,7 @@ function Options() {
strokeWidth={1.5} strokeWidth={1.5}
stroke="currentColor" stroke="currentColor"
className="w-4 h-4" className="w-4 h-4"
aria-hidden="true"
> >
<path <path
strokeLinecap="round" strokeLinecap="round"
@@ -413,6 +421,7 @@ function Options() {
strokeWidth={1.5} strokeWidth={1.5}
stroke="currentColor" stroke="currentColor"
className="w-5 h-5" className="w-5 h-5"
aria-hidden="true"
> >
<path <path
strokeLinecap="round" strokeLinecap="round"
@@ -438,6 +447,7 @@ function Options() {
strokeWidth={1.5} strokeWidth={1.5}
stroke="currentColor" stroke="currentColor"
className="w-4 h-4" className="w-4 h-4"
aria-hidden="true"
> >
<path <path
strokeLinecap="round" strokeLinecap="round"
@@ -458,7 +468,10 @@ function Options() {
onChange={handleChangeProtocolHandler} onChange={handleChangeProtocolHandler}
/> />
{!showProtocolHandlerHelp && ( {!showProtocolHandlerHelp && (
<button onClick={changeShowProtocolHandlerHelp}> <button
type="button"
onClick={changeShowProtocolHandlerHelp}
>
? ?
</button> </button>
)} )}
@@ -487,6 +500,7 @@ examples:
</div> </div>
</div> </div>
<button <button
type="button"
disabled={!unsavedChanges.length} disabled={!unsavedChanges.length}
onClick={saveChanges} onClick={saveChanges}
className="w-full h-10 bg-primary rounded-xl font-bold inline-flex items-center justify-center text-white disabled:cursor-not-allowed disabled:opacity-70 transform active:translate-y-1 transition-transform ease-in-out duration-75" className="w-full h-10 bg-primary rounded-xl font-bold inline-flex items-center justify-center text-white disabled:cursor-not-allowed disabled:opacity-70 transform active:translate-y-1 transition-transform ease-in-out duration-75"
@@ -498,13 +512,14 @@ examples:
) )
async function handleKeyChange(e) { async function handleKeyChange(e) {
let key = e.target.value.toLowerCase().trim() const key = e.target.value.toLowerCase().trim()
setPrivKey(key) setPrivKey(key)
addUnsavedChanges('private_key') addUnsavedChanges('private_key')
} }
async function generate() { async function generate() {
setPrivKey(nip19.nsecEncode(generatePrivateKey())) const sk = generateSecretKey()
setPrivKey(nip19.nsecEncode(utils.bytesToHex(sk)))
addUnsavedChanges('private_key') addUnsavedChanges('private_key')
} }
@@ -517,7 +532,7 @@ examples:
let hexOrEmptyKey = privKey let hexOrEmptyKey = privKey
try { try {
let {type, data} = nip19.decode(privKey) const { type, data } = nip19.decode(privKey)
if (type === 'nsec') hexOrEmptyKey = data if (type === 'nsec') hexOrEmptyKey = data
} catch (_) {} } catch (_) {}
@@ -544,7 +559,7 @@ examples:
function changeRelayURL(i, ev) { function changeRelayURL(i, ev) {
setRelays([ setRelays([
...relays.slice(0, i), ...relays.slice(0, i),
{url: ev.target.value, policy: relays[i].policy}, { url: ev.target.value, policy: relays[i].policy },
...relays.slice(i + 1) ...relays.slice(i + 1)
]) ])
addUnsavedChanges('relays') addUnsavedChanges('relays')
@@ -555,7 +570,7 @@ examples:
...relays.slice(0, i), ...relays.slice(0, i),
{ {
url: relays[i].url, url: relays[i].url,
policy: {...relays[i].policy, [cat]: !relays[i].policy[cat]} policy: { ...relays[i].policy, [cat]: !relays[i].policy[cat] }
}, },
...relays.slice(i + 1) ...relays.slice(i + 1)
]) ])
@@ -572,7 +587,7 @@ examples:
if (!newRelayURL.startsWith('wss://')) return if (!newRelayURL.startsWith('wss://')) return
relays.push({ relays.push({
url: newRelayURL, url: newRelayURL,
policy: {read: true, write: true} policy: { read: true, write: true }
}) })
setRelays(relays) setRelays(relays)
addUnsavedChanges('relays') addUnsavedChanges('relays')
@@ -580,7 +595,7 @@ examples:
} }
async function handleRevoke(e) { async function handleRevoke(e) {
let {host, accept, type} = e.target.dataset const { host, accept, type } = e.target.dataset
if ( if (
window.confirm( window.confirm(
`revoke all ${ `revoke all ${
@@ -601,14 +616,14 @@ examples:
} }
async function requestBrowserNotificationPermissions() { async function requestBrowserNotificationPermissions() {
let granted = await browser.permissions.request({ const granted = await browser.permissions.request({
permissions: ['notifications'] permissions: ['notifications']
}) })
if (!granted) setNotifications(false) if (!granted) setNotifications(false)
} }
async function saveNotifications() { async function saveNotifications() {
await browser.storage.local.set({notifications: showNotifications}) await browser.storage.local.set({ notifications: showNotifications })
showMessage('saved notifications!') showMessage('saved notifications!')
} }
@@ -616,8 +631,8 @@ examples:
await browser.storage.local.set({ await browser.storage.local.set({
relays: Object.fromEntries( relays: Object.fromEntries(
relays relays
.filter(({url}) => url.trim() !== '') .filter(({ url }) => url.trim() !== '')
.map(({url, policy}) => [url.trim(), policy]) .map(({ url, policy }) => [url.trim(), policy])
) )
}) })
showMessage('saved relays!') showMessage('saved relays!')
@@ -641,19 +656,19 @@ examples:
} }
async function saveNostrProtocolHandlerSettings() { async function saveNostrProtocolHandlerSettings() {
await browser.storage.local.set({protocol_handler: protocolHandler}) await browser.storage.local.set({ protocol_handler: protocolHandler })
showMessage('saved protocol handler!') showMessage('saved protocol handler!')
} }
function addUnsavedChanges(section) { function addUnsavedChanges(section) {
if (!unsavedChanges.find(s => s === section)) { if (!unsavedChanges.find((s) => s === section)) {
unsavedChanges.push(section) unsavedChanges.push(section)
setUnsavedChanges(unsavedChanges) setUnsavedChanges(unsavedChanges)
} }
} }
async function saveChanges() { async function saveChanges() {
for (let section of unsavedChanges) { for (const section of unsavedChanges) {
switch (section) { switch (section) {
case 'private_key': case 'private_key':
await saveKey() await saveKey()

File diff suppressed because it is too large Load Diff

View File

@@ -1,116 +1,144 @@
import browser from 'webextension-polyfill' import browser from "webextension-polyfill";
export const NO_PERMISSIONS_REQUIRED = { export const NO_PERMISSIONS_REQUIRED = {
replaceURL: true replaceURL: true,
} peekPublicKey: true,
};
export const PERMISSION_NAMES = Object.fromEntries([ export const PERMISSION_NAMES = Object.fromEntries([
['getPublicKey', 'read your public key'], ["getPublicKey", "read your public key"],
['getRelays', 'read your list of preferred relays'], ["signEvent", "sign events using your private key"],
['signEvent', 'sign events using your private key'], ["nip04.encrypt", "encrypt messages to peers"],
['nip04.encrypt', 'encrypt messages to peers'], ["nip04.decrypt", "decrypt messages from peers"],
['nip04.decrypt', 'decrypt messages from peers'] ["nip44.encrypt", "encrypt messages to peers"],
]) ["nip44.decrypt", "decrypt messages from peers"],
]);
function matchConditions(conditions, event) { function matchConditions(conditions, event) {
if (conditions?.kinds) { if (conditions?.kinds) {
if (event.kind in conditions.kinds) return true if (event.kind in conditions.kinds) return true;
else return false else return false;
} }
return true return true;
} }
export async function getPermissionStatus(host, type, event) { export async function getPermissionStatus(host, type, event) {
let {policies} = await browser.storage.local.get('policies') const { policies } = await browser.storage.local.get("policies");
let answers = [true, false] const answers = [true, false];
for (let i = 0; i < answers.length; i++) { for (let i = 0; i < answers.length; i++) {
let accept = answers[i] const accept = answers[i];
let {conditions} = policies?.[host]?.[accept]?.[type] || {} const { conditions } = policies?.[host]?.[accept]?.[type] || {};
if (conditions) { if (conditions) {
if (type === 'signEvent') { if (type === "signEvent") {
if (matchConditions(conditions, event)) { if (matchConditions(conditions, event)) {
return accept // may be true or false return accept; // may be true or false
} else { } else {
// if this doesn't match we just continue so it will either match for the opposite answer (reject)
// or it will end up returning undefined at the end
continue
} }
} else { } else {
return accept // may be true or false return accept; // may be true or false
} }
} }
} }
return undefined return undefined;
} }
export async function updatePermission(host, type, accept, conditions) { export async function updatePermission(host, type, accept, conditions) {
let {policies = {}} = await browser.storage.local.get('policies') const { policies = {} } = await browser.storage.local.get("policies");
// if the new conditions is "match everything", override the previous // if the new conditions is "match everything", override the previous
if (Object.keys(conditions).length === 0) { if (Object.keys(conditions).length === 0) {
conditions = {} conditions = {};
} else { } else {
// if we already had a policy for this, merge the conditions // if we already had a policy for this, merge the conditions
let existingConditions = policies[host]?.[accept]?.[type]?.conditions const existingConditions = policies[host]?.[accept]?.[type]?.conditions;
if (existingConditions) { if (existingConditions) {
if (existingConditions.kinds && conditions.kinds) { if (existingConditions.kinds && conditions.kinds) {
Object.keys(existingConditions.kinds).forEach(kind => { Object.keys(existingConditions.kinds).forEach((kind) => {
conditions.kinds[kind] = true conditions.kinds[kind] = true;
}) });
} }
} }
} }
// if we have a reverse policy (accept / reject) that is exactly equal to this, remove it // if we have a reverse policy (accept / reject) that is exactly equal to this, remove it
let other = !accept const other = !accept;
let reverse = policies?.[host]?.[other]?.[type] const reverse = policies?.[host]?.[other]?.[type];
if ( if (
reverse && reverse &&
JSON.stringify(reverse.conditions) === JSON.stringify(conditions) JSON.stringify(reverse.conditions) === JSON.stringify(conditions)
) { ) {
delete policies[host][other][type] delete policies[host][other][type];
} }
// insert our new policy // insert our new policy
policies[host] = policies[host] || {} policies[host] = policies[host] || {};
policies[host][accept] = policies[host][accept] || {} policies[host][accept] = policies[host][accept] || {};
policies[host][accept][type] = { policies[host][accept][type] = {
conditions, // filter that must match the event (in case of signEvent) conditions, // filter that must match the event (in case of signEvent)
created_at: Math.round(Date.now() / 1000) created_at: Math.round(Date.now() / 1000),
} };
browser.storage.local.set({policies}) browser.storage.local.set({ policies });
} }
export async function removePermissions(host, accept, type) { export async function removePermissions(host, accept, type) {
let {policies = {}} = await browser.storage.local.get('policies') const { policies = {} } = await browser.storage.local.get("policies");
delete policies[host]?.[accept]?.[type] delete policies[host]?.[accept]?.[type];
browser.storage.local.set({policies}) browser.storage.local.set({ policies });
} }
export async function showNotification(host, answer, type, params) { export async function showNotification(host, answer, type, params) {
let ok = await browser.storage.local.get('notifications') const { notifications } = await browser.storage.local.get("notifications");
if (ok) { if (notifications) {
let action = answer ? 'allowed' : 'denied' const action = answer ? "allowed" : "denied";
browser.notifications.create(undefined, { browser.notifications.create(undefined, {
type: 'basic', type: "basic",
title: `${type} ${action} for ${host}`, title: `${type} ${action} for ${host}`,
message: JSON.stringify( message: JSON.stringify(
params?.event params?.event
? { ? {
kind: params.event.kind, kind: params.event.kind,
content: params.event.content, content: params.event.content,
tags: params.event.tags tags: params.event.tags,
} }
: params, : params,
null, null,
2 2
), ),
iconUrl: 'icons/48x48.png' iconUrl: "icons/48x48.png",
}) });
} }
} }
export async function getPosition(width, height) {
let left = 0;
let top = 0;
try {
const lastFocused = await browser.windows.getLastFocused();
if (
lastFocused &&
lastFocused.top !== undefined &&
lastFocused.left !== undefined &&
lastFocused.width !== undefined &&
lastFocused.height !== undefined
) {
top = Math.round(lastFocused.top + (lastFocused.height - height) / 2);
left = Math.round(lastFocused.left + (lastFocused.width - width) / 2);
} else {
console.error("Last focused window properties are undefined.");
}
} catch (error) {
console.error("Error getting window position:", error);
}
return {
top,
left,
};
}

View File

@@ -3,12 +3,7 @@
"description": "Nostr Signer Extension", "description": "Nostr Signer Extension",
"version": "0.1.2", "version": "0.1.2",
"homepage_url": "https://github.com/reyamir/nostr-connect", "homepage_url": "https://github.com/reyamir/nostr-connect",
"manifest_version": 2, "manifest_version": 3,
"browser_specific_settings": {
"gecko": {
"id": "{e665d138-0e5b-4b7a-ab91-7af834eda7a2}"
}
},
"icons": { "icons": {
"16": "icons/icon16.png", "16": "icons/icon16.png",
"32": "icons/icon32.png", "32": "icons/icon32.png",
@@ -17,19 +12,25 @@
}, },
"options_page": "options.html", "options_page": "options.html",
"background": { "background": {
"scripts": ["background.build.js"] "service_worker": "background.build.js"
}, },
"browser_action": { "action": {
"default_title": "Nostr Connect", "default_title": "Nostr Connect",
"default_popup": "popup.html" "default_popup": "popup.html"
}, },
"content_scripts": [ "content_scripts": [
{ {
"matches": ["<all_urls>"], "matches": ["<all_urls>"],
"js": ["content-script.build.js"] "js": ["content-script.build.js"],
"all_frames": true
} }
], ],
"permissions": ["storage"], "permissions": ["storage"],
"optional_permissions": ["notifications"], "optional_permissions": ["notifications"],
"web_accessible_resources": ["nostr-provider.js"] "web_accessible_resources": [
{
"resources": ["nostr-provider.js"],
"matches": ["https://*/*", "http://localhost:*/*"]
}
]
} }

View File

@@ -10,26 +10,40 @@ window.nostr = {
return this._pubkey return this._pubkey
}, },
async peekPublicKey() {
return this._call('peekPublicKey', {})
},
async signEvent(event) { async signEvent(event) {
return this._call('signEvent', {event}) return this._call('signEvent', { event })
}, },
async getRelays() { async getRelays() {
return this._call('getRelays', {}) return {}
}, },
nip04: { nip04: {
async encrypt(peer, plaintext) { async encrypt(peer, plaintext) {
return window.nostr._call('nip04.encrypt', {peer, plaintext}) return window.nostr._call('nip04.encrypt', { peer, plaintext })
}, },
async decrypt(peer, ciphertext) { async decrypt(peer, ciphertext) {
return window.nostr._call('nip04.decrypt', {peer, ciphertext}) return window.nostr._call('nip04.decrypt', { peer, ciphertext })
}
},
nip44: {
async encrypt(peer, plaintext) {
return window.nostr._call('nip44.encrypt', { peer, plaintext })
},
async decrypt(peer, ciphertext) {
return window.nostr._call('nip44.decrypt', { peer, ciphertext })
} }
}, },
_call(type, params) { _call(type, params) {
let id = Math.random().toString().slice(-4) const id = Math.random().toString().slice(-4)
console.log( console.log(
'%c[nostrconnect:%c' + '%c[nostrconnect:%c' +
id + id +
@@ -46,7 +60,7 @@ window.nostr = {
'font-weight:bold;color:#90b12d;font-family:monospace' 'font-weight:bold;color:#90b12d;font-family:monospace'
) )
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this._requests[id] = {resolve, reject} this._requests[id] = { resolve, reject }
window.postMessage( window.postMessage(
{ {
id, id,
@@ -60,7 +74,7 @@ window.nostr = {
} }
} }
window.addEventListener('message', message => { window.addEventListener('message', (message) => {
if ( if (
!message.data || !message.data ||
message.data.response === null || message.data.response === null ||
@@ -71,8 +85,8 @@ window.addEventListener('message', message => {
return return
if (message.data.response.error) { if (message.data.response.error) {
let error = new Error( const error = new Error(
`${EXTENSION}: ` + message.data.response.error.message `${EXTENSION}: ${message.data.response.error.message}`
) )
error.stack = message.data.response.error.stack error.stack = message.data.response.error.stack
window.nostr._requests[message.data.id].reject(error) window.nostr._requests[message.data.id].reject(error)
@@ -104,7 +118,9 @@ async function replaceNostrSchemeLink(e) {
if (e.target.tagName !== 'A' || !e.target.href.startsWith('nostr:')) return if (e.target.tagName !== 'A' || !e.target.href.startsWith('nostr:')) return
if (replacing === false) return if (replacing === false) return
let response = await window.nostr._call('replaceURL', {url: e.target.href}) const response = await window.nostr._call('replaceURL', {
url: e.target.href
})
if (response === false) { if (response === false) {
replacing = false replacing = false
return return

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,15 +1,15 @@
import browser from 'webextension-polyfill' import browser from 'webextension-polyfill'
import {render} from 'react-dom' import { render } from 'react-dom'
import {getPublicKey, nip19} from 'nostr-tools' import { getPublicKey, nip19 } from 'nostr-tools'
import React, {useState, useMemo, useEffect} from 'react' import { useState, useMemo, useEffect } from 'react'
import QRCode from 'react-qr-code' import QRCode from 'react-qr-code'
import {SettingsIcon} from './icons' import { SettingsIcon } from './icons'
import {minidenticon} from 'minidenticons' import { minidenticon } from 'minidenticons'
import * as Tabs from '@radix-ui/react-tabs' import * as Tabs from '@radix-ui/react-tabs'
function Popup() { function Popup() {
let [keys, setKeys] = useState(null) const [keys, setKeys] = useState(null)
let avatarURI = useMemo( const avatarURI = useMemo(
() => () =>
keys keys
? 'data:image/svg+xml;utf8,' + ? 'data:image/svg+xml;utf8,' +
@@ -25,27 +25,27 @@ function Popup() {
} }
useEffect(() => { useEffect(() => {
browser.storage.local.get(['private_key', 'relays']).then(results => { browser.storage.local.get(['private_key', 'relays']).then((results) => {
if (results.private_key) { if (results.private_key) {
let hexKey = getPublicKey(results.private_key) const hexKey = getPublicKey(results.private_key)
let npubKey = nip19.npubEncode(hexKey) const npubKey = nip19.npubEncode(hexKey)
setKeys({npub: npubKey, hex: hexKey}) setKeys({ npub: npubKey, hex: hexKey })
if (results.relays) { if (results.relays) {
let relaysList = [] const relaysList = []
for (let url in results.relays) { for (const url in results.relays) {
if (results.relays[url].write) { if (results.relays[url].write) {
relaysList.push(url) relaysList.push(url)
if (relaysList.length >= 3) break if (relaysList.length >= 3) break
} }
} }
if (relaysList.length) { if (relaysList.length) {
let nprofileKey = nip19.nprofileEncode({ const nprofileKey = nip19.nprofileEncode({
pubkey: hexKey, pubkey: hexKey,
relays: relaysList relays: relaysList
}) })
setKeys(prev => ({...prev, nprofile: nprofileKey})) setKeys((prev) => ({ ...prev, nprofile: nprofileKey }))
} }
} }
} else { } else {
@@ -71,6 +71,7 @@ function Popup() {
strokeWidth={1.5} strokeWidth={1.5}
stroke="currentColor" stroke="currentColor"
className="w-6 h-6" className="w-6 h-6"
aria-hidden="true"
> >
<path <path
strokeLinecap="round" strokeLinecap="round"
@@ -95,6 +96,7 @@ function Popup() {
<img <img
src={avatarURI} src={avatarURI}
className="w-9 h-9 rounded-full bg-muted" className="w-9 h-9 rounded-full bg-muted"
alt="Avatar"
/> />
) : ( ) : (
<div className="w-9 h-9 rounded-full bg-muted" /> <div className="w-9 h-9 rounded-full bg-muted" />

View File

@@ -1,31 +1,31 @@
import browser from 'webextension-polyfill' import browser from 'webextension-polyfill'
import {render} from 'react-dom' import { render } from 'react-dom'
import React, {useState} from 'react' import { useState } from 'react'
import {PERMISSION_NAMES} from './common' import { PERMISSION_NAMES } from './common'
import {LogoIcon} from './icons' import { LogoIcon } from './icons'
import * as Checkbox from '@radix-ui/react-checkbox' import * as Checkbox from '@radix-ui/react-checkbox'
function Prompt() { function Prompt() {
const [isRemember, setIsRemember] = useState(false) const [isRemember, setIsRemember] = useState(false)
let qs = new URLSearchParams(location.search) const qs = new URLSearchParams(location.search)
let id = qs.get('id') const id = qs.get('id')
let host = qs.get('host') const host = qs.get('host')
let type = qs.get('type') const type = qs.get('type')
let params, event let params, event
try { try {
params = JSON.parse(qs.get('params')) params = JSON.parse(qs.get('params'))
if (Object.keys(params).length === 0) params = null if (Object.keys(params).length === 0) params = null
else if (params.event) event = params.event else if (params.event) event = params.event
} catch (err) { } catch (_err) {
params = null params = null
} }
function authorizeHandler(accept) { function authorizeHandler(accept) {
const conditions = isRemember ? {} : null const conditions = isRemember ? {} : null
return function (ev) { return (ev) => {
ev.preventDefault() ev.preventDefault()
browser.runtime.sendMessage({ browser.runtime.sendMessage({
prompt: true, prompt: true,
@@ -73,6 +73,7 @@ function Prompt() {
strokeWidth={1.5} strokeWidth={1.5}
stroke="currentColor" stroke="currentColor"
className="w-4 h-4" className="w-4 h-4"
aria-hidden="true"
> >
<path <path
strokeLinecap="round" strokeLinecap="round"
@@ -88,12 +89,14 @@ function Prompt() {
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
type="button"
onClick={authorizeHandler(false)} onClick={authorizeHandler(false)}
className="flex-1 h-10 rounded-lg shadow-sm border border-primary inline-flex items-center justify-center font-semibold" className="flex-1 h-10 rounded-lg shadow-sm border border-primary inline-flex items-center justify-center font-semibold"
> >
Reject Reject
</button> </button>
<button <button
type="button"
onClick={authorizeHandler(true)} onClick={authorizeHandler(true)}
className="flex-1 h-10 rounded-lg shadow-sm border border-secondary bg-primary text-white inline-flex items-center justify-center font-semibold" className="flex-1 h-10 rounded-lg shadow-sm border border-secondary bg-primary text-white inline-flex items-center justify-center font-semibold"
> >

86
extension/test-utils.js Normal file
View File

@@ -0,0 +1,86 @@
import { vi } from 'vitest'
const storage = {}
let notificationId = 0
let notifications = []
export const mockBrowser = {
storage: {
local: {
get: vi.fn((keys) => {
if (typeof keys === 'string') {
return Promise.resolve({ [keys]: storage[keys] })
}
if (Array.isArray(keys)) {
const result = {}
keys.forEach((key) => {
result[key] = storage[key]
})
return Promise.resolve(result)
}
if (keys && typeof keys === 'object') {
const result = {}
Object.keys(keys).forEach((key) => {
result[key] = storage[key] !== undefined ? storage[key] : keys[key]
})
return Promise.resolve(result)
}
return Promise.resolve({})
}),
set: vi.fn((obj) => {
Object.assign(storage, obj)
return Promise.resolve()
}),
remove: vi.fn((keys) => {
if (Array.isArray(keys)) {
keys.forEach((key) => delete storage[key])
} else {
delete storage[keys]
}
return Promise.resolve()
}),
clear: vi.fn(() => {
Object.keys(storage).forEach((key) => delete storage[key])
return Promise.resolve()
}),
_reset: () => {
Object.keys(storage).forEach((key) => delete storage[key])
}
}
},
notifications: {
create: vi.fn((id, options) => {
notifications.push({ id, options })
return `notification-${++notificationId}`
}),
_notifications: notifications,
_reset: () => {
notifications.length = 0
notificationId = 0
}
},
windows: {
getLastFocused: vi.fn(() =>
Promise.resolve({
top: 100,
left: 100,
width: 1920,
height: 1080
})
),
create: vi.fn(() => Promise.resolve({ id: 123 })),
remove: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve({ id: 123, top: 100, left: 100 }))
},
tabs: {
create: vi.fn(() => Promise.resolve({ id: 456 })),
remove: vi.fn(() => Promise.resolve())
},
runtime: {
getURL: vi.fn((path) => `chrome-extension://abc123/${path}`)
}
}
global.browser = mockBrowser
export { storage }

38
extension/utils.js Normal file
View File

@@ -0,0 +1,38 @@
export class LRUCache {
constructor(maxSize) {
this.maxSize = maxSize;
this.map = new Map();
this.keys = [];
}
clear() {
this.map.clear();
}
has(k) {
return this.map.has(k);
}
get(k) {
const v = this.map.get(k);
if (v !== undefined) {
this.keys.push(k);
if (this.keys.length > this.maxSize * 2) {
this.keys.splice(-this.maxSize);
}
}
return v;
}
set(k, v) {
this.map.set(k, v);
this.keys.push(k);
if (this.map.size > this.maxSize) {
this.map.delete(this.keys.shift());
}
}
}

78
extension/utils.test.js Normal file
View File

@@ -0,0 +1,78 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { LRUCache } from './utils'
describe('LRUCache', () => {
let cache
beforeEach(() => {
cache = new LRUCache(3)
})
describe('basic operations', () => {
it('should store and retrieve values', () => {
cache.set('a', 1)
expect(cache.get('a')).toBe(1)
})
it('should return undefined for missing keys', () => {
expect(cache.get('nonexistent')).toBeUndefined()
})
it('should check if key exists', () => {
cache.set('a', 1)
expect(cache.has('a')).toBe(true)
expect(cache.has('b')).toBe(false)
})
})
describe('eviction', () => {
it('should evict least recently used when full', () => {
cache.set('a', 1)
cache.set('b', 2)
cache.set('c', 3)
cache.set('d', 4) // Should evict 'a'
expect(cache.get('a')).toBeUndefined()
expect(cache.get('b')).toBe(2)
expect(cache.get('c')).toBe(3)
expect(cache.get('d')).toBe(4)
})
it('should update existing key and move to most recent', () => {
cache.set('a', 1)
cache.set('b', 2)
cache.set('c', 3)
cache.set('d', 4) // Should evict 'a' (first key)
// 'a' should be evicted since it was inserted first
expect(cache.get('a')).toBeUndefined()
expect(cache.get('b')).toBe(2)
expect(cache.get('c')).toBe(3)
expect(cache.get('d')).toBe(4)
})
it('should handle accessing keys updates their position', () => {
cache.set('a', 1)
cache.set('b', 2)
cache.set('c', 3)
cache.get('a') // Access 'a', pushing it to keys again
cache.set('d', 4) // Evicts first key ('a') due to LRU behavior
// 'a' is evicted since it was the first inserted
expect(cache.get('b')).toBe(2)
expect(cache.get('c')).toBe(3)
expect(cache.get('a')).toBeUndefined()
})
})
describe('clear', () => {
it('should remove all entries', () => {
cache.set('a', 1)
cache.set('b', 2)
cache.clear()
expect(cache.get('a')).toBeUndefined()
expect(cache.get('b')).toBeUndefined()
})
})
})

View File

@@ -1,33 +1,39 @@
{ {
"license": "WTFPL", "license": "WTFPL",
"dependencies": { "dependencies": {
"@radix-ui/react-checkbox": "^1.0.4", "@nostr/tools": "npm:@jsr/nostr__tools@^2.23.3",
"@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-checkbox": "^1.3.3",
"async-mutex": "^0.3.2", "@radix-ui/react-tabs": "^1.1.13",
"esbuild": "^0.14.54", "async-mutex": "^0.3.2",
"eslint": "^8.54.0", "esbuild": "^0.14.54",
"eslint-plugin-babel": "^5.3.1", "events": "^3.3.0",
"eslint-plugin-react": "^7.33.2", "minidenticons": "^4.2.1",
"events": "^3.3.0", "nostr-tools": "^2.8.1",
"minidenticons": "^4.2.0", "react": "^17.0.2",
"nostr-tools": "^1.17.0", "react-dom": "^17.0.2",
"prettier": "^2.8.8", "react-native-svg": "^13.14.1",
"react": "^17.0.2", "react-qr-code": "^2.0.18",
"react-dom": "^17.0.2", "use-boolean-state": "^1.0.2",
"react-native-svg": "^13.14.0", "use-debounce": "^7.0.1",
"react-qr-code": "^2.0.12", "webextension-polyfill": "^0.8.0"
"use-boolean-state": "^1.0.2", },
"use-debounce": "^7.0.1", "scripts": {
"webextension-polyfill": "^0.8.0" "dev": "./build.js; bunx tailwindcss -i ./extension/style.css -o ./extension/output/style.css --watch",
}, "build": "bunx tailwindcss -i ./extension/style.css -o ./extension/output/style.css; ./build.js prod",
"scripts": { "package:chrome": "bunx tailwindcss -i ./extension/style.css -o ./extension/output/style.css --minify; ./build.js prod; cd extension/output; zip -r archive *; cd ../../; mv extension/output/archive.zip extension/releases/nostrconnect_chrome.zip",
"dev": "./build.js; pnpm exec tailwindcss -i ./extension/style.css -o ./extension/build/style.css --watch", "package:firefox": "bunx tailwindcss -i ./extension/style.css -o ./extension/output/style.css --minify; ./build.js prod firefox; cd extension/output; zip -r archive *; cd ../../; mv extension/output/archive.zip extension/releases/nostrconnect_firefox.xpi",
"build": "pnpm exec tailwindcss -i ./extension/style.css -o ./extension/output/style.css; ./build.js prod", "lint": "biome lint ./extension/background.js ./extension/common.js ./extension/nostr-provider.js ./extension/content-script.js ./extension/popup.jsx ./extension/prompt.jsx ./extension/options.jsx ./extension/icons.jsx ./extension/utils.js",
"package:chrome": "pnpm exec tailwindcss -i ./extension/style.css -o ./extension/output/style.css --minify; ./build.js prod; cd extension/output; zip -r archive *; cd ../../; mv extension/output/archive.zip extension/releases/nostrconnect_chrome.zip", "format": "biome format --write ./extension/background.js ./extension/common.js ./extension/nostr-provider.js ./extension/content-script.js ./extension/popup.jsx ./extension/prompt.jsx ./extension/options.jsx ./extension/icons.jsx ./extension/utils.js",
"package:firefox": "pnpm exec tailwindcss -i ./extension/style.css -o ./extension/output/style.css --minify; ./build.js prod firefox; cd extension/output; zip -r archive *; cd ../../; mv extension/output/archive.zip extension/releases/nostrconnect_firefox.xpi" "test": "vitest run",
}, "test:watch": "vitest",
"devDependencies": { "test:coverage": "vitest run --coverage"
"esbuild-plugin-copy": "^2.1.1", },
"tailwindcss": "^3.3.5" "devDependencies": {
} "@biomejs/biome": "^2.4.10",
"@vitejs/plugin-react": "^6.0.1",
"esbuild-plugin-copy": "^2.1.1",
"jsdom": "^29.0.2",
"tailwindcss": "^3.4.19",
"vitest": "^4.1.3"
}
} }

10240
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
allowBuilds:
esbuild: false

14
vitest.config.js Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['extension/**/*.test.{js,jsx}'],
coverage: {
reporter: ['text', 'json', 'html'],
include: ['extension/**/*.js'],
exclude: ['extension/output/**', 'extension/**/*.test.js']
}
}
})