Skip to main content

Markdown to Nostr long-form publisher with NIP-46 remote signing

Project description

nostr-publish

Deterministic, cross-platform publisher for Nostr long-form content (NIP-23).

Publishes Markdown files with YAML frontmatter to Nostr relays using remote signing via NIP-46.

Rationale

  1. Composition over reinvention - Leverages existing tools (nak, remote signers) rather than reimplementing cryptographic protocols
  2. No key management - This tool never touches your private keys; signing is delegated entirely to NIP-46 remote signers
  3. Bridge to the best editor - Brings Nostr publishing to Emacs, where long-form content belongs

Features

  • Markdown authoring: Write in Markdown with YAML frontmatter
  • Remote signing: NIP-46 support via any compatible signer
  • Deterministic: Same input always produces identical events
  • CLI + Emacs: Use from command line or Emacs (C-c C-p)
  • Strict validation: Fail-fast on invalid frontmatter

Prerequisites

  • nak CLI tool (v0.17.4+) for NIP-46 signing
  • NIP-46 compatible remote signer (any signer supporting NIP-46)

NIP-46 Remote Signing

nostr-publish uses NIP-46 (Nostr Connect) for remote signing, meaning your private keys never leave your signer application. The connection is established via a "bunker URI":

bunker://<signer-pubkey>?relay=wss://relay.example.com&secret=<optional-secret>

Components:

  • signer-pubkey: The hex public key of your signer
  • relay: The relay both client and signer connect to for message exchange
  • secret: Optional authentication token (required by some signers)

To use nostr-publish:

  1. Obtain a bunker URI from your NIP-46 signer
  2. Ensure the relay in the URI is accessible from your machine
  3. Configure nostr-publish with the bunker URI (see Configuration)
  4. Approve connection/signing requests in your signer when prompted

Security best practices:

  • Never share your bunker URI (it contains authentication credentials)
  • Use unique URIs per application
  • Review signing requests before approving
  • Rotate secrets periodically

For local development without a remote signer, see Local Setup.

Installation

From PyPI

pipx install nostr-publish

Or with pip: pip install nostr-publish

From Source

git clone https://github.com/941design/emacs-nostr-publish.git
cd emacs-nostr-publish

# Install to user space (adds nostr-publish to ~/.local/bin)
make install

# Or install globally (may require sudo)
pip install .

For development setup, see Local Setup.

Emacs Package

The Emacs package requires the CLI to be installed separately (see above).

From MELPA

