2 Commits

Author SHA1 Message Date
woikos
cdfd034c68 Support CAT token in bunker URLs for NIP-46 connections (v0.2.2)
- Add 'bunker' to SignerType with isRemote getter and displayName
- Parse CAT token from bunker URL (?cat= parameter)
- Pass CAT token to BunkerSigner constructor
- Store bunkerCatToken in account for reconnection
- Add deploy command documentation

Files modified:
- src/domain/identity/SignerType.ts: Add bunker signer type
- src/providers/NostrProvider/bunker.signer.ts: Parse and use CAT tokens
- src/providers/NostrProvider/index.tsx: Pass CAT to login/reconnect
- src/types/index.d.ts: Add bunkerCatToken to TAccount

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 13:02:34 +01:00
mleku
12e02dd05b Add nostr+connect QR code flow for bunker login (v0.2.1)
- Add buildNostrConnectUrl() and parseNostrConnectUrl() for nostr+connect:// URLs
- Add BunkerSigner.awaitSignerConnection() for reverse flow (client waits for signer)
- Update BunkerLogin to show QR code that signers like Amber can scan
- Add bunkerLoginWithSigner() to NostrProvider for completing login after scan
- Support both QR scan mode and paste bunker URL mode

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 19:48:40 +02:00
8 changed files with 849 additions and 36 deletions

View File

@@ -0,0 +1,31 @@
# Deploy Command
Deploy smesh to the VPS at 10.0.0.1, serving on port 3008 behind smesh.mleku.dev.
## Instructions
1. Build the project locally:
```bash
npm run build
```
2. If build fails, fix any errors and retry before proceeding.
3. Sync the dist folder to the VPS:
```bash
rsync -avz --delete dist/ 10.0.0.1:~/smesh/dist/
```
4. Restart the smesh service on the VPS:
```bash
ssh 10.0.0.1 "sudo systemctl restart smesh"
```
5. Verify the service is running:
```bash
ssh 10.0.0.1 "sudo systemctl status smesh"
```
6. Report the deployment status and the URL: https://smesh.mleku.dev
$ARGUMENTS

315
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "smesh",
"version": "0.1.0",
"version": "0.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "smesh",
"version": "0.1.0",
"version": "0.2.1",
"license": "MIT",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -42,6 +42,7 @@
"@tiptap/react": "^2.12.0",
"@tiptap/starter-kit": "^2.12.0",
"@tiptap/suggestion": "^2.12.0",
"@types/qrcode": "^1.5.6",
"@webbtc/webln-types": "^3.0.0",
"blossom-client-sdk": "^4.1.0",
"blurhash": "^2.0.5",
@@ -66,6 +67,7 @@
"path-to-regexp": "^8.2.0",
"qr-code-styling": "^1.9.2",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^15.2.0",
@@ -5875,7 +5877,6 @@
"version": "22.10.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz",
"integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==",
"dev": true,
"dependencies": {
"undici-types": "~6.20.0"
}
@@ -5891,6 +5892,15 @@
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz",
"integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ=="
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "18.3.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz",
@@ -6630,6 +6640,15 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/camelcase-css": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@@ -6764,6 +6783,72 @@
"url": "https://polar.sh/cva"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/cliui/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/cliui/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/cliui/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -7386,6 +7471,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decode-named-character-reference": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
@@ -7477,6 +7571,12 @@
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@@ -8282,6 +8382,15 @@
"node": ">=6.9.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.6.tgz",
@@ -10615,6 +10724,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -10660,7 +10778,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"engines": {
"node": ">=8"
}
@@ -10747,6 +10864,15 @@
"node": ">= 6"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
@@ -11152,6 +11278,23 @@
"@types/offscreencanvas": "^2019.6.4"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode-generator": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.4.4.tgz",
@@ -11534,6 +11677,15 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -11543,6 +11695,12 @@
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@@ -11732,6 +11890,12 @@
"randombytes": "^2.1.0"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -12635,8 +12799,7 @@
"node_modules/undici-types": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"dev": true
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="
},
"node_modules/unicode-canonical-property-names-ecmascript": {
"version": "2.0.1",
@@ -13181,6 +13344,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/which-typed-array": {
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.18.tgz",
@@ -13640,6 +13809,12 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -13657,6 +13832,134 @@
"node": ">= 14"
}
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/yargs/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yargs/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yet-another-react-lightbox": {
"version": "3.21.7",
"resolved": "https://registry.npmjs.org/yet-another-react-lightbox/-/yet-another-react-lightbox-3.21.7.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "smesh",
"version": "0.2.0",
"version": "0.2.2",
"description": "A user-friendly Nostr client for exploring relay feeds",
"private": true,
"type": "module",
@@ -53,6 +53,7 @@
"@tiptap/react": "^2.12.0",
"@tiptap/starter-kit": "^2.12.0",
"@tiptap/suggestion": "^2.12.0",
"@types/qrcode": "^1.5.6",
"@webbtc/webln-types": "^3.0.0",
"blossom-client-sdk": "^4.1.0",
"blurhash": "^2.0.5",
@@ -77,6 +78,7 @@
"path-to-regexp": "^8.2.0",
"qr-code-styling": "^1.9.2",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^15.2.0",

