Skip to content

Repository files navigation

nostr-dm-magiclink-utils

npm version License: MIT TypeScript Node.js Version Test Coverage Build Status Dependencies Code Style: Prettier Author Bundle Size Downloads Languages Security

A comprehensive Nostr utility library for implementing secure, user-friendly authentication via magic links in direct messages. Built with TypeScript and following Nostr Improvement Proposals (NIPs) for maximum compatibility and security.

Release note โ€” v0.4.0 (staged, pending publish). Part of the coordinated 2026-07 correctness pass across the Nostr library family, verified against a shared known-answer vector set (NIP-44 v2 / NIP-49 / NIP-19 TLV / BIP-340). This release moves to canonical NIP-04 (via nostr-crypto-utils ^0.8.0) and emits standard kind-4 DMs; it is breaking โ€” see CHANGELOG.md. The family dogfoods only its own libraries โ€” no upstream nostr-tools dependency.

Features

  • ๐Ÿ” NIP-04 Compliant: Secure, encrypted direct messages emitted as standard kind 4 events, using the canonical nostr-crypto-utils encryptMessage / decryptMessage API
  • ๐ŸŒ Rich i18n Support: 9 languages with RTL support
  • ๐Ÿ”„ Multi-Relay Support: Reliable message delivery with automatic failover
  • ๐Ÿ›ก๏ธ Type-Safe: Full TypeScript support with comprehensive types
  • ๐Ÿ“ Flexible Templates: Customizable messages with variable interpolation
  • ๐Ÿš€ Modern API: Promise-based, async/await friendly interface
  • ๐ŸŽฏ Zero Config: Sensible defaults with optional deep customization

Installation

npm install nostr-dm-magiclink-utils

Quick Start

Here's a complete example showing how to set up and use the magic link service:

import { createNostrMagicLink, NostrError } from 'nostr-dm-magiclink-utils';
import { generateKeyPair } from 'nostr-crypto-utils';

async function setupAuthService() {
  // Create manager with secure configuration
  const magicLink = createNostrMagicLink({
    nostr: {
      // In production, load from secure environment variable
      privateKey: process.env.NOSTR_PRIVATE_KEY || generateKeyPair().privateKey,
      relayUrls: [
        'wss://relay.damus.io',
        'wss://relay.nostr.band',
        'wss://nos.lol'
      ],
      // Optional: Configure connection timeouts
      connectionTimeout: 5000
    },
    magicLink: {
      verifyUrl: 'https://your-app.com/verify',
      // Async token generation with expiry
      token: async () => {
        const token = await generateSecureToken({
          expiresIn: '15m',
          length: 32
        });
        return token;
      },
      defaultLocale: 'en',
      // Optional: Custom message templates
      templates: {
        en: {
          subject: 'Login to {{appName}}',
          body: 'Click this secure link to log in: {{link}}\nValid for 15 minutes.'
        }
      }
    }
  });

  return magicLink;
}

// Example usage in an Express route handler
app.post('/auth/magic-link', async (req, res) => {
  try {
    const { pubkey } = req.body;
    
    if (!pubkey) {
      return res.status(400).json({ error: 'Missing pubkey' });
    }

    const magicLink = await setupAuthService();
    
    const result = await magicLink.sendMagicLink({
      recipientPubkey: pubkey,
      messageOptions: {
        locale: req.locale, // From i18n middleware
        variables: {
          appName: 'YourApp',
          username: req.body.username
        }
      }
    });

    if (result.success) {
      res.json({ 
        message: 'Magic link sent successfully',
        expiresIn: '15 minutes'
      });
    }
  } catch (error) {
    if (error instanceof NostrError) {
      // Handle specific Nostr-related errors
      res.status(400).json({ 
        error: error.message,
        code: error.code 
      });
    } else {
      // Handle unexpected errors
      res.status(500).json({ 
        error: 'Failed to send magic link' 
      });
    }
  }
});

Advanced Usage

Custom Error Handling

try {
  const result = await magicLink.sendMagicLink({
    recipientPubkey: pubkey,
    messageOptions: { locale: 'en' }
  });
  
  if (!result.success) {
    switch (result.error.code) {
      case 'RELAY_CONNECTION_FAILED':
        // Attempt reconnection or use fallback relay
        await magicLink.reconnect();
        break;
      case 'ENCRYPTION_FAILED':
        // Log encryption errors for debugging
        logger.error('Encryption failed:', result.error);
        break;
      case 'INVALID_PUBKEY':
        // Handle invalid recipient public key
        throw new UserError('Invalid recipient');
        break;
    }
  }
} catch (error) {
  // Handle other errors
}

Multi-Language Support

// Arabic (RTL) example
const result = await magicLink.sendMagicLink({
  recipientPubkey: pubkey,
  messageOptions: {
    locale: 'ar',
    // Optional: Override default template
    template: {
      subject: 'ุชุณุฌูŠู„ ุงู„ุฏุฎูˆู„ ุฅู„ู‰ {{appName}}',
      body: 'ุงู†ู‚ุฑ ููˆู‚ ู‡ุฐุง ุงู„ุฑุงุจุท ุงู„ุขู…ู† ู„ุชุณุฌูŠู„ ุงู„ุฏุฎูˆู„: {{link}}'
    },
    variables: {
      appName: 'ุชุทุจูŠู‚ูƒ',
      username: 'ุงู„ู…ุณุชุฎุฏู…'
    }
  }
});

Custom Token Generation

