From c832aa22af3e67907274a6af524f08e369226456 Mon Sep 17 00:00:00 2001 From: Ryan Breen Date: Tue, 31 Jan 2023 01:09:05 -0500 Subject: [PATCH] Permissions UI with user requests and modification post-saving. Commits: Basic, functional permission tab when requesting getPubKey. Only allows one time deny. getPubKey and getRelays for sure working after prompt. Changed the prompt to use a query string instead of background script queue. This should prevent any disconnect between the user expecting one thing and getting a different prompt. It is not working using query string and working quite nicely. Finally figured out the secret sauce to only open one prompt at a time. Allow and deny now both work, with the option to remember the request next time. Still tweaking lots of events to try and get the prompts working smoothly Nice rendering for the event query message. Tweaking the migration setup and the tab opening/closing code. When remembering signing events, it is now scoped by event kind, as well! Include extra event information in event signing dialogs. Change confirm buttons to confirm dialog box. Update nostr-tools to 1.2.1 The interface for app permissions looks good. Ready to work on functionality now. Don't show app permissions section unless there are things to show. Fix bug when saving a "Deny". Additional formatting changes to options page for permissions UI. Fine permissions seem to be working nicely! Quick usability fix so that App Permissions section appears on Options page, even when there are no options selected. Bumping build to #5. Preparing for new build release. --- Nostore.xcodeproj/project.pbxproj | 34 ++- .../xcschemes/xcschememanagement.plist | 4 +- Shared (Extension)/Resources/background.js | 223 +++++++++++++----- Shared (Extension)/Resources/content.js | 7 +- Shared (Extension)/Resources/nostr.js | 2 +- Shared (Extension)/Resources/options.css | 8 + Shared (Extension)/Resources/options.html | 57 ++++- Shared (Extension)/Resources/options.js | 85 ++++++- Shared (Extension)/Resources/permission.html | 43 ++++ Shared (Extension)/Resources/permission.js | 103 ++++++++ Shared (Extension)/Resources/popup.js | 4 +- Shared (Extension)/Resources/utils.js | 93 +++++++- build.js | 1 + extras/pfp.png | Bin 0 -> 19199 bytes package-lock.json | 28 ++- package.json | 8 +- 16 files changed, 602 insertions(+), 98 deletions(-) create mode 100644 Shared (Extension)/Resources/permission.html create mode 100644 Shared (Extension)/Resources/permission.js create mode 100644 extras/pfp.png diff --git a/Nostore.xcodeproj/project.pbxproj b/Nostore.xcodeproj/project.pbxproj index 50fb97a..f069b5d 100644 --- a/Nostore.xcodeproj/project.pbxproj +++ b/Nostore.xcodeproj/project.pbxproj @@ -71,6 +71,12 @@ 941B04342978CDF900CA291E /* Icon-16.png in Resources */ = {isa = PBXBuildFile; fileRef = 941B042F2978CDF900CA291E /* Icon-16.png */; }; 941B04352978CDF900CA291E /* Icon-64.png in Resources */ = {isa = PBXBuildFile; fileRef = 941B04302978CDF900CA291E /* Icon-64.png */; }; 941B04362978CDF900CA291E /* Icon-64.png in Resources */ = {isa = PBXBuildFile; fileRef = 941B04302978CDF900CA291E /* Icon-64.png */; }; + 944A6DD32988BA200032C2E3 /* permission.html in Resources */ = {isa = PBXBuildFile; fileRef = 944A6DD22988BA200032C2E3 /* permission.html */; }; + 944A6DD42988BA200032C2E3 /* permission.html in Resources */ = {isa = PBXBuildFile; fileRef = 944A6DD22988BA200032C2E3 /* permission.html */; }; + 944A6DD62988BD230032C2E3 /* permission.js in Resources */ = {isa = PBXBuildFile; fileRef = 944A6DD52988BD230032C2E3 /* permission.js */; }; + 944A6DD72988BD230032C2E3 /* permission.js in Resources */ = {isa = PBXBuildFile; fileRef = 944A6DD52988BD230032C2E3 /* permission.js */; }; + 944A6DD92988D7900032C2E3 /* permission.build.js in Resources */ = {isa = PBXBuildFile; fileRef = 944A6DD82988D7900032C2E3 /* permission.build.js */; }; + 944A6DDA2988D7900032C2E3 /* permission.build.js in Resources */ = {isa = PBXBuildFile; fileRef = 944A6DD82988D7900032C2E3 /* permission.build.js */; }; 948C69D9297F887600FB3574 /* options.html in Resources */ = {isa = PBXBuildFile; fileRef = 948C69D8297F887600FB3574 /* options.html */; }; 948C69DA297F887600FB3574 /* options.html in Resources */ = {isa = PBXBuildFile; fileRef = 948C69D8297F887600FB3574 /* options.html */; }; 948C69DD297F88A200FB3574 /* options.css in Resources */ = {isa = PBXBuildFile; fileRef = 948C69DB297F88A200FB3574 /* options.css */; }; @@ -171,6 +177,9 @@ 941B042E2978CDF900CA291E /* Icon-32.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "Icon-32.png"; sourceTree = ""; }; 941B042F2978CDF900CA291E /* Icon-16.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "Icon-16.png"; sourceTree = ""; }; 941B04302978CDF900CA291E /* Icon-64.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "Icon-64.png"; sourceTree = ""; }; + 944A6DD22988BA200032C2E3 /* permission.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = permission.html; sourceTree = ""; }; + 944A6DD52988BD230032C2E3 /* permission.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = permission.js; sourceTree = ""; }; + 944A6DD82988D7900032C2E3 /* permission.build.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = permission.build.js; sourceTree = ""; }; 948C69D8297F887600FB3574 /* options.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = options.html; sourceTree = ""; }; 948C69DB297F88A200FB3574 /* options.css */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.css; path = options.css; sourceTree = ""; }; 948C69DC297F88A200FB3574 /* options.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = options.js; sourceTree = ""; }; @@ -258,6 +267,9 @@ 941B03A2296FA90400CA291E /* Resources */ = { isa = PBXGroup; children = ( + 944A6DD82988D7900032C2E3 /* permission.build.js */, + 944A6DD52988BD230032C2E3 /* permission.js */, + 944A6DD22988BA200032C2E3 /* permission.html */, 948C69E4297F8BA600FB3574 /* options.build.css */, 948C69E1297F891F00FB3574 /* options.build.js */, 948C69DB297F88A200FB3574 /* options.css */, @@ -501,7 +513,9 @@ 941B0413297110F100CA291E /* background.build.js in Resources */, 948C69E82982DFE900FB3574 /* background.html in Resources */, 948C69DF297F88A200FB3574 /* options.js in Resources */, + 944A6DD32988BA200032C2E3 /* permission.html in Resources */, 948C69DD297F88A200FB3574 /* options.css in Resources */, + 944A6DD92988D7900032C2E3 /* permission.build.js in Resources */, 941B03F2296FA90400CA291E /* background.js in Resources */, 948C69E2297F891F00FB3574 /* options.build.js in Resources */, 941B03F8296FA90400CA291E /* popup.css in Resources */, @@ -512,6 +526,7 @@ 941B040D296FAD6900CA291E /* nostr.js in Resources */, 941B03EE296FA90400CA291E /* images in Resources */, 941B03F0296FA90400CA291E /* manifest.json in Resources */, + 944A6DD62988BD230032C2E3 /* permission.js in Resources */, 941B04312978CDF900CA291E /* Icon-32.png in Resources */, 941B041A2971139000CA291E /* content.build.js in Resources */, 941B041C2971139000CA291E /* popup.build.js in Resources */, @@ -535,7 +550,9 @@ 941B0414297110F100CA291E /* background.build.js in Resources */, 948C69E92982DFE900FB3574 /* background.html in Resources */, 948C69E0297F88A200FB3574 /* options.js in Resources */, + 944A6DD42988BA200032C2E3 /* permission.html in Resources */, 948C69DE297F88A200FB3574 /* options.css in Resources */, + 944A6DDA2988D7900032C2E3 /* permission.build.js in Resources */, 941B03F3296FA90400CA291E /* background.js in Resources */, 948C69E3297F891F00FB3574 /* options.build.js in Resources */, 941B03F9296FA90400CA291E /* popup.css in Resources */, @@ -546,6 +563,7 @@ 941B040E296FAD6900CA291E /* nostr.js in Resources */, 941B03EF296FA90400CA291E /* images in Resources */, 941B03F1296FA90400CA291E /* manifest.json in Resources */, + 944A6DD72988BD230032C2E3 /* permission.js in Resources */, 941B04322978CDF900CA291E /* Icon-32.png in Resources */, 941B041B2971139000CA291E /* content.build.js in Resources */, 941B041D2971139000CA291E /* popup.build.js in Resources */, @@ -764,7 +782,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 5SD834TD9W; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "iOS (Extension)/Info.plist"; @@ -795,7 +813,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 5SD834TD9W; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "iOS (Extension)/Info.plist"; @@ -830,7 +848,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 5SD834TD9W; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "iOS (App)/Info.plist"; @@ -872,7 +890,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 5SD834TD9W; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "iOS (App)/Info.plist"; @@ -913,7 +931,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = "macOS (Extension)/nostore.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 5SD834TD9W; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; @@ -945,7 +963,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = "macOS (Extension)/nostore.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 5SD834TD9W; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; @@ -980,7 +998,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "macOS (App)/nostore.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 5SD834TD9W; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1016,7 +1034,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "macOS (App)/nostore.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = 5SD834TD9W; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; diff --git a/Nostore.xcodeproj/xcuserdata/ryan.xcuserdatad/xcschemes/xcschememanagement.plist b/Nostore.xcodeproj/xcuserdata/ryan.xcuserdatad/xcschemes/xcschememanagement.plist index 2fa6e1a..1e154e6 100644 --- a/Nostore.xcodeproj/xcuserdata/ryan.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Nostore.xcodeproj/xcuserdata/ryan.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,12 +7,12 @@ Nostore (iOS).xcscheme_^#shared#^_ orderHint - 1 + 0 Nostore (macOS).xcscheme_^#shared#^_ orderHint - 0 + 1 nostore (iOS).xcscheme_^#shared#^_ diff --git a/Shared (Extension)/Resources/background.js b/Shared (Extension)/Resources/background.js index a55816a..68e37cf 100644 --- a/Shared (Extension)/Resources/background.js +++ b/Shared (Extension)/Resources/background.js @@ -5,81 +5,197 @@ import { nip04, nip19, } from 'nostr-tools'; - -import { getProfileIndex, get, getProfile } from './utils'; +import { Mutex } from 'async-mutex'; +import { + getProfileIndex, + get, + getProfile, + getPermission, + setPermission, +} from './utils'; const storage = browser.storage.local; const log = msg => console.log('Background: ', msg); +const validations = {}; +let prompt = { mutex: new Mutex(), release: null, tabId: null }; browser.runtime.onInstalled.addListener(async ({ reason }) => { // I would like to be able to skip this for development purposes - let ignoreHook = (await storage.get({ ignoreInstallHook: false })) - .ignoreInstallHook; - if (ignoreHook === true) { - return; - } - if (['install'].includes(reason)) { - browser.tabs.create({ - url: 'https://ursus.camp/nostore', - }); + // let ignoreHook = (await storage.get({ ignoreInstallHook: false })) + // .ignoreInstallHook; + // if (ignoreHook === true) { + // return; + // } + // if (['install'].includes(reason)) { + // browser.tabs.create({ + // url: 'https://ursus.camp/nostore', + // }); + // } +}); + +browser.runtime.onMessage.addListener((message, _sender, sendResponse) => { + log(message); + let uuid = crypto.randomUUID(); + let sr; + + switch (message.kind) { + // General + case 'closePrompt': + prompt.release?.(); + return Promise.resolve(true); + case 'allowed': + complete(message); + return Promise.resolve(true); + case 'denied': + deny(message); + return Promise.resolve(true); + case 'generatePrivateKey': + return Promise.resolve(generatePrivateKey()); + case 'savePrivateKey': + return savePrivateKey(message.payload); + case 'getNpub': + return getNpub(message.payload); + case 'getNsec': + return getNsec(message.payload); + + // window.nostr + case 'getPubKey': + case 'signEvent': + case 'nip04.encrypt': + case 'nip04.decrypt': + case 'getRelays': + console.log('asking'); + validations[uuid] = sendResponse; + ask(uuid, message); + setTimeout(() => { + console.log('timeout release'); + prompt.release?.(); + }, 10_000); + return true; + default: + return Promise.resolve(); } }); -browser.runtime.onMessage.addListener( - async (message, _sender, sendResponse) => { - log(message); +async function forceRelease() { + if (prompt.tabId !== null) { + try { + // If the previous prompt is still open, then this won't do anything. + // If it's not open, it will throw an error and get caught. + await browser.tabs.get(prompt.tabId); + } catch (error) { + // If the tab is closed, but somehow escaped our event handling, we can clean it up here + // before attempting to open the next tab. + prompt.release?.(); + prompt.tabId = null; + } + } +} - switch (message.kind) { - // General - case 'log': - console.log( - message.payload.module ? `${module}: ` : '', - message.payload.msg - ); - break; - case 'generatePrivateKey': - sendResponse(generatePrivateKey()); - break; - case 'savePrivateKey': - await savePrivateKey(message.payload); - break; - case 'getNpub': - let npub = await getNpub(message.payload); - sendResponse(npub); - break; - case 'getNsec': - let nsec = await getNsec(message.payload); - sendResponse(nsec); - break; +async function ask(uuid, { kind, host, payload }) { + await forceRelease(); // Clean up previous tab if it closed without cleaning itself up + prompt.release = await prompt.mutex.acquire(); + + let mKind = kind === 'signEvent' ? `signEvent:${payload.kind}` : kind; + let permission = await getPermission(host, mKind); + console.log('existing permission: ', permission); + if (permission === 'allow') { + console.log('already allowed'); + complete({ + payload: uuid, + origKind: kind, + event: payload, + remember: false, + host, + }); + prompt.release(); + return; + } + + if (permission === 'deny') { + console.log('already denied'); + deny({ payload: uuid, origKind: kind, host }); + prompt.release(); + return; + } + + console.log('creating asking popup'); + let qs = new URLSearchParams({ + uuid, + kind, + host, + payload: JSON.stringify(payload || false), + }); + let tab = await browser.tabs.getCurrent(); + let p = await browser.tabs.create({ + url: `/permission.html?${qs.toString()}`, + openerTabId: tab.id, + }); + prompt.tabId = p.id; + return true; +} + +function complete({ payload, origKind, event, remember, host }) { + console.log('complete'); + sendResponse = validations[payload]; + + if (remember) { + console.log('saving permission'); + let mKind = + origKind === 'signEvent' ? `signEvent:${event.kind}` : origKind; + setPermission(host, mKind, 'allow'); + } + + if (sendResponse) { + console.log('sendResponse found'); + switch (origKind) { case 'getPubKey': - let pubKey = await getPubKey(); - sendResponse(pubKey); + getPubKey().then(pk => { + console.log(pk); + sendResponse(pk); + }); break; - - // window.nostr case 'signEvent': - let event = await signEvent_(message.payload); - sendResponse(event); + signEvent_(event).then(e => sendResponse(e)); break; case 'nip04.encrypt': - let cipherText = await nip04Encrypt(message.payload); - sendResponse(cipherText); + nip04Encrypt(event).then(e => sendResponse(e)); break; case 'nip04.decrypt': - let plainText = await nip04Decrypt(message.payload); - sendResponse(plainText); + nip04Decrypt(event).then(e => sendResponse(e)); break; case 'getRelays': - let relays = await getRelays(); - sendResponse(relays); - break; - - default: + getRelays().then(e => sendResponse(e)); break; } - return false; } -); +} + +function deny({ origKind, host, payload, remember, event }) { + console.log('denied'); + sendResponse = validations[payload]; + + if (remember) { + console.log('saving permission'); + let mKind = + origKind === 'signEvent' ? `signEvent:${event.kind}` : origKind; + setPermission(host, mKind, 'deny'); + } + + sendResponse?.(undefined); + return false; +} + +function keyDeleter(key) { + return new Promise(resolver => { + setTimeout(() => { + console.log('Validations: ', validations); + console.log('Deleting key validations: ', key); + resolver(); + delete validations[key]; + }, 1000); + }); +} // Options async function savePrivateKey([index, privKey]) { @@ -89,6 +205,7 @@ async function savePrivateKey([index, privKey]) { let profiles = await get('profiles'); profiles[index].privKey = privKey; await storage.set({ profiles }); + return true; } async function getNsec(index) { diff --git a/Shared (Extension)/Resources/content.js b/Shared (Extension)/Resources/content.js index 7dfd747..b81a3d5 100644 --- a/Shared (Extension)/Resources/content.js +++ b/Shared (Extension)/Resources/content.js @@ -13,7 +13,12 @@ window.addEventListener('message', async message => { let { kind, reqId, payload } = message.data; if (!validEvents.includes(kind)) return; - payload = await browser.runtime.sendMessage({ kind, payload }); + payload = await browser.runtime.sendMessage({ + kind, + payload, + host: window.location.host, + }); + console.log(payload); kind = `return_${kind}`; diff --git a/Shared (Extension)/Resources/nostr.js b/Shared (Extension)/Resources/nostr.js index 83371fd..92dd87a 100644 --- a/Shared (Extension)/Resources/nostr.js +++ b/Shared (Extension)/Resources/nostr.js @@ -56,6 +56,6 @@ window.addEventListener('message', message => { if (!validEvents.includes(kind)) return; - window.nostr.requests[reqId](payload); + window.nostr.requests[reqId]?.(payload); delete window.nostr.requests[reqId]; }); diff --git a/Shared (Extension)/Resources/options.css b/Shared (Extension)/Resources/options.css index bdc4b3d..66a7c3b 100644 --- a/Shared (Extension)/Resources/options.css +++ b/Shared (Extension)/Resources/options.css @@ -34,4 +34,12 @@ .section-header { @apply text-2xl lg:text-5xl font-bold; } + + .subsection-header { + @apply text-xl lg:text-4xl font-bold; + } + + a { + @apply border-2 border-dotted text-fuchsia-800 border-fuchsia-800 hover:border-transparent; + } } \ No newline at end of file diff --git a/Shared (Extension)/Resources/options.html b/Shared (Extension)/Resources/options.html index cd57dd4..b520413 100644 --- a/Shared (Extension)/Resources/options.html +++ b/Shared (Extension)/Resources/options.html @@ -22,9 +22,8 @@
- - - + +
@@ -53,7 +52,7 @@
- +
@@ -81,7 +80,7 @@ - + @@ -111,10 +110,52 @@
+ + +
+