View File

@@ -2,9 +2,14 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useNostr } from '@/providers/NostrProvider'
import { ArrowLeft, Loader2, Server } from 'lucide-react'
import { useState } from 'react'
import { BunkerSigner } from '@/providers/NostrProvider/bunker.signer'
import { ArrowLeft, Loader2, QrCode, Server, Copy, Check } from 'lucide-react'
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import QRCode from 'qrcode'
// Default relay for bunker connections - can be configured
const DEFAULT_BUNKER_RELAY = 'wss://relay.nsec.app'
export default function BunkerLogin({
back,
@@ -14,19 +19,82 @@ export default function BunkerLogin({
onLoginSuccess: () => void
}) {
const { t } = useTranslation()
const { bunkerLogin } = useNostr()
const { bunkerLoginWithSigner } = useNostr()
const [mode, setMode] = useState<'choose' | 'scan' | 'paste'>('choose')
const [bunkerUrl, setBunkerUrl] = useState('')
const [relayUrl, setRelayUrl] = useState(DEFAULT_BUNKER_RELAY)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [connectUrl, setConnectUrl] = useState<string | null>(null)
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
// Generate QR code when in scan mode
useEffect(() => {
if (mode !== 'scan') return
let cancelled = false
const startConnection = async () => {
setLoading(true)
setError(null)
try {
const { connectUrl, signer: signerPromise } = await BunkerSigner.awaitSignerConnection(
relayUrl,
undefined,
120000 // 2 minute timeout
)
if (cancelled) return
setConnectUrl(connectUrl)
// Generate QR code
const qr = await QRCode.toDataURL(connectUrl, {
width: 256,
margin: 2,
color: { dark: '#000000', light: '#ffffff' }
})
setQrDataUrl(qr)
setLoading(false)
// Wait for signer to connect
const signer = await signerPromise
if (cancelled) {
signer.disconnect()
return
}
// Get the user's pubkey from the signer
const pubkey = await signer.getPublicKey()
// Complete login
await bunkerLoginWithSigner(signer, pubkey)
onLoginSuccess()
} catch (err) {
if (!cancelled) {
setError((err as Error).message)
setLoading(false)
}
}
}
startConnection()
return () => {
cancelled = true
}
}, [mode, relayUrl, bunkerLoginWithSigner, onLoginSuccess])
const handlePasteSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!bunkerUrl.trim()) {
setError(t('Please enter a bunker URL'))
return
}
// Validate bunker URL format
if (!bunkerUrl.startsWith('bunker://')) {
setError(t('Invalid bunker URL format. Must start with bunker://'))
return
@@ -36,6 +104,8 @@ export default function BunkerLogin({
setError(null)
try {
// Use the existing bunkerLogin flow for bunker:// URLs
const { bunkerLogin } = useNostr()
await bunkerLogin(bunkerUrl.trim())
onLoginSuccess()
} catch (err) {
@@ -45,19 +115,167 @@ export default function BunkerLogin({
}
}
const copyToClipboard = async () => {
if (connectUrl) {
await navigator.clipboard.writeText(connectUrl)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
if (mode === 'choose') {
return (
<div className="flex flex-col gap-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2">
<Button size="icon" variant="ghost" className="rounded-full" onClick={back}>
<ArrowLeft className="size-4" />
</Button>
<div className="flex items-center gap-2">
<Server className="size-5" />
<span className="font-semibold">{t('Login with Bunker')}</span>
</div>
</div>
<div className="space-y-3">
<Button
variant="outline"
className="w-full justify-start gap-3 h-auto py-4"
onClick={() => setMode('scan')}
>
<QrCode className="size-6" />
<div className="text-left">
<div className="font-medium">{t('Show QR Code')}</div>
<div className="text-xs text-muted-foreground">
{t('Scan with Amber or another NIP-46 signer')}
</div>
</div>
</Button>
<Button
variant="outline"
className="w-full justify-start gap-3 h-auto py-4"
onClick={() => setMode('paste')}
>
<Server className="size-6" />
<div className="text-left">
<div className="font-medium">{t('Paste Bunker URL')}</div>
<div className="text-xs text-muted-foreground">
{t('Enter a bunker:// URL from your signer')}
</div>
</div>
</Button>
</div>
<div className="text-xs text-muted-foreground space-y-2 pt-2">
<p>
<strong>{t('What is a bunker?')}</strong>
</p>
<p>
{t(
'A bunker (NIP-46) is a remote signing service that keeps your private key secure while allowing you to sign Nostr events. Your key never leaves the bunker.'
)}
</p>
</div>
</div>
)
}
if (mode === 'scan') {
return (
<div className="flex flex-col gap-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2">
<Button size="icon" variant="ghost" className="rounded-full" onClick={() => setMode('choose')}>
<ArrowLeft className="size-4" />
</Button>
<div className="flex items-center gap-2">
<QrCode className="size-5" />
<span className="font-semibold">{t('Scan with Signer')}</span>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="relayUrl">{t('Relay URL')}</Label>
<Input
id="relayUrl"
type="text"
value={relayUrl}
onChange={(e) => setRelayUrl(e.target.value)}
disabled={loading || !!qrDataUrl}
className="font-mono text-sm"
/>
</div>
{loading && !qrDataUrl && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)}
{qrDataUrl && (
<div className="flex flex-col items-center gap-4">
<div
className="relative cursor-pointer rounded-lg overflow-hidden"
onClick={copyToClipboard}
title={t('Click to copy URL')}
>
<img src={qrDataUrl} alt="Bunker QR Code" className="w-64 h-64" />
<div className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 hover:opacity-100 transition-opacity">
{copied ? (
<Check className="size-8 text-white" />
) : (
<Copy className="size-8 text-white" />
)}
</div>
</div>
<p className="text-sm text-muted-foreground text-center">
{t('Scan this QR code with Amber or your NIP-46 signer')}
</p>
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm text-muted-foreground">{t('Waiting for connection...')}</span>
</div>
{connectUrl && (
<div className="w-full">
<Label className="text-xs text-muted-foreground">{t('Connection URL')}</Label>
<div className="flex gap-2 mt-1">
<Input
value={connectUrl}
readOnly
className="font-mono text-xs"
/>
<Button size="icon" variant="outline" onClick={copyToClipboard}>
{copied ? <Check className="size-4" /> : <Copy className="size-4" />}
</Button>
</div>
</div>
)}
</div>
)}
{error && <div className="text-sm text-destructive text-center">{error}</div>}
</div>
</div>
)
}
// Paste mode
return (
<div className="flex flex-col gap-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2">
<Button size="icon" variant="ghost" className="rounded-full" onClick={back}>
<Button size="icon" variant="ghost" className="rounded-full" onClick={() => setMode('choose')}>
<ArrowLeft className="size-4" />
</Button>
<div className="flex items-center gap-2">
<Server className="size-5" />
<span className="font-semibold">{t('Login with Bunker')}</span>
<span className="font-semibold">{t('Paste Bunker URL')}</span>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<form onSubmit={handlePasteSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="bunkerUrl">{t('Bunker URL')}</Label>
<Input
@@ -89,17 +307,6 @@ export default function BunkerLogin({
)}
</Button>
</form>
<div className="text-xs text-muted-foreground space-y-2">
<p>
<strong>{t('What is a bunker?')}</strong>
</p>
<p>
{t(
'A bunker (NIP-46) is a remote signing service that keeps your private key secure while allowing you to sign Nostr events. Your key never leaves the bunker.'
)}
</p>
</div>
</div>
)
}

View File

@@ -4,7 +4,7 @@
* Represents the type of signer/authentication method used for an account.
*/
const VALID_SIGNER_TYPES = ['nsec', 'nip-07', 'browser-nsec', 'ncryptsec', 'npub'] as const
const VALID_SIGNER_TYPES = ['nsec', 'nip-07', 'browser-nsec', 'ncryptsec', 'npub', 'bunker'] as const
export type SignerTypeValue = (typeof VALID_SIGNER_TYPES)[number]
@@ -26,6 +26,7 @@ export class SignerType {
static readonly BROWSER_NSEC = new SignerType('browser-nsec')
static readonly NCRYPTSEC = new SignerType('ncryptsec')
static readonly NPUB = new SignerType('npub')
static readonly BUNKER = new SignerType('bunker')
/**
* Create a SignerType from a string value
@@ -87,7 +88,7 @@ export class SignerType {
* Whether this signer uses a remote/external service
*/
get isRemote(): boolean {
return this._value === 'nip-07'
return this._value === 'nip-07' || this._value === 'bunker'
}
/**
@@ -112,6 +113,8 @@ export class SignerType {
return 'Encrypted Key'
case 'npub':
return 'View Only'
case 'bunker':
return 'Remote Signer'
}
}

View File

@@ -59,12 +59,13 @@ function generateRequestId(): string {
}
/**
* Parse a bunker URL (bunker://<pubkey>?relay=<url>&secret=<secret>).
* Parse a bunker URL (bunker://<pubkey>?relay=<url>&secret=<secret>&cat=<token>).
*/
export function parseBunkerUrl(url: string): {
pubkey: string
relays: string[]
secret?: string
catToken?: string
} {
if (!url.startsWith('bunker://')) {
throw new Error('Invalid bunker URL: must start with bunker://')
@@ -80,6 +81,7 @@ export function parseBunkerUrl(url: string): {
const params = new URLSearchParams(queryPart || '')
const relays = params.getAll('relay')
const secret = params.get('secret') || undefined
const catToken = params.get('cat') || undefined
if (relays.length === 0) {
throw new Error('Invalid bunker URL: no relay specified')
@@ -88,10 +90,65 @@ export function parseBunkerUrl(url: string): {
return {
pubkey: pubkeyPart,
relays,
secret,
catToken
}
}
/**
* Parse a nostr+connect URL (nostr+connect://<relay-url>?pubkey=<client-pubkey>&secret=<secret>).
* This is the format that signers (like Amber) scan to connect to a client.
*/
export function parseNostrConnectUrl(url: string): {
relay: string
pubkey?: string
secret?: string
} {
if (!url.startsWith('nostr+connect://')) {
throw new Error('Invalid nostr+connect URL: must start with nostr+connect://')
}
const withoutPrefix = url.slice('nostr+connect://'.length)
const [relayPart, queryPart] = withoutPrefix.split('?')
if (!relayPart) {
throw new Error('Invalid nostr+connect URL: missing relay')
}
const params = new URLSearchParams(queryPart || '')
const pubkey = params.get('pubkey') || undefined
const secret = params.get('secret') || undefined
return {
relay: relayPart,
pubkey,
secret
}
}
/**
* Build a nostr+connect URL for signers to scan.
* @param relay - The relay URL (without ws:// prefix, will be added)
* @param pubkey - The client's ephemeral pubkey for this session
* @param secret - Optional secret for the handshake
*/
export function buildNostrConnectUrl(relay: string, pubkey: string, secret?: string): string {
// Ensure relay URL uses the relay host without protocol
let relayHost = relay
.replace('wss://', '')
.replace('ws://', '')
.replace('https://', '')
.replace('http://', '')
.replace(/\/$/, '')
const params = new URLSearchParams()
params.set('pubkey', pubkey)
if (secret) {
params.set('secret', secret)
}
return `nostr+connect://${relayHost}?${params.toString()}`
}
/**
* Build a bunker URL from components.
*/
@@ -118,22 +175,170 @@ export class BunkerSigner implements ISigner {
private mintUrl: string | null = null
private requestTimeout = 30000 // 30 seconds
// Whether we're waiting for signer to connect (reverse flow)
private awaitingConnection = false
private connectionResolve: ((pubkey: string) => void) | null = null
/**
* Create a BunkerSigner.
* @param bunkerPubkey - The bunker's public key (hex)
* @param relayUrls - Relay URLs to connect to
* @param connectionSecret - Optional connection secret for initial handshake
* @param catToken - Optional CAT token (encoded string) for authorization
*/
constructor(bunkerPubkey: string, relayUrls: string[], connectionSecret?: string) {
constructor(bunkerPubkey: string, relayUrls: string[], connectionSecret?: string, catToken?: string) {
this.bunkerPubkey = bunkerPubkey
this.relayUrls = relayUrls
this.connectionSecret = connectionSecret
// Decode CAT token if provided
if (catToken) {
try {
this.token = cashuTokenService.decodeToken(catToken)
} catch (err) {
console.warn('Failed to decode CAT token from URL:', err)
}
}
// Generate local ephemeral keypair for NIP-46 communication
this.localPrivkey = secp256k1.utils.randomPrivateKey()
this.localPubkey = nGetPublicKey(this.localPrivkey)
}
/**
* Create a BunkerSigner that waits for a signer (like Amber) to connect.
* Returns the nostr+connect URL to display as QR code and a promise for the connected signer.
*
* @param relayUrl - The relay URL for the connection
* @param secret - Optional secret for the handshake
* @param timeout - Connection timeout in ms (default 120000 = 2 minutes)
*/
static async awaitSignerConnection(
relayUrl: string,
secret?: string,
timeout = 120000
): Promise<{ connectUrl: string; signer: Promise<BunkerSigner> }> {
// Generate ephemeral keypair for this session
const localPrivkey = secp256k1.utils.randomPrivateKey()
const localPubkey = nGetPublicKey(localPrivkey)
// Generate secret if not provided
const connectionSecret = secret || generateRequestId()
// Build the nostr+connect URL for signer to scan
const connectUrl = buildNostrConnectUrl(relayUrl, localPubkey, connectionSecret)
// Create signer instance (bunkerPubkey will be set when signer connects)
const signer = new BunkerSigner('', [relayUrl], connectionSecret)
signer.localPrivkey = localPrivkey
signer.localPubkey = localPubkey
signer.awaitingConnection = true
// Return URL immediately, signer promise resolves when connected
const signerPromise = new Promise<BunkerSigner>((resolve, reject) => {
signer.connectionResolve = (signerPubkey: string) => {
signer.bunkerPubkey = signerPubkey
signer.remotePubkey = signerPubkey
signer.awaitingConnection = false
resolve(signer)
}
// Set timeout
setTimeout(() => {
if (signer.awaitingConnection) {
signer.disconnect()
reject(new Error('Connection timeout waiting for signer'))
}
}, timeout)
// Connect to relay and wait
signer.connectAndWait(relayUrl).catch(reject)
})
return { connectUrl, signer: signerPromise }
}
/**
* Connect to relay and wait for signer to initiate connection.
*/
private async connectAndWait(relayUrl: string): Promise<void> {
await this.acquireTokenIfNeeded(relayUrl)
await this.connectToRelayAndListen(relayUrl)
}
/**
* Connect to relay and listen for incoming connect requests.
*/
private async connectToRelayAndListen(relayUrl: string): Promise<void> {
return new Promise((resolve, reject) => {
let wsUrl = relayUrl
if (relayUrl.startsWith('http://')) {
wsUrl = 'ws://' + relayUrl.slice(7)
} else if (relayUrl.startsWith('https://')) {
wsUrl = 'wss://' + relayUrl.slice(8)
} else if (!relayUrl.startsWith('ws://') && !relayUrl.startsWith('wss://')) {
wsUrl = 'wss://' + relayUrl
}
// Add token if available
if (this.token) {
const tokenEncoded = cashuTokenService.encodeToken(this.token)
const url = new URL(wsUrl)
url.searchParams.set('token', tokenEncoded)
wsUrl = url.toString()
}
const ws = new WebSocket(wsUrl)
const timeout = setTimeout(() => {
ws.close()
reject(new Error('Connection timeout'))
}, 10000)
ws.onopen = () => {
clearTimeout(timeout)
this.ws = ws
this.connected = true
// Subscribe to events for our local pubkey
const subId = generateRequestId()
ws.send(
JSON.stringify([
'REQ',
subId,
{
kinds: [24133],
'#p': [this.localPubkey],
since: Math.floor(Date.now() / 1000) - 60
}
])
)
resolve()
}
ws.onerror = () => {
clearTimeout(timeout)
reject(new Error('WebSocket error'))
}
ws.onclose = () => {
this.connected = false
this.ws = null
}
ws.onmessage = (event) => {
this.handleMessage(event.data)
}
})
}
/**
* Get the local public key (for displaying in nostr+connect URL).
*/
getLocalPubkey(): string {
return this.localPubkey
}
/**
* Set the Cashu token for authentication.
*/
@@ -212,7 +417,7 @@ export class BunkerSigner implements ISigner {
return
}
const mintInfo = await infoResponse.json()
await infoResponse.json() // Validate JSON response
console.log(`Relay ${relayUrl} requires Cashu token, acquiring...`)
// Configure the mint
@@ -359,8 +564,46 @@ export class BunkerSigner implements ISigner {
try {
// Decrypt the content with NIP-04
const decrypted = await nip04.decrypt(this.localPrivkey, event.pubkey, event.content)
const parsed = JSON.parse(decrypted)
const response: NIP46Response = JSON.parse(decrypted)
// Check if this is an incoming connect request (signer initiating connection)
if (this.awaitingConnection && parsed.method === 'connect') {
const request = parsed as NIP46Request
console.log('Received connect request from signer:', event.pubkey)
// Verify secret if we have one
if (this.connectionSecret) {
const providedSecret = request.params[1] // Second param is the secret
if (providedSecret !== this.connectionSecret) {
console.warn('Connect request has wrong secret, ignoring')
return
}
}
// Send ack response
const response: NIP46Response = {
id: request.id,
result: 'ack'
}
const encrypted = await nip04.encrypt(this.localPrivkey, event.pubkey, JSON.stringify(response))
const responseEvent: TDraftEvent = {
kind: 24133,
created_at: Math.floor(Date.now() / 1000),
content: encrypted,
tags: [['p', event.pubkey]]
}
const signedResponse = finalizeEvent(responseEvent, this.localPrivkey)
this.ws?.send(JSON.stringify(['EVENT', signedResponse]))
// Resolve the connection promise
if (this.connectionResolve) {
this.connectionResolve(event.pubkey)
}
return
}
// Handle as normal response
const response = parsed as NIP46Response
const pending = this.pendingRequests.get(response.id)
if (pending) {

View File

@@ -68,6 +68,7 @@ type TNostrContext = {
nip07Login: () => Promise<string>
npubLogin(npub: string): Promise<string>
bunkerLogin: (bunkerUrl: string) => Promise<string>
bunkerLoginWithSigner: (signer: BunkerSigner, pubkey: string) => Promise<string>
removeAccount: (account: TAccountPointer) => void
/**
* Default publish the event to current relays, user's write relays and additional relays
@@ -527,8 +528,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const bunkerLogin = async (bunkerUrl: string) => {
try {
const { pubkey: bunkerPubkey, relays, secret } = parseBunkerUrl(bunkerUrl)
const bunkerSigner = new BunkerSigner(bunkerPubkey, relays, secret)
const { pubkey: bunkerPubkey, relays, secret, catToken } = parseBunkerUrl(bunkerUrl)
const bunkerSigner = new BunkerSigner(bunkerPubkey, relays, secret, catToken)
await bunkerSigner.init()
const pubkey = await bunkerSigner.getPublicKey()
return login(bunkerSigner, {
@@ -536,7 +537,27 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
signerType: 'bunker',
bunkerPubkey,
bunkerRelays: relays,
bunkerSecret: secret
bunkerSecret: secret,
bunkerCatToken: catToken
})
} catch (err) {
toast.error(t('Bunker login failed') + ': ' + (err as Error).message)
throw err
}
}
/**
* Login with an already-connected BunkerSigner instance.
* Used for the nostr+connect flow where we wait for signer to connect.
*/
const bunkerLoginWithSigner = async (signer: BunkerSigner, pubkey: string) => {
try {
return login(signer, {
pubkey,
signerType: 'bunker',
bunkerPubkey: signer.getBunkerPubkey(),
bunkerRelays: signer.getRelayUrls(),
bunkerSecret: undefined
})
} catch (err) {
toast.error(t('Bunker login failed') + ': ' + (err as Error).message)
@@ -594,7 +615,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const bunkerSigner = new BunkerSigner(
account.bunkerPubkey,
account.bunkerRelays,
account.bunkerSecret
account.bunkerSecret,
account.bunkerCatToken
)
await bunkerSigner.init()
return login(bunkerSigner, account)
@@ -828,6 +850,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
nip07Login,
npubLogin,
bunkerLogin,
bunkerLoginWithSigner,
removeAccount,
publish,
attemptDelete,

View File

@@ -109,6 +109,7 @@ export type TAccount = {
bunkerPubkey?: string
bunkerRelays?: string[]
bunkerSecret?: string
bunkerCatToken?: string
}
export type TAccountPointer = Pick<TAccount, 'pubkey' | 'signerType'>