2 Commits

Author SHA1 Message Date
Your Name
bad361a686 last commit before bundling nostr-tools ourselves 2025-09-13 10:22:57 -04:00
Your Name
025d66c096 extensions and nsec login working 2025-09-13 09:06:32 -04:00
8 changed files with 12371 additions and 0 deletions

260
lite/README.md Normal file
View File

@@ -0,0 +1,260 @@
# NOSTR_LOGIN_LITE
A minimal, dependency-light replacement for the current auth/UI stack that preserves all login methods and window.nostr surface.
## Features
- ✅ Single file distributable (`nostr-login-lite.bundle.js`)
- ✅ All major login methods: Extension, Local key, Read-only
- ✅ Compatible window.nostr facade
- ✅ NIP-04 and NIP-44 encryption (local)
- ✅ Minimal vanilla JavaScript modal
- ✅ No bulky frameworks (no Stencil/Tailwind)
- ✅ Just ~50KB gzipped (minus nostr-tools)
## Installation
```html
<!-- 1. Load nostr-tools (local bundle) -->
<script src="./lite/nostr.bundle.js"></script>
<!-- 2. Load NOSTR_LOGIN_LITE -->
<script src="./lite/nostr-login-lite.bundle.js"></script>
```
## Quick Start
```javascript
// Initialize with default options
await window.NOSTR_LOGIN_LITE.init({
theme: 'light',
relays: ['wss://relay.damus.io'],
methods: {
extension: true,
local: true,
readonly: true
}
});
// Launch login modal
window.NOSTR_LOGIN_LITE.launch();
```
## API Reference
### Initialization
```javascript
// Initialize the library
await window.NOSTR_LOGIN_LITE.init(options)
// Options
{
theme: 'light' | 'dark',
darkMode: boolean,
relays: string[], // Default relays for NIP-46
methods: {
connect: boolean, // NIP-46 providers (coming)
extension: boolean, // Browser extensions
local: boolean, // Local key generation
readonly: boolean, // Read-only mode
otp: boolean // OTP/DM (coming)
}
}
```
### User Interface
```javascript
// Launch authentication modal
window.NOSTR_LOGIN_LITE.launch(startScreen);
// Available screens
'login' // Auth selection
'signup' // Account creation
'switch' // Account switching
// Logout current user
window.NOSTR_LOGIN_LITE.logout();
// Set theme
window.NOSTR_LOGIN_LITE.setDarkMode(dark: boolean);
```
### Programmatic Auth
```javascript
// Set authentication manually
window.NOSTR_LOGIN_LITE.setAuth({
type: 'login' | 'signup' | 'logout',
method: 'extension' | 'local' | 'readonly' | 'connect' | 'otp',
pubkey: string, // Public key
secret: string // Private key (optional)
});
// Cancel ongoing auth flow
window.NOSTR_LOGIN_LITE.cancelNeedAuth();
```
### window.nostr Compatibility
All standard window.nostr methods work transparently:
```javascript
// Get current user's public key
const pubkey = await window.nostr.getPublicKey();
// Sign an event
const signed = await window.nostr.signEvent({
kind: 1,
content: 'Hello Nostr!',
tags: [],
created_at: Date.now()
});
// NIP-04 encrypt/decrypt
const encrypted = await window.nostr.nip04.encrypt(recipientPubkey, 'secret');
const decrypted = await window.nostr.nip04.decrypt(senderPubkey, encrypted);
// NIP-44 encrypt/decrypt (if available in nostr-tools)
const encrypted = await window.nostr.nip44.encrypt(recipientPubkey, 'secret');
const decrypted = await window.nostr.nip44.decrypt(senderPubkey, encrypted);
```
## Events
Listen for authentication events:
```javascript
// Auth state changes
window.addEventListener('nlAuth', (event) => {
console.log(event.detail);
// { type: 'login', pubkey: '...', method: 'local' }
});
// Dark mode changes
window.addEventListener('nlDarkMode', (event) => {
document.body.classList.toggle('dark', event.detail.dark);
});
// Auth URLs (for NIP-46)
window.addEventListener('nlAuthUrl', (event) => {
console.log('Auth URL:', event.detail.url);
});
// Logout events
window.addEventListener('nlLogout', () => {
console.log('User logged out');
});
```
## Architecture
```
NOSTR_LOGIN_LITE/
├── nostr-login-lite.bundle.js # Single distributable
├── core/
│ ├── nip46-client.js # NIP-46 transport over websockets
│ └── {other components} # Future components
├── ui/
│ └── modal.js # Vanilla JS modal
└── README.md # This file
```
### Key Components
- **Modal**: Lightweight modal using vanilla CSS
- **Store**: localStorage helpers for accounts/relays
- **LocalSigner**: Wraps nostr-tools for local signing
- **ExtensionBridge**: Detects and bridges browser extensions
- **NIP46Client**: Handles remote signing (NIP-46)
- **Relays**: Default relay management
## Browser Support
- Modern browsers with ES2018+ support
- WebWorkers (for NIP-46)
- localStorage (for account storage)
## Dependencies
This project uses a local copy of nostr-tools instead of loading from CDN:
- **nostr.bundle.js**: Local nostr-tools bundle (~200KB)
- **nostr-login-lite.bundle.js**: This library (~50KB gzipped)
## Size Comparison
```
nostr-tools ~200KB (served locally)
NOSTR_LOGIN_LITE ~50KB (gzipped)
Total: ~250KB
vs.
Full nostr-login: ~2.5MB+ framework dependencies
```
## Future Roadmap ⚠️
The following features are planned but not yet implemented:
- **NIP-46 Connect**: External signer integration
- **OTP/DM**: Server-side OTP delivery
- **Connect Wallet**: Wallet integration
- **Advanced UI**: Multi-screen flows
- **NIP-05 Profiles**: User discovery
- **Backup/Restore**: Key backup functionality
## Development
To work on the source files:
```bash
# Edit individual components
lite/core/nip46-client.js
lite/ui/modal.js
lite/nostr-login-lite.js
# Run bundler to create distribution
node lite/bundler.js
# Start dev server (from project root)
python3 -m http.server 8000
# Open test page
open http://localhost:8000/examples/simple-demo.html
```
### Local Bundle Setup
The project uses a local copy of nostr-tools:
- `lite/nostr.bundle.js` - Local nostr-tools bundle (serves from this location)
- `nostr-tools/` - Reference directory (excluded from git, not used in runtime)
- Examples load from: `../lite/nostr.bundle.js`
## Examples
See `examples/` directory:
- `simple-demo.html` - Basic functionality demo
- `full-test.html` - Comprehensive test suite
- `test-lite.html` - Minimal functionality test
## Migration from Full nostr-login
The lite version provides the same API surface, but is much smaller:
```javascript
// Before (full library)
import { init, launch, logout } from 'nostr-login';
// After (lite version)
await window.NOSTR_LOGIN_LITE.init();
window.NOSTR_LOGIN_LITE.launch();
window.NOSTR_LOGIN_LITE.logout();
```
## License
Same license as the original nostr-login project.
## Contributing
This is meant to be lightweight and focused. Contributions should maintain the minimal footprint while extending functionality.

