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
- Composition over reinvention - Leverages existing tools (nak, remote signers) rather than reimplementing cryptographic protocols
- No key management - This tool never touches your private keys; signing is delegated entirely to NIP-46 remote signers
- 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:
- Obtain a bunker URI from your NIP-46 signer
- Ensure the relay in the URI is accessible from your machine
- Configure nostr-publish with the bunker URI (see Configuration)
- 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(oradd-hook) is required to enablenostr-publish-mode, which provides theC-c C-pkeybinding. This minor mode binding takes precedence over markdown-mode's defaultC-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:
--bunkeris required for publishing but not needed with--dry-run. Use--dry-runto 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:
- Frontmatter specifies
relays: Only those relays are used (must all be in CLI allowlist) - Frontmatter specifies
relays: ["*"]: All CLI relays are used - 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
- Specification - Complete technical specification (v1.0)
- Local Development - From source setup and local test stack
- Integration Tests - Test architecture and fixtures
- Troubleshooting - Common issues and solutions
- Publishing Guide - PyPI and MELPA release process
- User Stories - User personas, epics, and feature stories
Note on naming: The GitHub repository is
emacs-nostr-publishto reflect the Emacs-first focus, while both the PyPI and MELPA packages use the shorter namenostr-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
pytestdirectly (without make) only runs unit tests. This is configured inpyproject.tomlfor faster iteration. Usemake testormake test-e2eto 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
- nostr-publisher-cli - JavaScript CLI for fetching and publishing notes to Nostr
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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8fe14045ac9765985b603cf09b96f2b5d8009ed4c5a9958dc4d072e96fb1a1df
|
|
| MD5 |
a14913f1fe79652d471de1cc24604815
|
|
| BLAKE2b-256 |
db835bf4449c7f2225d1aa9bcba0c79fbfcbdc5fac8f075045611cda74dafa70
|
Provenance
The following attestation bundles were made for nostr_publish-0.1.0.tar.gz:
Publisher:
release.yml on 941design/emacs-nostr-publish
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
nostr_publish-0.1.0.tar.gz -
Subject digest:
8fe14045ac9765985b603cf09b96f2b5d8009ed4c5a9958dc4d072e96fb1a1df - Sigstore transparency entry: 795252658
- Sigstore integration time:
-
Permalink:
941design/emacs-nostr-publish@445be541f6b6e593b14f530036d30fcc9fd78324 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/941design
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@445be541f6b6e593b14f530036d30fcc9fd78324 -
Trigger Event:
push
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
01c727cc4613ffaab95890ff31f1c5232110a6b63869701faa8c50122973fb76
|
|
| MD5 |
91d1fde6f99a5db9ab65dae19c7f29fd
|
|
| BLAKE2b-256 |
4d5e24581e2fa97eb7b8fbf4d1fd6657ab2ce5752f553d8b50823bf9bc5a6281
|
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
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
nostr_publish-0.1.0-py3-none-any.whl -
Subject digest:
01c727cc4613ffaab95890ff31f1c5232110a6b63869701faa8c50122973fb76 - Sigstore transparency entry: 795252733
- Sigstore integration time:
-
Permalink:
941design/emacs-nostr-publish@445be541f6b6e593b14f530036d30fcc9fd78324 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/941design
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@445be541f6b6e593b14f530036d30fcc9fd78324 -
Trigger Event:
push
-
Statement type: