Set up literate programming structure
This commit is contained in:
+11
@@ -0,0 +1,11 @@
|
|||||||
|
ref
|
||||||
|
|
||||||
|
# Generated source (tangled from book/)
|
||||||
|
coracle-lib/src/
|
||||||
|
coracle-net/src/
|
||||||
|
coracle-signer/src/
|
||||||
|
coracle-content/src/
|
||||||
|
coracle-storage/src/
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
target/
|
||||||
Generated
+321
@@ -0,0 +1,321 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "2.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cc"
|
||||||
|
version = "1.2.59"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283"
|
||||||
|
dependencies = [
|
||||||
|
"find-msvc-tools",
|
||||||
|
"shlex",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coracle-content"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"coracle-lib",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coracle-lib"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"hex",
|
||||||
|
"secp256k1",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coracle-net"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"coracle-lib",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coracle-signer"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"coracle-lib",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coracle-storage"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"coracle-lib",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coracle-tangle"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"pulldown-cmark",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.10.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer",
|
||||||
|
"crypto-common",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "find-msvc-tools"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "generic-array"
|
||||||
|
version = "0.14.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
"version_check",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getopts"
|
||||||
|
version = "0.2.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-width",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hex"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.184"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.106"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pulldown-cmark"
|
||||||
|
version = "0.12.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"getopts",
|
||||||
|
"memchr",
|
||||||
|
"pulldown-cmark-escape",
|
||||||
|
"unicase",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pulldown-cmark-escape"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "secp256k1"
|
||||||
|
version = "0.29.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
|
||||||
|
dependencies = [
|
||||||
|
"secp256k1-sys",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "secp256k1-sys"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.149"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.10.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.117"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typenum"
|
||||||
|
version = "1.19.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-width"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "version_check"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = [
|
||||||
|
"coracle-tangle",
|
||||||
|
"coracle-lib",
|
||||||
|
"coracle-net",
|
||||||
|
"coracle-signer",
|
||||||
|
"coracle-content",
|
||||||
|
"coracle-storage",
|
||||||
|
]
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
This is a monorepo aimed at developers building nostr applications in rust. All crates are prefixed by the namespace `coracle`.
|
||||||
|
|
||||||
|
It has the following crates:
|
||||||
|
|
||||||
|
- `coracle-lib` - Struct definitions, stateless utilities related to nostr.
|
||||||
|
- `coracle-net` - Networking utilities for working with relays
|
||||||
|
- `coracle-signer` - Signer client/server utilities
|
||||||
|
- `coracle-content` - Text parsing and rendering utilities
|
||||||
|
- `coracle-storage` - Storage adapters for different platforms
|
||||||
|
|
||||||
|
All code is written in a [literate programming](https://en.wikipedia.org/wiki/Literate_programming) style and compiled to both html documentation and rust source code. The goal of this repository is threefold:
|
||||||
|
|
||||||
|
- To create a complete resource for learning how to work with the nostr protocol for humans
|
||||||
|
- To create a production-ready nostr utility library for inclusion in rust, KMP, and web projects.
|
||||||
|
- To experiment with using literate programming to serve both as a library and context file for LLMs.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- [Rust](https://rustup.rs/)
|
||||||
|
- [just](https://github.com/casey/just)
|
||||||
|
- [mdbook](https://rust-lang.github.io/mdBook/) (`cargo install mdbook`)
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
The source of truth is the `book/` directory, which contains markdown files written in a literate programming style. Code blocks annotated with a file path are extracted ("tangled") into Rust source files by the `coracle-tangle` tool.
|
||||||
|
|
||||||
|
For example, a code block like this in a markdown file:
|
||||||
|
|
||||||
|
```text
|
||||||
|
```rust {file=coracle-lib/src/event.rs}
|
||||||
|
pub struct Event { ... }
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
will be extracted to `coracle-lib/src/event.rs`. Multiple blocks targeting the same file are concatenated in document order, so you can introduce a struct in one section and add methods to it later in the narrative.
|
||||||
|
|
||||||
|
Code blocks without a `{file=...}` annotation are illustrative only and are not tangled.
|
||||||
|
|
||||||
|
The `src/` directories of the library crates are generated artifacts and should not be edited directly. Edit the markdown in `book/` instead.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Tangle: extract code from markdown into .rs files
|
||||||
|
just tangle
|
||||||
|
|
||||||
|
# Build: tangle + compile all library crates
|
||||||
|
just build
|
||||||
|
|
||||||
|
# Check: tangle + type-check without full compilation
|
||||||
|
just check
|
||||||
|
|
||||||
|
# Weave: tangle + generate the HTML book (output in target/book/)
|
||||||
|
just weave
|
||||||
|
|
||||||
|
# Clean: remove all generated source and book output
|
||||||
|
just clean
|
||||||
|
|
||||||
|
# All: build + weave
|
||||||
|
just all
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
book/ # Literate source (the single source of truth)
|
||||||
|
book.toml # mdBook configuration
|
||||||
|
SUMMARY.md # Table of contents
|
||||||
|
01-introduction.md
|
||||||
|
02-events.md # etc.
|
||||||
|
coracle-tangle/ # The tangle/weave tool (Rust binary)
|
||||||
|
coracle-lib/ # Core nostr types and utilities (generated src/)
|
||||||
|
coracle-net/ # Relay networking (generated src/)
|
||||||
|
coracle-signer/ # Signing abstractions (generated src/)
|
||||||
|
coracle-content/ # Content parsing (generated src/)
|
||||||
|
coracle-storage/ # Storage adapters (generated src/)
|
||||||
|
```
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Introduction
|
||||||
|
|
||||||
|
Nostr is a simple, open protocol for decentralized social networking. Unlike traditional
|
||||||
|
platforms, nostr has no central server. Instead, users publish signed messages called
|
||||||
|
**events** to a network of **relays** — simple WebSocket servers that store and forward
|
||||||
|
events.
|
||||||
|
|
||||||
|
The protocol's power comes from its simplicity. At its core, nostr defines just one data
|
||||||
|
structure (the event) and a handful of message types for communicating with relays. Everything
|
||||||
|
else — social graphs, encrypted messaging, long-form content, marketplace listings — is built
|
||||||
|
on top of this foundation through a system of **NIPs** (Nostr Implementation Possibilities),
|
||||||
|
which are community-authored specifications.
|
||||||
|
|
||||||
|
## What this book covers
|
||||||
|
|
||||||
|
This book is both a tutorial and the source code for the `coracle` family of Rust crates:
|
||||||
|
|
||||||
|
- **coracle-lib** — Core types and stateless utilities: events, keys, tags, filters, and
|
||||||
|
serialization. Everything you need to understand and manipulate nostr data.
|
||||||
|
- **coracle-net** — Networking: connecting to relays, managing subscriptions, publishing
|
||||||
|
events, and relay discovery.
|
||||||
|
- **coracle-signer** — Signing abstractions: local key signing, NIP-46 remote signing,
|
||||||
|
and browser extension integration.
|
||||||
|
- **coracle-content** — Content handling: parsing note text, rendering mentions, handling
|
||||||
|
media links, and working with NIP-27 references.
|
||||||
|
- **coracle-storage** — Persistence: storing and querying events locally across different
|
||||||
|
platforms and backends.
|
||||||
|
|
||||||
|
The chapters are ordered so that each concept builds on what came before. Code blocks marked
|
||||||
|
with a file path are **tangled** — extracted and assembled into Rust source files that form the
|
||||||
|
actual library. What you're reading *is* the source code.
|
||||||
|
|
||||||
|
## How literate programming works here
|
||||||
|
|
||||||
|
Throughout this book, you'll see code blocks annotated with an output file path like this:
|
||||||
|
|
||||||
|
```text
|
||||||
|
```rust {file=coracle-lib/src/lib.rs}
|
||||||
|
pub mod event;
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
These blocks are the real source code. The `coracle-tangle` tool extracts them in document
|
||||||
|
order and writes them to the indicated file paths. Multiple blocks targeting the same file are
|
||||||
|
concatenated, so a struct can be introduced in one section and have methods added later in the
|
||||||
|
narrative.
|
||||||
|
|
||||||
|
Code blocks *without* a file annotation are illustrative — they show examples, intermediate
|
||||||
|
states, or protocol concepts without contributing to the compiled output.
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
# Events
|
||||||
|
|
||||||
|
Everything in nostr is an **event**. A short note, a profile update, a reaction, a relay list —
|
||||||
|
they're all events. An event is a JSON object signed by its author's cryptographic key. Once
|
||||||
|
signed, it cannot be altered without invalidating the signature, which means any relay or client
|
||||||
|
can verify that an event is authentic without trusting anyone.
|
||||||
|
|
||||||
|
## The event structure
|
||||||
|
|
||||||
|
Here's what a nostr event looks like as JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "4376c65d2f232afbe9b882a35baa4f6fe8bce184396e157b1a35b3f01b3e285c",
|
||||||
|
"pubkey": "6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93",
|
||||||
|
"created_at": 1673347337,
|
||||||
|
"kind": 1,
|
||||||
|
"tags": [
|
||||||
|
["e", "3da979448d9ba263864c4d6f14984c423a3838364ec255f03c7904b1ae77f206"],
|
||||||
|
["p", "bf2376e17ba4ec269d10fcc996a4746b451152be9031fa48e74553dde5526bce"]
|
||||||
|
],
|
||||||
|
"content": "Hello, nostr!",
|
||||||
|
"sig": "908a15e46fb4d8675bab026fc230a0e3542bfade63da02d542fb78b2a8513fcd..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Let's define this in Rust. We'll start with the module declaration:
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/lib.rs}
|
||||||
|
pub mod event;
|
||||||
|
```
|
||||||
|
|
||||||
|
And now the `Event` struct itself. Each field maps directly to the JSON above:
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/event.rs}
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Sha256, Digest};
|
||||||
|
|
||||||
|
/// A nostr event — the fundamental data type of the protocol.
|
||||||
|
///
|
||||||
|
/// Every interaction on nostr is represented as a signed event. The `id` is a
|
||||||
|
/// SHA-256 hash of the canonical serialization, and the `sig` is a Schnorr
|
||||||
|
/// signature over that hash using the author's private key.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Event {
|
||||||
|
/// The event ID: a 32-byte hex-encoded SHA-256 hash of the canonical serialization.
|
||||||
|
pub id: String,
|
||||||
|
/// The author's public key: a 32-byte hex-encoded secp256k1 public key (x-only).
|
||||||
|
pub pubkey: String,
|
||||||
|
/// Unix timestamp in seconds when the event was created.
|
||||||
|
pub created_at: u64,
|
||||||
|
/// The event kind determines how the event should be interpreted.
|
||||||
|
/// Kind 0 = metadata, kind 1 = short text note, kind 3 = contacts, etc.
|
||||||
|
pub kind: u16,
|
||||||
|
/// A list of tags. Each tag is an array of strings where the first element
|
||||||
|
/// identifies the tag type. Common tags: ["e", <event-id>], ["p", <pubkey>].
|
||||||
|
pub tags: Vec<Vec<String>>,
|
||||||
|
/// The event content. Interpretation depends on the kind.
|
||||||
|
pub content: String,
|
||||||
|
/// A 64-byte hex-encoded Schnorr signature of the event ID.
|
||||||
|
pub sig: String,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Computing the event ID
|
||||||
|
|
||||||
|
The event ID is not chosen by the author — it's *derived* from the event's content via a
|
||||||
|
deterministic process. This is what makes events tamper-proof: change any field and the ID
|
||||||
|
changes, which invalidates the signature.
|
||||||
|
|
||||||
|
The ID is the SHA-256 hash of a specific JSON serialization called the **canonical form**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[0, <pubkey>, <created_at>, <kind>, <tags>, <content>]
|
||||||
|
```
|
||||||
|
|
||||||
|
That leading `0` is a version number reserved for future use. The array is serialized as
|
||||||
|
compact JSON (no whitespace) and UTF-8 encoded before hashing.
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/event.rs}
|
||||||
|
impl Event {
|
||||||
|
/// Serialize this event into the canonical form used for ID computation.
|
||||||
|
/// The result is: `[0, pubkey, created_at, kind, tags, content]`
|
||||||
|
pub fn serialize(&self) -> String {
|
||||||
|
serde_json::to_string(
|
||||||
|
&serde_json::json!([0, self.pubkey, self.created_at, self.kind, self.tags, self.content])
|
||||||
|
).expect("event serialization should never fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the event ID by SHA-256 hashing the canonical serialization.
|
||||||
|
pub fn compute_id(&self) -> String {
|
||||||
|
let serialized = self.serialize();
|
||||||
|
let hash = Sha256::digest(serialized.as_bytes());
|
||||||
|
hex::encode(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether the event's `id` field matches the computed hash.
|
||||||
|
pub fn id_is_valid(&self) -> bool {
|
||||||
|
self.id == self.compute_id()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verifying signatures
|
||||||
|
|
||||||
|
Nostr uses **Schnorr signatures** over the secp256k1 curve — the same curve used by Bitcoin.
|
||||||
|
The signature is computed over the event ID (which is already a hash), and can be verified
|
||||||
|
using the author's public key.
|
||||||
|
|
||||||
|
This means anyone can verify an event's authenticity without contacting the author or any
|
||||||
|
trusted authority. The math guarantees it.
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/event.rs}
|
||||||
|
impl Event {
|
||||||
|
/// Verify the event's Schnorr signature against its public key and ID.
|
||||||
|
/// Returns `true` if the signature is valid and the ID matches the content.
|
||||||
|
pub fn verify(&self) -> bool {
|
||||||
|
if !self.id_is_valid() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let id_bytes = match hex::decode(&self.id) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let sig_bytes = match hex::decode(&self.sig) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let pubkey_bytes = match hex::decode(&self.pubkey) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let msg = match secp256k1::Message::from_digest_slice(&id_bytes) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let sig = match secp256k1::schnorr::Signature::from_slice(&sig_bytes) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
let pubkey = match secp256k1::XOnlyPublicKey::from_slice(&pubkey_bytes) {
|
||||||
|
Ok(k) => k,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
sig.verify(&msg, &pubkey).is_ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating events
|
||||||
|
|
||||||
|
To create an event, you provide the content fields and sign with a secret key. The process is:
|
||||||
|
|
||||||
|
1. Fill in `pubkey`, `created_at`, `kind`, `tags`, and `content`
|
||||||
|
2. Compute the `id` from the canonical serialization
|
||||||
|
3. Sign the `id` with the secret key to produce `sig`
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/event.rs}
|
||||||
|
impl Event {
|
||||||
|
/// Create a new signed event from the given fields and secret key.
|
||||||
|
///
|
||||||
|
/// The `pubkey`, `id`, and `sig` fields are computed automatically.
|
||||||
|
pub fn new(
|
||||||
|
kind: u16,
|
||||||
|
content: &str,
|
||||||
|
tags: Vec<Vec<String>>,
|
||||||
|
created_at: u64,
|
||||||
|
secret_key: &secp256k1::SecretKey,
|
||||||
|
) -> Self {
|
||||||
|
let secp = secp256k1::Secp256k1::new();
|
||||||
|
let keypair = secp256k1::Keypair::from_secret_key(&secp, secret_key);
|
||||||
|
let (pubkey, _parity) = keypair.x_only_public_key();
|
||||||
|
|
||||||
|
let mut event = Event {
|
||||||
|
id: String::new(),
|
||||||
|
pubkey: hex::encode(pubkey.serialize()),
|
||||||
|
created_at,
|
||||||
|
kind,
|
||||||
|
tags,
|
||||||
|
content: content.to_string(),
|
||||||
|
sig: String::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
event.id = event.compute_id();
|
||||||
|
|
||||||
|
let id_bytes = hex::decode(&event.id).expect("just computed, must be valid hex");
|
||||||
|
let msg = secp256k1::Message::from_digest_slice(&id_bytes)
|
||||||
|
.expect("SHA-256 output is always 32 bytes");
|
||||||
|
let sig = secp.sign_schnorr_no_aux_rand(&msg, &keypair);
|
||||||
|
event.sig = hex::encode(sig.serialize());
|
||||||
|
|
||||||
|
event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
With this, you can create and verify events — the atomic unit of all nostr communication. In
|
||||||
|
the next chapter, we'll look at how keys work in more detail and introduce signing abstractions
|
||||||
|
that go beyond a raw secret key.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Summary
|
||||||
|
|
||||||
|
- [Introduction](01-introduction.md)
|
||||||
|
- [Events](02-events.md)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[book]
|
||||||
|
title = "Coracle: A Nostr Library"
|
||||||
|
description = "A literate programming approach to building nostr applications in Rust"
|
||||||
|
authors = ["Jonathan Staab"]
|
||||||
|
language = "en"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
build-dir = "../../target/book"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[package]
|
||||||
|
name = "coracle-content"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Text parsing and rendering utilities for nostr"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
coracle-lib = { path = "../coracle-lib" }
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "coracle-lib"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Struct definitions and stateless utilities related to nostr"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
sha2 = "0.10"
|
||||||
|
secp256k1 = { version = "0.29", features = ["global-context", "serde"] }
|
||||||
|
hex = "0.4"
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[package]
|
||||||
|
name = "coracle-net"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Networking utilities for working with nostr relays"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
coracle-lib = { path = "../coracle-lib" }
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[package]
|
||||||
|
name = "coracle-signer"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Signer client/server utilities for nostr"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
coracle-lib = { path = "../coracle-lib" }
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[package]
|
||||||
|
name = "coracle-storage"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Storage adapters for nostr on different platforms"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
coracle-lib = { path = "../coracle-lib" }
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "coracle-tangle"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Literate programming tangle/weave tool for the coracle monorepo"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "coracle-tangle"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
pulldown-cmark = "0.12"
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
use pulldown_cmark::{Event, Parser, Tag, TagEnd, CodeBlockKind};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
fn extract_file_attr(info_string: &str) -> Option<&str> {
|
||||||
|
// Parse info strings like "rust {file=coracle-lib/src/event.rs}"
|
||||||
|
let brace_start = info_string.find('{')?;
|
||||||
|
let brace_end = info_string.find('}')?;
|
||||||
|
let attrs = &info_string[brace_start + 1..brace_end];
|
||||||
|
for attr in attrs.split(',') {
|
||||||
|
let attr = attr.trim();
|
||||||
|
if let Some(path) = attr.strip_prefix("file=") {
|
||||||
|
return Some(path.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tangle(book_dir: &Path, output_base: &Path) {
|
||||||
|
let mut files: BTreeMap<PathBuf, String> = BTreeMap::new();
|
||||||
|
|
||||||
|
// Collect all .md files sorted by name
|
||||||
|
let mut md_files: Vec<PathBuf> = fs::read_dir(book_dir)
|
||||||
|
.expect("Failed to read book directory")
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.map(|e| e.path())
|
||||||
|
.filter(|p| p.extension().is_some_and(|ext| ext == "md"))
|
||||||
|
.collect();
|
||||||
|
md_files.sort();
|
||||||
|
|
||||||
|
for md_path in &md_files {
|
||||||
|
let content = fs::read_to_string(md_path)
|
||||||
|
.unwrap_or_else(|_| panic!("Failed to read {}", md_path.display()));
|
||||||
|
|
||||||
|
let parser = Parser::new(&content);
|
||||||
|
let mut current_file: Option<PathBuf> = None;
|
||||||
|
let mut in_code_block = false;
|
||||||
|
|
||||||
|
for event in parser {
|
||||||
|
match event {
|
||||||
|
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(info))) => {
|
||||||
|
let info_str = info.as_ref();
|
||||||
|
if let Some(file_path) = extract_file_attr(info_str) {
|
||||||
|
current_file = Some(output_base.join(file_path));
|
||||||
|
in_code_block = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Text(text) if in_code_block => {
|
||||||
|
if let Some(ref path) = current_file {
|
||||||
|
files.entry(path.clone())
|
||||||
|
.or_default()
|
||||||
|
.push_str(text.as_ref());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::End(TagEnd::CodeBlock) => {
|
||||||
|
if in_code_block {
|
||||||
|
// Add a newline between concatenated blocks
|
||||||
|
if let Some(ref path) = current_file {
|
||||||
|
files.entry(path.clone())
|
||||||
|
.or_default()
|
||||||
|
.push('\n');
|
||||||
|
}
|
||||||
|
current_file = None;
|
||||||
|
in_code_block = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if files.is_empty() {
|
||||||
|
println!("No tangled files found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (path, content) in &files {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.unwrap_or_else(|_| panic!("Failed to create directory {}", parent.display()));
|
||||||
|
}
|
||||||
|
fs::write(path, content)
|
||||||
|
.unwrap_or_else(|_| panic!("Failed to write {}", path.display()));
|
||||||
|
println!(" tangled: {}", path.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n{} files tangled.", files.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn weave(book_dir: &Path, output_dir: &Path) {
|
||||||
|
// Preprocess markdown files: transform {file=...} annotations into
|
||||||
|
// readable captions for mdBook, writing to a temp directory.
|
||||||
|
let staging = output_dir.join("book-staging");
|
||||||
|
if staging.exists() {
|
||||||
|
fs::remove_dir_all(&staging).expect("Failed to clean staging directory");
|
||||||
|
}
|
||||||
|
let staging_src = staging.join("src");
|
||||||
|
fs::create_dir_all(&staging_src).expect("Failed to create staging src directory");
|
||||||
|
|
||||||
|
for entry in fs::read_dir(book_dir)
|
||||||
|
.expect("Failed to read book directory")
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
{
|
||||||
|
let path = entry.path();
|
||||||
|
let filename = path.file_name().unwrap();
|
||||||
|
|
||||||
|
if path.file_name().is_some_and(|n| n == "book.toml") {
|
||||||
|
// book.toml goes at the staging root
|
||||||
|
let content = fs::read_to_string(&path)
|
||||||
|
.unwrap_or_else(|_| panic!("Failed to read {}", path.display()));
|
||||||
|
let dest = staging.join(filename);
|
||||||
|
fs::write(&dest, content)
|
||||||
|
.unwrap_or_else(|_| panic!("Failed to write {}", dest.display()));
|
||||||
|
} else if path.extension().is_some_and(|ext| ext == "md") {
|
||||||
|
// Markdown files go into staging/src/
|
||||||
|
let content = fs::read_to_string(&path)
|
||||||
|
.unwrap_or_else(|_| panic!("Failed to read {}", path.display()));
|
||||||
|
let transformed = transform_for_weave(&content);
|
||||||
|
let dest = staging_src.join(filename);
|
||||||
|
fs::write(&dest, transformed)
|
||||||
|
.unwrap_or_else(|_| panic!("Failed to write {}", dest.display()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Preprocessed markdown written to {}", staging.display());
|
||||||
|
println!("Run: mdbook build {}", staging.display());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transform_for_weave(content: &str) -> String {
|
||||||
|
let mut result = String::with_capacity(content.len());
|
||||||
|
for line in content.lines() {
|
||||||
|
if line.starts_with("```") && line.contains("{file=") {
|
||||||
|
// Extract language and file path
|
||||||
|
let lang = line.trim_start_matches('`')
|
||||||
|
.split_whitespace()
|
||||||
|
.next()
|
||||||
|
.unwrap_or("rust");
|
||||||
|
if let Some(file_path) = extract_file_attr(line) {
|
||||||
|
result.push_str(&format!("```{lang}\n// -> {file_path}\n"));
|
||||||
|
} else {
|
||||||
|
result.push_str(line);
|
||||||
|
result.push('\n');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.push_str(line);
|
||||||
|
result.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
|
||||||
|
let (command, book_dir) = match args.len() {
|
||||||
|
1 => ("tangle", "book"),
|
||||||
|
2 => (args[1].as_str(), "book"),
|
||||||
|
3 => (args[1].as_str(), args[2].as_str()),
|
||||||
|
_ => {
|
||||||
|
eprintln!("Usage: coracle-tangle [tangle|weave] [book-dir]");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let book_path = Path::new(book_dir);
|
||||||
|
let output_base = Path::new(".");
|
||||||
|
|
||||||
|
match command {
|
||||||
|
"tangle" => tangle(book_path, output_base),
|
||||||
|
"weave" => weave(book_path, Path::new("target")),
|
||||||
|
_ => {
|
||||||
|
eprintln!("Unknown command: {command}. Use 'tangle' or 'weave'.");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
tangle:
|
||||||
|
cargo run -p coracle-tangle -- tangle book/
|
||||||
|
|
||||||
|
build: tangle
|
||||||
|
cargo build --workspace --exclude coracle-tangle
|
||||||
|
|
||||||
|
weave: tangle
|
||||||
|
cargo run -p coracle-tangle -- weave book/
|
||||||
|
mdbook build target/book-staging/
|
||||||
|
|
||||||
|
check: tangle
|
||||||
|
cargo check --workspace --exclude coracle-tangle
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf coracle-lib/src/ coracle-net/src/ coracle-signer/src/ coracle-content/src/ coracle-storage/src/
|
||||||
|
rm -rf target/book/ target/book-staging/
|
||||||
|
|
||||||
|
all: build weave
|
||||||
Reference in New Issue
Block a user