97
lite/bundler-clean.js Normal file
View File

@@ -0,0 +1,97 @@
/**
* Clean bundler for NOSTR_LOGIN_LITE
* Removes problematic files and recreates bundle
*/
const fs = require('fs');
const path = require('path');
async function createCleanBundle() {
// First, remove the old bundle if it exists
const outputPath = path.join(__dirname, 'nostr-login-lite.bundle.js');
try {
if (fs.existsSync(outputPath)) {
fs.unlinkSync(outputPath);
}
} catch (e) {
console.log('No old bundle to remove');
}
const mainFile = path.join(__dirname, 'nostr-login-lite.js');
const nip46File = path.join(__dirname, 'core/nip46-client.js');
const modalFile = path.join(__dirname, 'ui/modal.js');
// Start with a clean header
let bundle = `/**
* NOSTR_LOGIN_LITE
* Single-file Nostr authentication library
* Generated on: ${new Date().toISOString()}
*/
`;
// Add section markers and combine files
const files = [
{ path: modalFile, name: 'modal.js' },
{ path: nip46File, name: 'nip46-client.js' },
{ path: mainFile, name: 'nostr-login-lite.js' }
];
for (const file of files) {
if (fs.existsSync(file.path)) {
const content = fs.readFileSync(file.path, 'utf8');
bundle += `\n// ======================================\n`;
bundle += `// ${file.name}\n`;
bundle += `// ======================================\n\n`;
// Clean the content by removing initial header comments
let lines = content.split('\n');
let contentStartIndex = 0;
// Skip the first 10 lines if they contain file headers
for (let i = 0; i < Math.min(10, lines.length); i++) {
const line = lines[i].trim();
if (line.startsWith('/**') || line.startsWith('*') || line.startsWith('/*') || line.startsWith('//') ||
line.includes('Copyright') || line.includes('@license') || line.includes('Licensed') || line.includes('©')) {
contentStartIndex = i + 1;
}
}
if (contentStartIndex > 0) {
lines = lines.slice(contentStartIndex);
}
bundle += lines.join('\n');
bundle += '\n\n';
console.log(`Added ${file.name}`);
} else {
console.warn(`File not found: ${file.path}`);
}
}
// Write the bundled file
fs.writeFileSync(outputPath, bundle, 'utf8');
const sizeKB = (bundle.length / 1024).toFixed(2);
console.log(`\n✅ Clean bundle created: ${outputPath}`);
console.log(`📏 Bundle size: ${sizeKB} KB`);
console.log(`📄 Total lines: ${bundle.split('\n').length}`);
// Verify the bundle starts correctly
const firstLines = bundle.split('\n').slice(0, 20).join('\n');
console.log('\n📋 First 20 lines:');
console.log(firstLines);
return bundle;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { createCleanBundle };
}
// Run if called directly
if (typeof require !== 'undefined' && require.main === module) {
createCleanBundle().catch(console.error);
}