const magicLink = createNostrMagicLink({
  // ... other config
  magicLink: {
    verifyUrl: 'https://your-app.com/verify',
    token: async (recipientPubkey: string) => {
      // Generate a secure, short-lived token
      const token = await generateJWT({
        sub: recipientPubkey,
        exp: Math.floor(Date.now() / 1000) + (15 * 60), // 15 minutes
        jti: crypto.randomUUID(),
        iss: 'your-app'
      });
      
      // Optional: Store token in database for verification
      await db.tokens.create({
        token,
        pubkey: recipientPubkey,
        expiresAt: new Date(Date.now() + 15 * 60 * 1000)
      });
      
      return token;
    }
  }
});

Relay Management

const magicLink = createNostrMagicLink({
  nostr: {
    privateKey: process.env.NOSTR_PRIVATE_KEY,
    relayUrls: ['wss://relay1.com', 'wss://relay2.com'],
    // Advanced relay options
    relayOptions: {
      retryAttempts: 3,
      retryDelay: 1000,
      timeout: 5000,
      onError: async (error, relay) => {
        logger.error(`Relay ${relay} error:`, error);
        // Optionally switch to backup relay
        await magicLink.addRelay('wss://backup-relay.com');
      }
    }
  }
});

// Monitor relay status
magicLink.on('relay:connected', (relay) => {
  logger.info(`Connected to relay: ${relay}`);
});

magicLink.on('relay:disconnected', (relay) => {
  logger.warn(`Disconnected from relay: ${relay}`);
});

Direct Message Format

Magic links are delivered as standard NIP-04 encrypted direct messages (kind 4), so any Nostr client recognises and decrypts them. The default encryptionMode is 'nip04'. Setting encryptionMode: 'nip44' switches the payload to NIP-44 (ChaCha20 + HMAC) encryption while still emitting a kind-4 event.

Note: Earlier releases (โ‰ค 0.3.2) emitted the NIP-44 mode under a non-standard kind 44 that no client treated as a DM. As of 0.4.0, all direct messages are kind 4. The modern private-DM standard is NIP-17 (a kind 14 rumor sealed and gift-wrapped per NIP-59); it is planned as a future enhancement.

Replay Protection

Magic-link tokens are single-use: each token carries a unique jti, and once a token is verified its jti is recorded so it cannot be redeemed again (until its 15-minute JWT expiry).

By default this record is kept in an in-memory Map (InMemoryConsumedTokenStore). This is single-instance only โ€” replay protection does not survive a process restart and is not shared across horizontally-scaled instances or serverless invocations. For those deployments, inject a shared/persistent store:

import {
  MagicLinkManager,
  ConsumedTokenStore,
} from 'nostr-dm-magiclink-utils';

// Example: back replay protection with your own Redis/DB (keyed on jti + exp)
class RedisConsumedTokenStore implements ConsumedTokenStore {
  async has(jti: string): Promise<boolean> {
    return (await redis.exists(`jti:${jti}`)) === 1;
  }
  async set(jti: string, expiry: number): Promise<void> {
    const ttl = Math.max(1, expiry - Math.floor(Date.now() / 1000));
    await redis.set(`jti:${jti}`, '1', 'EX', ttl);
  }
  // cleanup is optional when the store has native TTL
}

const manager = new MagicLinkManager(
  nostrService,
  magicLinkConfig,
  undefined, // logger
  new RedisConsumedTokenStore(),
);

Security Best Practices

Dependency Vulnerability Status

We actively monitor and address security vulnerabilities in this codebase. npm audit --omit=dev reports zero vulnerabilities for this package โ€” there are no known security issues in production dependencies.

Any remaining npm audit findings are in development-only tooling (eslint, typescript-eslint, vitest, typedoc, etc.) and stem from transitive dependencies with no upstream fix available. These are devDependencies that are never included in the published package and pose no risk to consumers of this library. We monitor upstream fixes and update promptly when they become available.

  1. Private Key Management
    • Never hardcode private keys
    • Use secure environment variables
    • Rotate keys periodically
// Load private key securely
const privateKey = await loadPrivateKeyFromSecureStore();
if (!privateKey) {
  throw new Error('Missing required private key');
}
  1. Token Security

    • Use short expiration times (15-30 minutes)
    • Include necessary claims (sub, exp, jti)
    • Store tokens securely for verification
  2. Error Handling

    • Never expose internal errors to users
    • Log errors securely
    • Implement rate limiting
  3. Relay Security

    • Use trusted relays
    • Implement connection timeouts
    • Handle connection errors gracefully

Supported Languages

The library includes built-in support for:

  • ๐Ÿ‡บ๐Ÿ‡ธ English (en)
  • ๐Ÿ‡ช๐Ÿ‡ธ Spanish (es)
  • ๐Ÿ‡ซ๐Ÿ‡ท French (fr)
  • ๐Ÿ‡ฏ๐Ÿ‡ต Japanese (ja)
  • ๐Ÿ‡ฐ๐Ÿ‡ท Korean (ko)
  • ๐Ÿ‡จ๐Ÿ‡ณ Chinese (zh)
  • ๐Ÿ‡ง๐Ÿ‡ท Portuguese (pt)
  • ๐Ÿ‡ท๐Ÿ‡บ Russian (ru)
  • ๐Ÿ‡ธ๐Ÿ‡ฆ Arabic (ar) - with RTL support

Contributing

See CONTRIBUTING.md for development setup and guidelines.

License

MIT ยฉ vveerrgg

About

๐Ÿ” Secure, NIP-compliant magic link authentication for Nostr applications. Features encrypted DMs (NIP-04), multi-relay support, and i18n with RTL languages. Built with TypeScript for type-safe, passwordless authentication flows.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Releases

Sponsor this project

Packages

Contributors

Languages