App Permissions

+

+ Permissions granted to individual applications. + Everything defaults to Ask unless you have saved a different option. +

+ +
+ +
+ +
+ +

You have not remembered any app requests yet.

+ + + + + + + +
App RequestAction
+
+
- - - + +
diff --git a/Shared (Extension)/Resources/options.js b/Shared (Extension)/Resources/options.js index 7e7f5f7..8e34260 100644 --- a/Shared (Extension)/Resources/options.js +++ b/Shared (Extension)/Resources/options.js @@ -11,12 +11,16 @@ import { saveProfileName, saveRelays, RECOMMENDED_RELAYS, + getPermissions, + setPermission, + KINDS, + humanPermission, } from './utils'; const log = console.log; Alpine.data('options', () => ({ - profileNames: ['Poop'], + profileNames: ['---'], profileIndex: 0, profileName: '', pristineProfileName: '', @@ -27,8 +31,11 @@ Alpine.data('options', () => ({ newRelay: '', urlError: '', recommendedRelay: '', - confirmDelete: false, - confirmClear: false, + permissions: {}, + host: '', + permHosts: [], + hostPerms: [], + setPermission, async init(watch = true) { log('Initialize backend.'); @@ -37,6 +44,11 @@ Alpine.data('options', () => ({ if (watch) { this.$watch('profileIndex', async () => { await this.refreshProfile(); + this.host = ''; + }); + + this.$watch('host', () => { + this.calcHostPerms(); }); this.$watch('recommendedRelay', async () => { @@ -59,8 +71,7 @@ Alpine.data('options', () => ({ await this.getNsec(); await this.getNpub(); await this.getRelays(); - this.confirmClear = false; - this.confirmDelete = false; + await this.getPermissions(); }, // Profile functions @@ -87,8 +98,14 @@ Alpine.data('options', () => ({ }, async deleteProfile() { - await deleteProfile(this.profileIndex); - await this.init(false); + if ( + confirm( + 'This will delete this profile and all associated data. Are you sure you wish to continue?' + ) + ) { + await deleteProfile(this.profileIndex); + await this.init(false); + } }, // Key functions @@ -96,9 +113,13 @@ Alpine.data('options', () => ({ async saveProfile() { if (!this.needsSave) return; + console.log('saving private key'); await savePrivateKey(this.profileIndex, this.privKey); + console.log('saving profile name'); await saveProfileName(this.profileIndex, this.profileName); + console.log('getting profile name'); await this.getProfileNames(); + console.log('refreshing profile'); await this.refreshProfile(); }, @@ -161,11 +182,57 @@ Alpine.data('options', () => ({ }, 3000); }, + // Permissions + + async getPermissions() { + this.permissions = await getPermissions(this.profileIndex); + + // Set the convenience variables + this.calcPermHosts(); + this.calcHostPerms(); + }, + + calcPermHosts() { + let hosts = Object.keys(this.permissions); + hosts.sort(); + this.permHosts = hosts; + }, + + calcHostPerms() { + let hp = this.permissions[this.host] || {}; + let keys = Object.keys(hp); + keys.sort(); + this.hostPerms = keys.map(k => [k, humanPermission(k), hp[k]]); + console.log(this.hostPerms); + }, + + permTypes(hostPerms) { + let k = Object.keys(hostPerms); + k = Object.keys.sort(); + k = k.map(p => { + let e = [p, hostPerms[p]]; + if (p.startsWith('signEvent')) { + let n = parseInt(p.split(':')[1]); + let name = + KINDS.find(kind => kind[0] === n) || `Unknown (Kind ${n})`; + e = [name, hostPerms[p]]; + } + return e; + }); + return k; + }, + // General async clearData() { - await clearData(); - await this.init(false); + if ( + confirm( + 'This will remove your private keys and all associated data. Are you sure you wish to continue?' + ) + ) { + await clearData(); + await this.init(false); + } }, // Properties diff --git a/Shared (Extension)/Resources/permission.html b/Shared (Extension)/Resources/permission.html new file mode 100644 index 0000000..846f0ab --- /dev/null +++ b/Shared (Extension)/Resources/permission.html @@ -0,0 +1,43 @@ + + + + + + + + + + Permission Requested + + + +
+ +

App is requesting permission

+

+ The host + + is requesting the following permission: + . +

+

+ Event kind is . +

+ + + +
+ + + + +
+
+ + + \ No newline at end of file diff --git a/Shared (Extension)/Resources/permission.js b/Shared (Extension)/Resources/permission.js new file mode 100644 index 0000000..82dc77a --- /dev/null +++ b/Shared (Extension)/Resources/permission.js @@ -0,0 +1,103 @@ +import Alpine from 'alpinejs'; +import jsonFormatHighlight from 'json-format-highlight'; +import { KINDS } from './utils'; + +storage = browser.storage.local; + +window.addEventListener('beforeunload', () => { + browser.runtime.sendMessage({ kind: 'closePrompt' }); + return true; +}); + +Alpine.data('permission', () => ({ + host: '', + permission: '', + key: '', + event: '', + remember: false, + + async init() { + let qs = new URLSearchParams(location.search); + console.log(location.search); + this.host = qs.get('host'); + this.permission = qs.get('kind'); + this.key = qs.get('uuid'); + this.event = JSON.parse(qs.get('payload')); + }, + + async allow() { + console.log('allowing'); + await browser.runtime.sendMessage({ + kind: 'allowed', + payload: this.key, + origKind: this.permission, + event: this.event, + remember: this.remember, + host: this.host, + }); + console.log('closing'); + await this.close(); + }, + + async deny() { + await browser.runtime.sendMessage({ + kind: 'denied', + payload: this.key, + origKind: this.permission, + event: this.event, + remember: this.remember, + host: this.host, + }); + await this.close(); + }, + + async close() { + let tab = await browser.tabs.getCurrent(); + console.log('closing current tab: ', tab.id); + await browser.tabs.update(tab.openerTabId, { active: true }); + window.close(); + }, + + async openNip() { + await browser.tabs.create({ url: this.eventInfo.nip, active: true }); + }, + + get humanPermission() { + switch (this.permission) { + case 'getPubKey': + return 'Read public key'; + case 'signEvent': + return 'Sign event'; + case 'getRelays': + return 'Read relay list'; + case 'nip04.encrypt': + return 'Encrypt private message'; + case 'nip04.decrypt': + return 'Decrypt private message'; + default: + break; + } + }, + + get humanEvent() { + return jsonFormatHighlight(this.event); + }, + + get isSigningEvent() { + return this.permission === 'signEvent'; + }, + + get eventInfo() { + if (!this.isSigningEvent) { + return {}; + } + + let [kind, desc, nip] = KINDS.find(([kind, desc, nip]) => { + return kind === this.event.kind; + }) || ['Unknown', 'Unknown', 'https://github.com/nostr-protocol/nips']; + + return { kind, desc, nip }; + }, +})); + +Alpine.start(); diff --git a/Shared (Extension)/Resources/popup.js b/Shared (Extension)/Resources/popup.js index 0097618..8e45c0a 100644 --- a/Shared (Extension)/Resources/popup.js +++ b/Shared (Extension)/Resources/popup.js @@ -1,11 +1,11 @@ import { - bglog, getProfileNames, setProfileIndex, getProfileIndex, getRelays, RECOMMENDED_RELAYS, saveRelays, + initialize, } from './utils'; import Alpine from 'alpinejs'; window.Alpine = Alpine; @@ -19,7 +19,7 @@ Alpine.data('popup', () => ({ async init() { log('Initializing backend.'); - await browser.runtime.sendMessage({ kind: 'init' }); + await initialize(); this.$watch('profileIndex', async () => { await this.loadNames(); diff --git a/Shared (Extension)/Resources/utils.js b/Shared (Extension)/Resources/utils.js index 494aa79..2a1a458 100644 --- a/Shared (Extension)/Resources/utils.js +++ b/Shared (Extension)/Resources/utils.js @@ -1,3 +1,4 @@ +const DB_VERSION = 1; const storage = browser.storage.local; export const RECOMMENDED_RELAYS = [ new URL('wss://relay.damus.io'), @@ -5,18 +6,41 @@ export const RECOMMENDED_RELAYS = [ new URL('wss://nostr-relay.derekross.me'), new URL('wss://relay.snort.social'), ]; +// prettier-ignore +export const KINDS = [ + [0, 'Metadata', 'https://github.com/nostr-protocol/nips/blob/master/01.md'], + [1, 'Text', 'https://github.com/nostr-protocol/nips/blob/master/01.md'], + [2, 'Recommend Relay', 'https://github.com/nostr-protocol/nips/blob/master/01.md'], + [3, 'Contacts', 'https://github.com/nostr-protocol/nips/blob/master/02.md'], + [4, 'Encrypted Direct Messages', 'https://github.com/nostr-protocol/nips/blob/master/04.md'], + [5, 'Event Deletion', 'https://github.com/nostr-protocol/nips/blob/master/09.md'], + [7, 'Reaction', 'https://github.com/nostr-protocol/nips/blob/master/25.md'], + [40, 'Channel Creation', 'https://github.com/nostr-protocol/nips/blob/master/28.md'], + [41, 'Channel Metadata', 'https://github.com/nostr-protocol/nips/blob/master/28.md'], + [42, 'Channel Message', 'https://github.com/nostr-protocol/nips/blob/master/28.md'], + [43, 'Channel Hide Message', 'https://github.com/nostr-protocol/nips/blob/master/28.md'], + [44, 'Channel Mute User', 'https://github.com/nostr-protocol/nips/blob/master/28.md'], +]; export async function initialize() { await getOrSetDefault('profileIndex', 0); await getOrSetDefault('profiles', [await generateProfile()]); - await getOrSetDefault('version', 0); + let version = (await storage.get({ version: 0 })).version; + console.log('DB version: ', version); + while (version < DB_VERSION) { + version = await migrate(version, DB_VERSION); + await storage.set({ version }); + } } -export async function bglog(msg, module = null) { - await browser.runtime.sendMessage({ - kind: 'log', - payload: { msg, module }, - }); +async function migrate(version, goal) { + if (version === 0) { + console.log('Migrating to version 1.'); + let profiles = await getProfiles(); + profiles.forEach(profile => (profile.hosts = {})); + await storage.set({ profiles }); + return version + 1; + } } export async function getProfiles() { @@ -72,7 +96,7 @@ export async function generateProfile(name = 'Default') { return { name, privKey: await generatePrivateKey(), - hosts: [], + hosts: {}, relays: [], }; } @@ -127,3 +151,58 @@ export async function saveRelays(profileIndex, relays) { export async function get(item) { return (await storage.get(item))[item]; } + +export async function getPermissions(index = null) { + if (!index) { + index = await getProfileIndex(); + } + let profile = await getProfile(index); + let hosts = await profile.hosts; + return hosts; +} + +export async function getPermission(host, action) { + let index = await getProfileIndex(); + let profile = await getProfile(index); + console.log(host, action); + console.log('profile: ', profile); + return profile.hosts?.[host]?.[action] || 'ask'; +} + +export async function setPermission(host, action, perm, index = null) { + let profiles = await getProfiles(); + if (!index) { + index = await getProfileIndex(); + } + let profile = profiles[index]; + let newPerms = profile.hosts[host] || {}; + newPerms = { ...newPerms, [action]: perm }; + profile.hosts[host] = newPerms; + profiles[index] = profile; + await storage.set({ profiles }); +} + +export function humanPermission(p) { + // Handle special case where event signing includes a kind number + if (p.startsWith('signEvent:')) { + let [e, n] = p.split(':'); + n = parseInt(n); + let nname = KINDS.find(k => k[0] === n)?.[1] || `Unknown (Kind ${n})`; + return `Sign event: ${nname}`; + } + + switch (p) { + case 'getPubKey': + return 'Read public key'; + case 'signEvent': + return 'Sign event'; + case 'getRelays': + return 'Read relay list'; + case 'nip04.encrypt': + return 'Encrypt private message'; + case 'nip04.decrypt': + return 'Decrypt private message'; + default: + return 'Unknown'; + } +} diff --git a/build.js b/build.js index e37c035..7476a90 100755 --- a/build.js +++ b/build.js @@ -18,6 +18,7 @@ require('esbuild') 'nostr.build': './Shared (Extension)/Resources/nostr.js', 'popup.build': './Shared (Extension)/Resources/popup.js', 'options.build': './Shared (Extension)/Resources/options.js', + 'permission.build': './Shared (Extension)/Resources/permission.js', }, outdir: './Shared (Extension)/Resources', sourcemap: 'inline', diff --git a/extras/pfp.png b/extras/pfp.png new file mode 100644 index 0000000000000000000000000000000000000000..f3120aa36c253d53aba26b4b1f7baa91063b63cb GIT binary patch literal 19199 zcmXtgby!u;_cfph(%lUbQqmwPjnYVWcS$!$U8F^j?w0QEmTtI&fRbK9y5HgReSd%W z_&oQXbI;5^Gkexvdo3cA6=l#-NKoM5;Lzn{B~{_z;NzbDA-@2h1a|U{g8yDR$!fd6 z!BK-Rsa4@!4lN_M7$^LM;n?!Y6G{Ns78lymwvfDUtLCa#d`{OY?of`}OW(qOPB}GwSS;)9)YQT$z>ga-?%?@%EFY=NQvTvfoBE4k%%({+I>Jr zHjeph86B@fF*~uO+N~B!t;7IYjc|7Mmk1LvGvkYwozd@8##U%T_mDGGAXgkF# z#XGwVs)dV%>Af6>Z-8Teea2P7&-}qvE)D#u2>8|e8u@Yx^wG=g9owelE6U$7ZslDh z(cay`{w*ITmTUy$Ao}3PgdIj)F<5{E`aQ*?e`3seQ)SLW?NrY8|Q*kCsA6!A?Sn zP-AC_Bi*hq-;@8~7oUk=MK0SEi-&d6@D%yz+)JjjxvqjPIA}@;GdG+P)9hD^Pg(k9 z3`ni_DDhbNfyhavb2GwDth-8Z0u86gnm^Q!WupCEgECu}+=-08?jxuk+bF5*W3$v_?Y`4)jk}1TMl0C&Cl6=ZHn{>+u6bRZYOMFg{}mW^YGA?D@3Sq zh=8gXPM4aELLs4FVox){EE?2HQfP*hJ5y%n<^4PFS0_p+vSdE*+xaJDrq8oG(0(tE z)8f3R-Ce)_bxXM4jAR#%Jw=U3hasG&p)j+{ZZkV4_5~(HljFwdsk-5NWFmZo4mFlE zlY@_-ohat%#~5er5`U|-fZL-m&o~=gfXALFQM(z87e}PaVOkDrPdoS?}E;F8X}5E<*|;J9A85>_eb>=Rr&xfeyYE zv)phxL~@IPv4|5@$>nv>hW6d(2R1E!s^2Da@|WKs-V~=Js(;_Bfa-Rl1C^6p0AEhl z+Ni4#b)f1+lf5K$meUrwTavq)ZgUJk%l1VvwArcjQfu?P%izk%$fjSJzqS#XC6Nw) z+iCfhM34b16v6oOIk{s13(d&X?mAOClyLVeZ9^|4K@Q$I9;_{#E}w)jD4yBkIPQlw zYlG=8s&fKD5cP_CRGt(wQ>8Ht>rKBgDU<7f9%1W1>o8QMaAJE5CewrU_pO6XJcf zb32@pBsG4<@~O}wo=1c{)-cRp@%M9b5|0Mu#*!1*<8th)yY4{hU*W7*hX!R6j`ig9 z*mCgIM$I|`gtK{Fo=|N&FuiGQmO@M_4{rkEylJzY3K6Rx814iK=;*WdcKObhT+7uN zBVRs6wcsu-Gfj)q~bGX*?PpL>#(8imySl`G25Z&iMV zSa@7coMOEenP1AJ>OX9Xb@V^LZeFCvNM0E%$lzzDeQ-OWv}2Hat}2%MSD^lr7V`O< zsv+Y z8Dr4kN7282o0xBNeSZ*S&;u?vS`TUb{L5i1C(rACp*cnoXqzI87cGa`>Kv!_jFljvB$E|yGM{j8D{b7 zt>O`I;FW$P=J^(j+VHP~84$hU2sZvs$dO-%<@hDamX4EQ*U43#73o2H7(_B6VQg+4 z&g~C^e%p+DoE8IOewIeD`hv|BHr7n+?ix3HFB|a?Bo>X{PpcF>K2+zTQT3#;7YxB_ z6tm6NN~5@vWwO7sKI2uA9QNE&^-;&D+hmDlLV`VQc<4Kmc)U0f{*Hz%$}S%}%e^0?)q7D|LdiL(sOC52 zySPOXYS+Fze_|=~tvw=P{P)!&Q5~B&!XA|{s%j8#_Ht!r_UGpe=w1!AXHpMd0#O~G zkgs>PhxS4KCn+`jk#|=eJlm$?D5zjaK2sd~(_g2Z7BRmF&xZJiqsHznq+44SYK7l^4_8tKw?q+hj;Y)^LQ=?j;m_74fkP}PgCcySb-@5_hJHtqD zt`(n2=NbZ0@%+Gaqlo2wrJcpR?23kr0)~GRV;0j2dBbG5Gm*cfKHDszr^;I?RuiHUFnz%Gg9@5oQs>UtT&_Z+ELA z0J5jO%wAF$p&)XAmW7quR|Ev1yb@5XwmA;yc^7Im&mKfJ~>-FI?>{i|K z-7cLjzH=Q~y`)2TeBFjhxQlwRG;X5XLl{?3YL|;Nf$AXoL&#SAg_h$uFX zLaXlL-Kzeqqje2=fuIcc1}59=D_uLJYNj02p-<-{Xvm^Sc#Gqo-mc+cnu_v$p3=m4 z=cOlF$S&Dwjf8P`fDH9lJ-ooh`O)Pwh2Yoy0{YHQZV{nIuUtT8LuhMTxEy7C!g!>7 zOP2M01GyCPmJTy?@zbM2>7$yZ;7H1>AU`U>o>%2EhKYnYLEP5hxCo-z^7u5qVOVT! z2S*q+DvG@fc27(2&zPF?TOZ{5rUt(0{9j)s^_-HO5Y3u({5Wjt}S74{}}RQ?$1}U*jx;z*QLYQ8vi6#A*qeQw#>T(-AEI|KYKG2Rk zB=R%SaW=#$v1KgUuFVB0NW#7$&-GRQMro+`xS<^q2Q@Xy5K8?%C;A5`;}fEq>^LUA z*#0yE7nvwzV+BSmi(93UrN;@{r<;85oJwCc1WW3!5XNnpQatvUr_p%Nw8^*nkx5ay z!q7~p^^T6Y)a@P)*?R0N1RqQEd&3F#1tyj;a1@;N#Xt_LJXRB95<2y!ir1j*Uuc9=;n9m zdCE1>iCFPP#uKJUOV`H+b**bwJp9KGQr_clq3>u|4^f6b)q4x^GrvQx%lN|zz?6lr z4-MkZ&%hkJ*__L@S15dF8XXbMfg z1oDTkoPKRPMQSSzMJvC}+8*_C8(M6%-x>D%!>bg(Mubx>u$0Q=Yr{OozM*@ z(TOkqmQ$7Qv_;seAje?4cAr)LsyN1Ow*v`D4hXH+D{mv6)o*YS+Yy~FX^i!N;>Kbj z@Yl6{8M}n_?K%AvRtm5U%Dg3qGTqP?qJkxJV-x*A&gR)5J95Tx;FB;c>=OuL7gIwEltt3lH$QL2i_j#VQfAQiOF)Pcb-s;WccvaD zDJTpg$vp^n*kU0-rQIdGCNtV!Itacy2gV{k6Iq^dcKDR4n5@iu=R@`(ByI!<#lk@k1v&QOWL zRy+*GSWyid{PXvIh8oHdA~+j1wWF82b68KFFL>zx5pm?BHcH525MFE(wu6UO0MuQ4 ztRgv*Jp}sOdn4l2zWRa^%+(M63JWic!J2=72 zf1#5BBNA_#n|O;k?82Z)5ckUFhiKF#JBryio^flL+LH=$2W@PY5NLkzgG9<}mp{!| z6)aSHvunXoK6FtR%NX;kHFHv}`6wH&cj|w&&$vS>nkoWW)YvEB?>ie@kZboFxvTFj zE3)btL-0`?3`;e>FOECE*2IrvP}gqgIVDOWL`4yN{oo6cU@o;A#OjF_qBx^WDwz{# z2u>eTN2DV@qU}P43WNyjQmiPrcy+m>QLk7fBf&~b+AEl-e$I~Aa{G?DV;F1lV_B@0 zLy?in9GR#?4y?4=YoN*|PhyIp*G;)^r0Wg%+B8KMO0n0eBP}`Iy1Qx-5TiJMd6!LD zN8F_Y_L+e{rBift!%TPq=T_!R#sl_aNu}8`d!xZ#{039xjkhQwmr*+B@VYSF=1{h$ z@+ecL`|i3Ez)?$4?w5vP%#BIdMJf+h?*IZ{jG+#JG9pn_ySYOW5pu2C35iVeVAhSX zv@TK1f>ulGHi8jY4qR**Rf+92QQ-WyId=_xDBW|14>Nchmy0=~y;A*p z7RO?h6^h(ALL)(~SlD?|rsI;drx8tv<+xMEyyc@`fUaQtZ=uZ9Tk<&%%-ZAl$qqD1uEUJL ze?$J7C&lHZ8Rw1mGJ;P-Ze*Ozm?%}3qF?4z74yBK?CPlVx1LAQVE;1TpQkEzsN#t?;(25WLOLuR@wr@dQKGlO0;Dz1@81wkE9ZG`I($C5 z9-gZ-?>3j|p~=S%)GIOgNxn9%h(`=YNYCzV(QU-Bi<1iL(N{&HPv9NB8b45*FrRyr z-0Hz*r}{99Qz^ZQzjxqn3GnRk)t?Z?sginCup0>fb>)-F5IfEXwdPHsMqD9sG2YO< zVVYhLPWZgkjHhRGZe_F~&f_>+3otBLIh%CPcD7ZH4Qel$5iQ%p6DcFl+-P;g-uh79 zT*w;K3Xc3pJ~KbX*Y68k?Oaj%xJe zYErCo7P&)>9Cq+q5{a_=0gWCQSWQiXR|nF+DC5lP6cG7jKIzN$Jwcos6SRr8RHt6q zuQfEww7AZt&YGyY0ab$_=dC4u+cW)JKR_8+#J2P2I=)4V&WFk{@>hLJq5yA(@pp92 zrF@7pQl?N(cTvo840Rt=T2DoCfU&mn7k*}?%)SmbNS1RWCE2tZbKWLB=(Ohj&Aat{ zosDS&8LVL9@UDsoBmwubdGE>A zyq8fMa>t{FBjRR{g-;lxXokHD;Ne`@D|$+&T{5(B1%vb}Z>9$_IcXFDDO7zjPHsi4 zM(B`jlzv?ur&OVAE^+G^r_G?J|L5Y1n*w0zA#C+Q`AT>3=0l# zeEgNqmnj3;uLGC;4+i;$V58R4<6ij&NSqCT67tPbI>fZqU;Ev8fLVNwQ#hdNI~w;$ zHHsH*+-5yHQ6PkV_;IUQwrA370!zQgIOo$LF3UmD9hnVWg8+&=7=t2RUWaT9=()GN1b=8I!uE)y)~PWGF}fMl_s%Q%BehRX(Wsd$D>&OSrE5RDCkzqr_U^~AG*N5+-h#9 zG=vi7Rg#(12*ev*+_!)I->C=cp$nf+AKB0E6;!iOWK&-MRK_|f4^m=qv3q;HGcwAI zt*?8yE;ZwzZN~T)q>_c*7u6*9!&|ty-sMV&`S?eJ@TM-~(uM*b=lvkXsEq<;?dGI8 z7T{_Y)i?XN+iOjXL$c!RhcgQ{tGC^~e{ALsx_oY?jT{T`u9&?3=?TeMFhks_W~c9? z=~lLXJ#RIPznCA{F;a-TXu()6UAC@-Ww)1RZ2Ohhky%NFn^7&GzZm~%Ixmr+e>=JP zJi`FS^4FEWP94!&a9MFClK_n3)tXi>`r%0yf&TE9lgne$^(0NFX{5s?XH0}-dRoD4(=};nrZ$FR%*wWgXkTbc_$JO)7-8)>TFxsKT zA6Rx#<|5eJG+2fD=)cYISG?UvcP#yGOi5x@DnPx~u(g3=Gd~ZF!RDdp41Y&CWO?Ce z`DWuLz5-@1mGJNN`1OW%1q%fnt%zO--0Tb{C#wbxc*86gF^2weONmnr%ZQgTrek5{Ql_F zH)!&$7~sdb+OR(#{boDorB{yCP`BV-_0VAdl7pA$0hLyk=}kjHhF=&`>8;IgnHQu7 zth%R(^3ML z9OdD|?T4G=kG-hHJ|3}0&hVh$!qoMueSAjd({i>`x)Oq5ja9F0#5C zD?(6gZ@b0ts5Mk}-5d?N%d-R@J+ai2L@>#M8;B*zcQ7vaI~_i@&Z!4=`E#` zru>XTcBAvy7pg8N!tDkjsghS~k)hqEYUVKtd|oET7B6xlz5hiBv-S=c7@~KU4SiG- zKO4Ttf)%(+1cT|t^V0QSHM}b8qx?Uv!sK`DwEc8BhR@m`N_`9Qo zZkG**C$x9a(4=QpEQAu^;9H3#Hy%CKKP+{M8WoLh+w}Wt6w0;F8yyRneA&*1Dt=7I z(&mC&@iTQFd!&O-;;cxtgwxH|z$+J%(}|rAr>a&~P<%wKR1@hzI% zqU%qC)LkuL@(Hpy(KnBhk5RS$~sE52sX4J$XY`;CyX)+OM@!B?+{ z%x!mbp_t0NX&G0=y$QM}XDK3iX`OQRX2)c|&l4f~8)C_B zjSjRcB$Sb8Tb+S@7c5)}m+vueqGr4%uGpn=qN4(?rrYt-kH&?sLw6&#|G}jlLCpoF z&H6m_WV!l z5G9FIJAU|nxqH6Yr9k(Be7fv*n|C?3f1O5RgW`I}JkQr+t$&ZJZQ$*UPo6=B{G4X< zOzp9G-({OiywMc3G}^ZbRgmi@OIn8G|pCPmls_ zT<`p?YdUn!(N&v{`{wiR_TIPjBL)}wmWaZTH%h&QKAX1S%zNq-$d2>XR(}7yqFMGrTvD3BfjSZ=)R2vLTjYN7l(e#F+;(NT^=0t+Wz{MLjbjcG`UDP*qI zz|>7eZ0%ldDvqoDr?#B$BRpC{3J)&!5B*xM9 z{2by#13I%)B~JkE@Viu4ZosEPQbC-&1BxM>`aabCGONalwl8JQHIIo{ zjsk$jd8R~O4OVj1sPi6UY`kAi;Y!|G38~{FUE*hFF`GJFCCd2MgA1S{j+Dog)B;KZPS8F>b{cyuy(_LSUcBIepOev*!t-1 zF2j(Q^C6xIQ4+#duhCGucbM^n&HRiG*KWqOuAzvM8D6s*mQHm+=)EUa z+SS|J34X&6a#c(fxLLv7T+s7fky69*S}<&);LG_b$NS%XQ-9;99C(;!WcIs$Tk+9K z%FkdtCF*jSWkA4k(AhhobhuVV6e_Y&J$_{0hF38hNfzMMSERzrMFI`avd3-S^lUT; zghDk=U|E*ktSh0Eg*1i`OLUl4p)QqUWVYj6yszViFKzLr_Y(*K+AxvnHbq*LB_0_& z6~!4NZge4fJUU+JZuGs<$4{Vyc?y#(sogdO=>QiF_#t!)6iAL}I@XEV&u6Wm6yL>G zYfblwS%}&S-u2FpkX9=IZ_QS5()b<*-LpbjilNaPpVg&sYqMzMLH#L!N+5Q`Dg)VzLhPnP)k17Iq)tqES2 z%5$voQ%2X!h=H#)e)Or>G&y!&PJCm3{+k3HvA5R3t}mu%R8){GrX*wtwJ}Wlwo~^d z&rceOsbR-mCo$4 z6D0@KR8wF{xLfG>X<*C^Gs~Z#d=@4uamksBEOq79if(r_V{O%>iJ|5XB#Arl_~+Jb zpfn$$WsrM`t?;(&KQg5>7?kmm-!$XDVc#RUBYIIxsu+f{ZBdFRPZ>bFy|vAfkH3zt zxTpz>&1z;`O-H5AFo(eH^YYbf?f8HKk>FZY!LGPCAsP=$>1%{&6xJrI&!a13gfJFV z@hm4-c>xpZjGagyXD^9V1MT<|a05`I}}T4tf~(_RyD&!@c@K zpFYz+bna&(S;e|@O+UjHju#P1-17j-OD)yCu}YhOc;FR-*=CNu9HwN`&n_>osGBP+ zK*bJhSpHaijC~I_dq~P|kQK7G*f0^qDKWy3Lh5i7{%Ndy0af8GfIc&YAaim%m#jW} zyI)ylMv%0N{zrYZEr5mzQN=Kqj=y8*I0p9o;4L^Tu}#2(+%CkxMNV12^+dUjvO@k1 z2SQ%j%RsdQZr$r>F?~+DCaGFdIO3Ee?HE|S*)4Sla2<%O1*LnQNcL9$`gM0SY6K~TC2f;go(b4>5wEBJf<=(OapW>Y93>D zNfBp`$dm?Twau(PyHh!3&$**bVZP1Zv*J}1i}9)pLio(9fh|S$uh1*yynXm;8YB{| z)t9`kf$=+T>RT$iyM$Q3d6IUs&ag}5o9)w|XkG)E;1saqDoz-`#p}<#-7i3*xv@MB zMU1){G6Kudsoto)I{e&}BKj)@1VWt9`8GA3So?`y)G0iyC(+*us*0z8;9+6E94X?z zZB39JPml@|cYS5GYajdBlmaOhd|Pt)iRJ+aGEz=5UNXjlytDa{Ji5@EszV24jx(|4 z(%cTyFS1l#wKc>6tH@6e&1iVdwX-HzY!jPBKr0;AwZV%!26@i8t=3C)CISwhz6kkM zTiu*_mz<6&M99sQS$Q}ZH;sHZpsM0|x%&Y%B{u@;zA@W`LX1NG7q>U?9LkGS3f8E{&ma zF2HziG2lTD(qw#`eEXj%Pv&2q@GqwgjB}?7{}pHHzj}VXW5IP%3sM4BPgCKl#bNck z&-hMlfqE69%FiV4iE0X0;U#WoBPIDRYH309_lYVR1%cq(|8N7h&o=Yw{P=?EEgU{L zlxN9u&mp6II8#MJZ?>Sga-=MgXi_G~WHz+3IQO-aN@QG@ZKsjmO0&8HdW0V!`SO%c zOMZ|YTyYGei3UNCb^XSWSuFaP)!^=N1lYVfNh~ zQplnv`n;)PcKq1^XfpZ>8|+PTP?dugTvR|HSV|`hD~DM8R%Yn3KM(Yd58s{jdpi?( zaxItJ%6wp#)QSh)pt{z9JTrOyzLbHCF{{BLk_c80>$OAp^t1B(Dd~ZC`@GVA!x32e zuUW>;&v?K)?;}*Z{203Cxj9%Q6q%9|q24G5#%6ivM$6<``Si%%6^ZL%o*tKfNXtWF z-m76RaL16RUi}FoUQdy=i3e9Wi;?;0dZQ0t~|)MDW=olK+9E< zKNo8$Mb|$Q`xZ|j>*9q9&?%_)oTb-3{{J?>1mNwwUuiA8`}v?jYtJNfREmD|nhO3k zADqGzMjko;g|}Z_8g{PCH0U$jSDa9p?$d;k`LvJE5`TzhCG%e3jP<~e17X+dJ)p-? z@6@r)C8qW^`_(2S9q_66X^>jyKis63RgU*hR^Ka5|0%?vkl3P{t0V#i#Mw{?dko#> zUyzZJr&wWK)sM+cROyBE=@yz)_W6Nzl&B-uR6Xk7G&xRNapjnfp)oXl#5{*qh3)gB zH?cEwAv1V!zaQbIL`kuW{iO^{UwX)mpL3lC$vqLOsom6p%C8l__mb!n#Hsoj&{h@v zSdZjZJ30nce|m|w{M9BS2r@rw@D^p-dsjIZ%}$TP=<-MM0wIPH=%Q?~cHt??CGmL# zHJ%?8NZizG1$j)8_esS{Nr+;-k)TN9!o#wZ;_gX*AI>wl7Pr^dlTqkrD+HusU~_KL z0ezE1_}HkJ>aL~YrB4wn)t9dK==$!uG%zb|h083u5JbT2*HWZLv9H>KRNyH1?`yS> z1!`ToyXu@Zs%#0Hz2@;JSI&&Tpgj3BTJBW-l(yUdOWSZ*4lczyrgm`mp{n}J{w?c5 zTN{}uicq(Rd}S(@;Ju!?FCto9L$Mg37Dg`BSir&{N})qtoekt7h{FOXs5X5qp0HGb z=apoZe2X)8GZ#21s($1-Un2Jv0Q#t13ly}Yr3itFq77b@|a!tdorj}D+T3Vdv*sjLoZeYJC);(YY{n+&Lj zPh%=L-x{pI+B=~LxyK~LNbtqq7PM$y{LV-i0}uxEpKz4_oNgN!2F?6;&2&@|+M60< z_49q5gkTBrv!&!8;T*e9ouXu18w?eDt5ZqzG3#CRRX>A9M6+sZf|je;K^ zpk|@?xv_Zit>9hI0!aTR56q1JooQv|gL>|V<(Augim0GK>BsU%FYoQH1Q~e{hfbix z9KAb~Y-19>;l;@{ydZ7oyZD>Ao)>Vv$Nv+a7U^i--X_0U$S9YI`i2#duxbv8QeiYL z0ESFf&5f>UikZG0CoVebD3C$Sm;7F5?b!lYCWpt)d_o$NL4c3Y9Y`& z$ZYHQOhFK*xNTrcuH!!YyH$kx23F=6MQy2*1S6us5O^2f%Ul{&U*zpv3COvV$$u=9 zlSPv4Px`^351h$tCgJ~>EY|q21KhvD7-_~oc6dg`(3{E_%<{xPiYtjW4a<^nkJ7FmU!NG8MroVe-VXW1+>IE&H1Cpoi9w0zzXn+hW z>VCxuj5Zy7_8#_vmfat2a`#pH^&Q|Q;k+iES@4=nDFX$YbSe(b*8zVEe#BZAkZqi^ zHyy*fN1IjP24BR_|46X%Q5OTxd50dbuV2_9cr}at8J!&Pk~B2mkStcXf@X8^K#h-f zV1?#8#TGmd6j@!G-@gBq|iLm;jVl^Op=;3Q3^&alBv^ zuyYOn#Gbi+c46y%U+DwH&BUJVjfM-F_yNLnYTi=vBP8F)e>+A!)zDNx#dLI(NS~(X z5}$c*;n-PR87mWeadO~Y-rf-0J2Ar6PQ#0SJ4pqan3DjZnPQX|=e8FPCgjj;EcjjL zir~j__XKoL&eH?Mmg~@Ov9DOzJb$*U(UOym0mFcQb0s{H{(`c+J=O0#30#b$KV7y_ z>36-{VYNJCVn=F9O*6K2>2whcq`08XmFok_XzMjw2T?KffEH!L#Ufh48=;<}V8ys6ZaHTXfd?$)Bgdlv|*>%(5 z@8>vo*OnP;1JnLj^|yge7n zSMjUR^Q%79yp`v*{D%8J3SrZAH-ut>#A8(!Wev0q!<V73i=i_3~I0MB@asEZlqCT3JAp?Xzx4& z*d{9xrUj03$1cSEiL=_{lx;UxJ_?dV!&vs;Kcv%*cLd;M(k;2Fw3&Y2;A26#cZy)M zKAhmW5zb!-x-1H;e)-cajikh%=fT3vZI)p?i52D+acU`?@s}w@zD16J_=_Tt! z!)tHqt10XspE+|>a2TKZ@yZWvZJM-!&&4&dI_G=wjIn}@%O+l2188!tasHXFHj7bc zB$v=~{as+neYm@jmcKS_FdIwboCCp&+qyLsri`#XpN$D;21-8n+;c0RmGk29eDls` zDOHCZg>%k>Zn-N?Ul1i!SJ~QA!a~q@OzHURGnWWqwN>4vl-$=5p39HBaKX* z?-hRADdNC=ln~sb_>GqZ6h5&?#A-_X_yL0QE}=_EucJW+tOCCpLA7*81j$<>zhVJb zFrb>9Z5;I_ofbUDihk@%(D3BA8OeF78bP=5??dN(VcKd_oR>N17fSMJD0|ZUGbk?t z^~GQR*{jDui0yKMR;8;>Y~75`l7nE_fqauOomcG(ke|_(PGmzvnP4~6uD=bEW$FUC z)vjfXWauiFGW;Hk^R1P#F5q3FC4FfswMny3#cw6N@VO4tWZws^yW?S-1IhqH$oj6T zEyJ2WAo4dNXuA>|^c$nENBrh#&Ff7q7iHmFGLy8HVK9Ri_&3b~#nxjISeO{SrO)x@ zq{5kKcqm@EqN57!sFnOKS@#q7r2XA9vTLp?)~9A~D&KyIj=pWpFr*})HbObF*_3&8 zbJ6zvx%oTe@1HEpI8J`%fg@Sv6L@cAK4X7Ptk4}h##cl#uAS*YgBjRV-t4y0`mG)U zfuxr_g`e*I`@8beqlj@@K}=}OhEAS{NJ#EA;!~}N+9+uExM#2hvzcu?oR++ML7X`A zlE77?C3>pqVh{Af%r6~H?8NjX{Qa;6dkRP;!0gFImwNQD1&2Q#D zU+~#2lF^@&WFcn!gqNiJ*I}{VE!xH1hLLq7%A1aFyxLAM6>fY!0eo0x+ z=u7=EKV`sNd22G9gyf;iND)Hb#IzhOd0%Kyx>K>RyDrt5q4t!4gMY@RI%q(O0Z`@I z>X2gu)j||LQr24=-!U16^8=|C{(Uav=rfeB$9}k0Z>HuW*6!9Q5MB!Xl*Pg6paBwkyfMgQ+{ZW3= zE()-z^NY7-OP)Izx~=jnekBiq@eAw&1FsN%KOirfm$o+oyMo!}Bl(-vGOVdQqveb( zfQlkv-mao zJMmOd5VPFiEV`g&c2WDK^bR|Pq25*cFmJcFhbp9ah%8}z77E~GT1cvdU6V_A`NB3k zSaH(!Q%0IB@_`HMaPnEX(*oJ&e$3Bw#3jdmMWPV-OU1*8Hk(*64|D3LM@$wmq94BO zNkCyu=}@=xuzeXe3UhxK0I4Y`C@uT)!C@U z1aiQ2If)x90q-e*kY%mrX?AN2o9{=u%zy$3{!hGASfLwtsFug62I-G6_&RYz!`u&L zZG5#2FDGgA^$Mpt>!;jp%}GfQt9U10Nq+pD^}V-g0<)2vHpdEy*VneSUqNiH^8NWr zfUeDo0X)1c{S^7=a#8^90tjN4ez*vSIJLs#awWfJAeNSrG2IcSowb8ys!)piEop2q ze3F9K&Yuts>FXVM5U-1$g-58TV((#y{?RC)8^X7H;i$*d_bC?kK?2BJ#_MDMd&w-5)+th9~K+ z4x3c=JibbUSNv0oz-c()c>JpwrK;gzqr&j0$k;Zad^;4Iu71s9RWGbyd%C(8}Lg_78B^ zsd&%?8S#v)fZUE#6#6S2CrR!*CbXD%hzCTIyEAI}nN+fL+e> z)BKnz@NxkFVe85r>Uqzs3$(wBF{%Z~0ZkvjH$X9_$rlK*0#VObo{^ltWU0W!rdjh; z6H34TMdwRgGgOtw(RX0DEWd?uP?Q+PJwEu}ZTKA!3e(V|Aa{xD-+h)|br1~4I-&E^ zdOdSQg89-n&e+F3ZUGz!y}Sqg&q6!gaxyQ!!pMa2xe%WlQTJmqX_-9T@I*@t=<&?D zqcJbXB6`8X#(<}+njG=hu^dJa)EzAk_zOJG zPf4xW8b!9@t0Xo$Uy$oUn+G)D;Q@6}D{+M)7K4LFBMzvFt&W%Zcvto^9JyYOftsR? zQ~2S-OvUhSCD4oJ{fJ~{D!543VfSbk;$Jt~DB^uz{*OxTzYLW9CC2=C-9yh;b_$35 zmOqkkcQd0Q_)JQk04p%md+jxPmW1Ll7LCcLLUWG2fhz;Ert?9cm-9>d#{TIq&P%jN zxJA`R|GRd4i{!B3zgPD!FPPZZI0)z!a2?zRZQu|+fY&IXAtO)Jq$LoJW!B{o@%#gj z(?S@4eM4qommA8lnI!#aSdw|F4;8p!-bO|45>xF&+xL39i10t&=*>$*fI+_T=Nq8J z1+>HP;;{%py?J|?kNGUk&Jvr(z${AM^hOvL$2;B_&kh~uCad4OhFSXfmYEF&S!@T7 z|EM4vb7`DLvW*Cs(^M!aO$4Re_`GG7v@;kEaRt*y%C8A9c;7G6bS5(hm@0eEN5yNVL3cgB*5);vEOz zO_<)Sc=|mTK0)O#WD(4glHdgi+;(3Xnh_yJQ>3GQIA1vjJ_!ju5IbNoI3V%k`1M79 zIt1b-FjJH4bPvvD6FIT&0>T3?XCEqqmH$F~$V^fp)*ysULjz=1-+)jEqLLK_>TU9u zjuraiDN0tL+PC?ir38*(ce8-RGU&yvr6vmY)(bC>w}HBj9R6&odAqX~h^cUW@^%;5UR8|Mu)^J5PPDJDAoH?CIIyfsX+TClCP=9`C{ zXh6{hRk15k^JLR0-YKsk?+Xh7iRj7aig=1g_O$)Al2nsN!F$LRif9;8raTt|{)>A` zsGOaO4#P!|KQQ&;lBMqi88_6X%PmjhN&A^F0`I52OawDfCIQ_9km8{F`1=Hs$9`Ol zF@9e_IDy1IUAtaK-d7mHP6hXKj+wSg8^!wzjKwn7_X{u%nFCAK)(L~%j!IKvm|LUW zA6e~a7~NFEQn&$;BPs1gD5%a1l-bdczutKmb--9w$srSn{?haFnEFy} z=W+RoDr^7Doqoy3^q`=m21cxG`NV4k&qvB*M5wkm%~84?eSkf8is=k5c5D+I9E$7H z-vt0|>`i;%cJlngMT*=g(2ys&?1=Q$eU(M{N3T0pV;sVC=pUOu(b@j8X!4xO6i=;# z1P8K=Aui*PpJe`pU(h`ctH^0pxp#dCcbh$+7@MG(qiC9<)2@~C2$#P&K8*`bY-vNT zD6maFGcZkA^dj>u17{VP=f8~F)o(KKs=Ew;y=@Y<@MeIa+%KsLU&b~BHvuysmsXC( z*v#?sI9tIFO6F1TO@RT8n44W`yoNvn5KK>EoB}G)bo-viZ2OMykG-Jm1JRzF__@+p~Vf^N>1siWXCS{pnQyEC?+#hTxqXkVIF0;$C*$lHIJ(e*x zEmXz4LCnnSHt$8zBke8s%0ZbPb${lBvLj24y$xvN%#7HN%8wTE6eu_BqOgT0Xk&-u zEXYJ9T%i0@`x;+){h5hF-CNK&+vvTPXBc;gkYUrMe-`FkH>Ejv=~=X`5=o8jr!qiE2 zOi|Cr68*jGb-Pg#si;;1P`owEVavi*&Tr1#C7n#$A1A zVfqGrans^$O#i%@Y$N&0y%yAWbjn--MMtjS=vs`jo5Bt~TbRL=KK4bSy)6xx7+2lGq-Mwy2mb#fN zd_=NVF24+AOx*4VQvS~EG6UCt zX>3_ zY<+W<(sQwlr0;k6S$kFazUlG0-pRmu$c-z|Jhltq>BW5ja;&Rb^KKLF=LdqoiX%K%IsfUes9W6v{j4Ny`D83T z_WuhM2J88}SvEm5M+%4q%hbzr6y9IO+7|JVk~SoToatHQOdmk*^ei$)r6o`~jhwdX zAa$D>soT&%-Nr^5cQ&Krb~xjV_ITjBi}jorZ3js-Qa~(OqFzl+CwwR9ZvY8BV33@f zM9#DX{3)#ONgj0WRi3Jw&V3mdrgqm%60;W+Gwq8BXFpVradh*mj?*N{S2vo z*{R`c3-z33wu1;MAg;1RZS|*9b0csEkYzhhEb*pz-YFL+ zw{`XkLV++ktkGczwS)lZXbIC%-$6%H$ikl{?u8ZlH=yn))pM$ChY=@}0^(vT)XJF} z^&?=O?JPo=r(Sa4rbW7VqwOT3Yf?aBz%sRZzCzyzoNGIa5C*)aQ1_MU;-_r~5oaa^ zB!(O>Bo6?yY-bT73N~xhw--|UcXXVbJQC-mfW#VwTEXf?8hsBi({>yoB4DdV-7SS9 z_Q(KjtkLLFUHtSL%sEG+J_78p9Z873QQH;jnih^sTd0edNb!h0(gTTcOVko?8iPKr z&|e2N!*(7adSE^1@3(ODuZ#6!S@aUaq=3YcBdWhNJ~+?b<>5QuGFs~ehpnJBE*GaOD|Rl*s2IFymci_aU-IE zD!o{nIj@Vh)`ciy(~~ssZ!^QJ9%l0}^UgUFI*+L=E08)fBx8F#(RoEKVNWGyb$k`{ zw#QvNq1hLq!u8_2YVEJaiZnmKBKkDKGh0BRdXyqF#`*z}C>2OjVcy?hBINMsm=$ zQ#Z@3K+JC~E>PTlRL~2+8SMWg^=4L}ALPa~n}>R}^?T?*rutZcn71+Sb>=TGAMHm2-Z9Nhh7M$EmlcRvH3Kq`5E`-y z$8_=2;C3JuI#-=5122I`f(}lzWz^;wX0=#>n2H&e;glz}KSB=+ngtF6n?mPW&lIKw zUJ5!2Zfu_N;~vdTh0bNxCo2%MX7ZAcrP$dfL|KJHEEdYH(79^ddyrA^PdsjPjt_5a zi*&PAtUydqBQnxdCfOFDt%BbJ(xI?Vpdoa&D)Sw9$KvYmw+_Y^n16m+J3odFX6ly} zh^abP1$ThL?lP`jaBZM%z-}M|GzX4XX;VNEm;iaL_}6j#t7qgUt|&fqIAeZg1!8K- zT`4ptaG40#;vgevi$X>rBhUh*LTCJo8HGuKqCioh=#j}7esUgPw0VW8B~~D2UCGPS zWCQc*he*o?P literal 0 HcmV?d00001 diff --git a/package-lock.json b/package-lock.json index e12aa96..d3f1203 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,9 @@ "license": "ISC", "dependencies": { "alpinejs": "^3.10.5", - "nostr-tools": "^1.1.1" + "async-mutex": "^0.4.0", + "json-format-highlight": "^1.0.4", + "nostr-tools": "^1.2.1" }, "devDependencies": { "@tailwindcss/forms": "^0.5.3", @@ -570,6 +572,14 @@ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", "dev": true }, + "node_modules/async-mutex": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.4.0.tgz", + "integrity": "sha512-eJFZ1YhRR8UN8eBLoNzcDPcy/jqjsg6I1AP+KvWQX80BqOSW1oJPJXDylPUEeMr2ZQvHgnQ//Lp6f3RQ1zI7HA==", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -879,6 +889,11 @@ "node": ">=0.12.0" } }, + "node_modules/json-format-highlight": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/json-format-highlight/-/json-format-highlight-1.0.4.tgz", + "integrity": "sha512-RqenIjKr1I99XfXPAml9G7YlEZg/GnsH7emWyWJh2yuGXqHW8spN7qx6/ME+MoIBb35/fxrMC9Jauj6nvGe4Mg==" + }, "node_modules/lilconfig": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.6.tgz", @@ -950,9 +965,9 @@ } }, "node_modules/nostr-tools": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-1.1.1.tgz", - "integrity": "sha512-mxgjbHR6nx2ACBNa2tBpeM/glsPWqxHPT1Kszx/XfzL+kUdi1Gm3Xz1UcaODQ2F84IFtCKNLO+aF31ZfTAhSYQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-1.2.1.tgz", + "integrity": "sha512-SL0sst29mrQ7oUPGQn+NMH4yuTe69a8S4bliNpYB2IG0fDl3Cx+xSLnuCTb4nZiNalatYsA5l+751wQiDGA3+A==", "dependencies": { "@noble/hashes": "^0.5.7", "@noble/secp256k1": "^1.7.0", @@ -1322,6 +1337,11 @@ "node": ">=8.0" } }, + "node_modules/tslib": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", + "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index 083f71b..e9a1a8c 100644 --- a/package.json +++ b/package.json @@ -18,12 +18,14 @@ "license": "ISC", "dependencies": { "alpinejs": "^3.10.5", - "nostr-tools": "^1.1.1" + "async-mutex": "^0.4.0", + "json-format-highlight": "^1.0.4", + "nostr-tools": "^1.2.1" }, "devDependencies": { - "esbuild": "^0.16.17", "@tailwindcss/forms": "^0.5.3", + "esbuild": "^0.16.17", "prettier": "^2.8.3", "tailwindcss": "^3.2.4" } -} \ No newline at end of file +}