86
lite/bundler.js Normal file
View File

@@ -0,0 +1,86 @@
/**
* Simple bundler for NOSTR_LOGIN_LITE
* Combines all files into a single distributable script
*/
const fs = require('fs');
const path = require('path');
async function bundleLite() {
const mainFile = path.join(__dirname, 'nostr-login-lite.js');
const nip46File = path.join(__dirname, 'core/nip46-client.js');
const modalFile = path.join(__dirname, 'ui/modal.js');
let bundle = `/**
* NOSTR_LOGIN_LITE
* Single-file Nostr authentication library
* Generated on: ${new Date().toISOString()}
*/
// ======================================
// Core Classes and Components
// ======================================
`;
// Read and combine files
const files = [modalFile, nip46File, mainFile];
for (const file of files) {
if (fs.existsSync(file)) {
let content = fs.readFileSync(file, 'utf8');
// Skip the initial comment and license if present
let lines = content.split('\n');
// Find and skip complete JSDoc blocks at the beginning
let skipUntil = 0;
let inJSDocBlock = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line.startsWith('/**')) {
inJSDocBlock = true;
skipUntil = i;
} else if (inJSDocBlock && line.startsWith('*/')) {
skipUntil = i;
break;
} else if (i < 10 && (line.startsWith('const') || line.startsWith('class') || line.startsWith('function'))) {
// Hit actual code before finding end of JSDoc block
inJSDocBlock = false;
break;
}
}
if (inJSDocBlock) {
lines = lines.slice(skipUntil + 1); // Skip the entire JSDoc block
} else {
// Fallback to old filtering (skip comment-like lines in first 10)
lines = lines.filter((line, index) => {
return index >= 10 || !line.trim().startsWith('*') && !line.trim().startsWith('//');
});
}
bundle += '\n// ======================================\n';
bundle += `// ${path.basename(file)}\n`;
bundle += '// ======================================\n\n';
bundle += lines.join('\n');
bundle += '\n\n';
}
}
// Write the bundled file
const outputPath = path.join(__dirname, 'nostr-login-lite.bundle.js');
fs.writeFileSync(outputPath, bundle);
console.log('Bundle created:', outputPath);
console.log('Bundle size:', (bundle.length / 1024).toFixed(2), 'KB');
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { bundleLite };
}
// Run if called directly
if (typeof require !== 'undefined' && require.main === module) {
bundleLite().catch(console.error);
}

398
lite/core/nip46-client.js Normal file
View File