;; Ensure MELPA is in your package-archives
(require 'package)
(add-to-list 'package-archives '("melpa" . "https://melpa.org/packages/") t)
(package-initialize)

;; Install
M-x package-refresh-contents
M-x package-install RET nostr-publish RET

Configuration

Basic setup with use-package:

(use-package nostr-publish
  :ensure t
  :hook (markdown-mode . nostr-publish-mode)
  :custom
  (nostr-publish-bunker-uri "bunker://pubkey?relay=wss://relay.example.com")
  (nostr-publish-default-relays '("wss://relay1.example.com" "wss://relay2.example.com"))
  (nostr-publish-timeout 60))

Or configure variables directly:

;; Enable nostr-publish-mode in markdown buffers (activates C-c C-p binding)
(add-hook 'markdown-mode-hook #'nostr-publish-mode)

;; Required: bunker URI for signing
(setq nostr-publish-bunker-uri "bunker://pubkey?relay=wss://relay.example.com")

;; Required: relay allowlist (also serves as defaults)
(setq nostr-publish-default-relays '("wss://relay1.example.com"
                                      "wss://relay2.example.com"))

;; Optional: signing timeout (seconds, default 30)
(setq nostr-publish-timeout 60)

Note: The :hook (or add-hook) is required to enable nostr-publish-mode, which provides the C-c C-p keybinding. This minor mode binding takes precedence over markdown-mode's default C-c C-p.

Directory-local configuration for project-specific settings (.dir-locals.el):

((markdown-mode
  . ((nostr-publish-bunker-uri . "bunker://project-pubkey?relay=wss://relay.example.com")
     (nostr-publish-default-relays . ("wss://project-relay.example.com")))))

Secure credential storage with auth-source:

;; Store in ~/.authinfo.gpg:
;; machine nostr-publish login bunker password bunker://pubkey?relay=...&secret=...

(setq nostr-publish-bunker-uri
      (auth-source-pick-first-password :host "nostr-publish" :user "bunker"))

For development with local source, see Local Setup.

Usage

CLI

# Publish to relay (--bunker and --relay are required)
nostr-publish article.md --bunker "bunker://..." --relay wss://relay.example.com

# Multiple relays (serves as allowlist and defaults)
nostr-publish article.md --bunker "bunker://..." --relay wss://relay1.example.com --relay wss://relay2.example.com

# Dry run: validate and construct event without publishing (--bunker not required)
nostr-publish article.md --relay wss://relay.example.com --dry-run

# Custom timeout for signer operations (default: 30 seconds)
nostr-publish article.md --bunker "bunker://..." --relay wss://relay.example.com --timeout 60

# Show version
nostr-publish --version

Note: --bunker is required for publishing but not needed with --dry-run. Use --dry-run to validate your article and preview the constructed event without a signer connection.

Emacs

Open a Markdown file and press C-c C-p to publish as long form content to nostr.

Frontmatter Format

---
title: Article Title          # Required
slug: article-slug            # Required (stable identifier)
summary: Short description    # Optional
published_at: 1700000000      # Optional (Unix timestamp)
tags:                         # Optional
  - nostr
  - writing
relays:                       # Optional (subset of CLI --relay allowlist)
  - wss://relay.example.com   # Must be in CLI allowlist
---

Available Fields

Field Required Type Description
title Yes string Article title (becomes "title" tag)
slug Yes string Stable identifier (becomes "d" tag)
summary No string Short description (becomes "summary" tag)
published_at No integer Unix timestamp (becomes "published_at" tag)
tags No list Hashtags (become "t" tags)
relays No list Subset of CLI relays (or "*" for all CLI relays)

Relay Precedence

CLI --relay arguments serve as both an allowlist and default relay set:

  1. Frontmatter specifies relays: Only those relays are used (must all be in CLI allowlist)
  2. Frontmatter specifies relays: ["*"]: All CLI relays are used
  3. Frontmatter omits relays: All CLI relays are used (same as ["*"])
# CLI: --relay wss://relay1 --relay wss://relay2 --relay wss://relay3

# Frontmatter: relays: [wss://relay1]
# Result: publishes to relay1 only

# Frontmatter: relays: [wss://relay4]
# Result: ERROR - relay4 not in allowlist

# Frontmatter: (no relays field)
# Result: publishes to relay1, relay2, relay3

See specs/spec.md for complete specification.

Documentation

Note on naming: The GitHub repository is emacs-nostr-publish to reflect the Emacs-first focus, while both the PyPI and MELPA packages use the shorter name nostr-publish.

Development

Setup

# Sync venv with dev dependencies
make sync-dev

# Or manually
uv sync --extra dev

Run Tests

# All tests (unit + integration)
make test

# Unit tests only (fast, no Docker required)
make test-unit

# Integration tests only (requires Docker, nak, Emacs 27.1+)
make test-e2e

Note: Running pytest directly (without make) only runs unit tests. This is configured in pyproject.toml for faster iteration. Use make test or make test-e2e to include integration tests.

Integration tests use Docker Compose to run end-to-end publishing tests against a real Nostr relay and NIP-46 signer. See docs/test-setup.md for details.

Available Make Targets

Run make help to see all available targets:

make build             # Build distribution packages
make clean             # Clean build artifacts
make dry-run           # Dry-run publish example (requires test fixture)
make format            # Auto-fix linting and formatting issues
make format-md         # Format markdown tables in all .md files
make help              # Show this help message
make install           # Install CLI tool globally via uv
make install-hooks     # Install git hooks
make lint              # Run linter and formatter check
make publish           # Publish to PyPI (requires UV_PUBLISH_TOKEN)
make publish-test      # Publish to TestPyPI
make stack-down        # Stop local test stack and remove volumes
make stack-up          # Start local test stack (relay + signer)
make sync              # Sync venv with production dependencies
make sync-dev          # Sync venv with dev dependencies
make test              # Run all tests
make test-e2e          # Run integration tests only
make test-unit         # Run unit tests only
make version-major     # Bump major version (0.1.0 -> 1.0.0)
make version-minor     # Bump minor version (0.1.0 -> 0.2.0)
make version-patch     # Bump patch version (0.1.0 -> 0.1.1)

Property-Based Testing

This project uses Hypothesis for property-based testing. All implementations include comprehensive property tests verifying invariants and edge cases across thousands of generated test cases.

Related Projects

Disclaimer

This project is 100% AI-generated using a spec-driven, property-based testing approach.

Use at your own risk. No warranty whatsoever is provided. The authors accept no responsibility if usage of this tool results in unintended content being posted.

If you want to show your appreciation, just say GM.

Author

Markus Rother mail@markusrother.de

License

GPLv3+

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

nostr_publish-0.1.0.tar.gz (33.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

nostr_publish-0.1.0-py3-none-any.whl (33.2 kB view details)

Uploaded Python 3

File details

Details for the file nostr_publish-0.1.0.tar.gz.

File metadata

  • Download URL: nostr_publish-0.1.0.tar.gz
  • Upload date:
  • Size: 33.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for nostr_publish-0.1.0.tar.gz
Algorithm Hash digest
SHA256 8fe14045ac9765985b603cf09b96f2b5d8009ed4c5a9958dc4d072e96fb1a1df
MD5 a14913f1fe79652d471de1cc24604815
BLAKE2b-256 db835bf4449c7f2225d1aa9bcba0c79fbfcbdc5fac8f075045611cda74dafa70

See more details on using hashes here.

Provenance

The following attestation bundles were made for nostr_publish-0.1.0.tar.gz:

Publisher: release.yml on 941design/emacs-nostr-publish

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file nostr_publish-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: nostr_publish-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 33.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for nostr_publish-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 01c727cc4613ffaab95890ff31f1c5232110a6b63869701faa8c50122973fb76
MD5 91d1fde6f99a5db9ab65dae19c7f29fd
BLAKE2b-256 4d5e24581e2fa97eb7b8fbf4d1fd6657ab2ce5752f553d8b50823bf9bc5a6281

See more details on using hashes here.

Provenance

The following attestation bundles were made for nostr_publish-0.1.0-py3-none-any.whl:

Publisher: release.yml on 941design/emacs-nostr-publish

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page