@@ -0,0 +1,398 @@
/**
* NOSTR NIP-46 Client Implementation
* Minimal RPC over NostrTools.SimplePool for NOSTR_LOGIN_LITE
*/
class NIP46Client {
constructor() {
this.pool = null;
this.localSk = null;
this.localPk = null;
this.remotePk = null;
this.relays = [];
this.sub = null;
this.pendingRequests = {};
this.useNip44 = false;
this.iframeOrigin = null;
this.iframePort = null;
}
init(localSk, remotePk, relays, iframeOrigin) {
// Create SimplePool
this.pool = new window.NostrTools.SimplePool();
// Setup keys
this.localSk = localSk;
if (this.localSk) {
this.localPk = window.NostrTools.getPublicKey(this.localSk);
}
this.remotePk = remotePk;
this.relays = [...relays];
// Store iframe origin for future use
this.iframeOrigin = iframeOrigin;
console.log('NIP46Client initialized for', this.remotePk ? 'remote signer' : 'listening mode');
}
setUseNip44(use) {
this.useNip44 = use;
}
subscribeReplies() {
if (!this.pool || !this.localPk) return;
// Subscribe to replies to our pubkey on kind 24133 (NIP-46 methods)
this.sub = this.pool.sub(this.relays, [{
kinds: [24133],
'#p': [this.localPk]
}]);
this.sub.on('event', (event) => this.onEvent(event));
this.sub.on('eose', () => {
console.log('NIP-46 subscription caught up');
});
console.log('Subscribed to NIP-46 replies on relays:', this.relays);
}
unsubscribe() {
if (this.sub) {
this.sub.unsub();
this.sub = null;
}
}
async onEvent(event) {
console.log('NIP-46 event received:', event);
try {
const parsed = await this.parseEvent(event);
if (parsed) {
if (parsed.id && this.pendingRequests[parsed.id]) {
// Handle response
const handler = this.pendingRequests[parsed.id];
delete this.pendingRequests[parsed.id];
if (parsed.result !== undefined) {
handler.resolve(parsed.result);
} else if (parsed.error) {
handler.reject(new Error(parsed.error));
} else {
handler.reject(new Error('Invalid response format'));
}
} else if (parsed.method === 'auth_url') {
// Handle auth_url emissions (deduplication required)
this.emitAuthUrlIfNeeded(parsed.params[0]);
}
}
} catch (error) {
console.error('Error processing NIP-46 event:', error);
}
}
emitAuthUrlIfNeeded(url) {
// Deduplicate auth_url emissions - only emit if not recently shown
const lastUrl = sessionStorage.getItem('nl-last-auth-url');
if (lastUrl === url) {
console.log('Auth URL already shown, skipping duplicate:', url);
return;
}
sessionStorage.setItem('nl-last-auth-url', url);
console.log('New auth URL:', url);
// Emit event for UI
window.dispatchEvent(new CustomEvent('nlAuthUrl', { detail: { url } }));
}
async parseEvent(event) {
try {
let content = event.content;
// Determine encryption method based on content structure
if (content.length > 44) {
// Likely NIP-44 (encrypted)
if (this.localSk && event.pubkey) {
try {
content = window.NostrTools.nip44?.decrypt(this.localSk, event.pubkey, content);
} catch (e) {
console.warn('NIP-44 decryption failed, trying NIP-04...');
content = await window.NostrTools.nip04.decrypt(this.localSk, event.pubkey, content);
}
}
} else {
// Likely NIP-04
if (this.localSk && event.pubkey) {
content = await window.NostrTools.nip04.decrypt(this.localSk, event.pubkey, content);
}
}
const payload = JSON.parse(content);
console.log('Decrypted NIP-46 payload:', payload);
return {
id: payload.id,
method: payload.method,
params: payload.params,
result: payload.result,
error: payload.error,
event: event
};
} catch (e) {
console.error('Failed to parse event:', e);
return null;
}
}
async listen(nostrConnectSecret) {
return new Promise((resolve, reject) => {
if (!this.localPk) {
reject(new Error('No local pubkey available for listening'));
return;
}
// Subscribe to unsolicited events to our pubkey
let foundSecretOrAck = false;
const listenSub = this.pool.sub(this.relays, [{
kinds: [24133],
'#p': [this.localPk]
}]);
listenSub.on('event', async (event) => {
try {
const parsed = await this.parseEvent(event);
if (parsed && parsed.method === 'connect') {
// Accept if it's an ack or matches our secret
const [userPubkey, token] = parsed.params || [];
if (token === '' && parsed.result === 'ack') {
// Ack received
foundSecretOrAck = true;
listenSub.unsub();
resolve(event.pubkey);
} else if (token === nostrConnectSecret) {
// Secret match
foundSecretOrAck = true;
listenSub.unsub();
resolve(event.pubkey);
}
}
} catch (error) {
console.error('Error in listen mode:', error);
}
});
// Timeout after 5 minutes
setTimeout(() => {
if (!foundSecretOrAck) {
listenSub.unsub();
reject(new Error('Listen timeout - no signer connected'));
}
}, 300000);
});
}
async connect(token, perms) {
return new Promise(async (resolve, reject) => {
try {
const result = await this.sendRequest(
this.remotePk,
'connect',
[this.localPk, token || '', perms || ''],
24133,
(response) => {
if (response === 'ack') {
resolve(true);
} else {
reject(new Error('Connection not acknowledged'));
}
}
);
// Set 30 second timeout
setTimeout(() => reject(new Error('Connection timeout')), 30000);
} catch (error) {
reject(error);
}
});
}
async initUserPubkey(hint) {
if (hint) {
this.remotePk = hint;
return hint;
}
if (!this.remotePk) {
// Request get_public_key
return new Promise(async (resolve, reject) => {
try {
const pubkey = await this.sendRequest(
this.remotePk,
'get_public_key',
[],
24133
);
this.remotePk = pubkey;
resolve(pubkey);
} catch (error) {
reject(error);
}
});
}
return this.remotePk;
}
async sendRequest(remotePubkey, method, params, kind = 24133, cb) {
if (!this.pool || !this.localSk || !this.localPk) {
throw new Error('NIP46Client not properly initialized');
}
if (!remotePubkey) {
throw new Error('No remote pubkey specified');
}
const id = this._generateId();
// Create request event
const event = await this.createRequestEvent(id, remotePubkey, method, params, kind);
console.log('Sending NIP-46 request:', { id, method, params });
// Publish to relays
const pubs = await this.pool.publish(this.relays, event);
console.log('Published to relays, waiting for response...');
return new Promise((resolve, reject) => {
// Set timeout
const timeout = setTimeout(() => {
console.error('NIP-46 request timeout for id:', id);
delete this.pendingRequests[id];
reject(new Error(`Request timeout for ${method}`));
}, 60000); // 1 minute timeout
// Store handler
this.pendingRequests[id] = {
resolve: (result) => {
clearTimeout(timeout);
resolve(result);
},
reject: (error) => {
clearTimeout(timeout);
reject(error);
},
timestamp: Date.now()
};
// If callback provided, override resolve
if (cb) {
const originalResolve = this.pendingRequests[id].resolve;
this.pendingRequests[id].resolve = (result) => {
cb(result);
originalResolve(result);
};
}
});
}
async createRequestEvent(id, remotePubkey, method, params, kind = 24133) {
let content = JSON.stringify({
id,
method,
params
});
// Choose encryption method
let encrypted = content;
if (method !== 'create_account') {
// Use NIP-44 for non-account creation methods if available
if (this.useNip44 && window.NostrTools.nip44) {
encrypted = window.NostrTools.nip44.encrypt(this.localSk, remotePubkey, content);
} else {
// Fallback to NIP-04
encrypted = await window.NostrTools.nip04.encrypt(this.localSk, remotePubkey, content);
}
}
// Create event structure
const event = {
kind: kind,
content: encrypted,
tags: [
['p', remotePubkey]
],
created_at: Math.floor(Date.now() / 1000),
pubkey: this.localPk,
id: '', // Will be set by finalizeEvent
sig: '' // Will be set by finalizeEvent
};
// Sign the event
const signedEvent = window.NostrTools.finalizeEvent(event, this.localSk);
return signedEvent;
}
_generateId() {
return 'nl-' + Date.now() + '-' + Math.random().toString(36).substring(2, 15);
}
setWorkerIframePort(port) {
this.iframePort = port;
// Set up postMessage routing if needed
if (this.iframePort && this.iframeOrigin) {
this.iframePort.onmessage = (event) => {
if (event.origin !== this.iframeOrigin) {
console.warn('Ignoring message from unknown origin:', event.origin);
return;
}
console.log('Received iframe message:', event.data);
// Handle iframe messages
};
// Send keepalive
setInterval(() => {
if (this.iframePort) {
try {
this.iframePort.postMessage({ type: 'ping' });
} catch (e) {
console.warn('Iframe port closed');
this.iframePort = null;
}
}
}, 30000); // 30 seconds
}
}
teardown() {
this.unsubscribe();
if (this.iframePort) {
try {
this.iframePort.close();
} catch (e) {
console.warn('Error closing iframe port:', e);
}
this.iframePort = null;
}
if (this.pool) {
this.pool.close(this.relays);
this.pool = null;
}
// Clear all pending requests
for (const id in this.pendingRequests) {
this.pendingRequests[id].reject(new Error('Client teardown'));
}
this.pendingRequests = {};
}
}

File diff suppressed because it is too large Load Diff

1039
lite/nostr-login-lite.js Normal file

File diff suppressed because it is too large Load Diff

6860
lite/nostr.bundle.js Normal file

File diff suppressed because it is too large Load Diff

1090
lite/ui/modal.js Normal file

File diff suppressed because it is too large Load Diff