Add protected chapter
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
# Protected Events
|
||||
|
||||
The previous chapter introduced behavior tags through expiration: a tag
|
||||
that requests something of the network without touching what the event
|
||||
itself means. NIP-70 adds another, aimed at a different concern. Where
|
||||
expiration asks relays to stop serving an event after a time,
|
||||
NIP-70's `protected` tag asks them to be careful about who they accept
|
||||
it from in the first place. Its shape on the wire is a single, valueless
|
||||
tag:
|
||||
|
||||
```json
|
||||
["-"]
|
||||
```
|
||||
|
||||
That is the whole thing — a tag whose name is a lone dash and which
|
||||
carries no value. Its mere presence is the signal. An event marked this
|
||||
way should be accepted by a relay only when it is published *directly by
|
||||
its author*: the connection delivering it must have authenticated over
|
||||
NIP-42, and the authenticated key must match the event's `pubkey`.
|
||||
|
||||
## What the marker asks for
|
||||
|
||||
A relay that honors NIP-70 runs a small decision when a protected event
|
||||
arrives. If the connection has not authenticated, the relay issues a
|
||||
NIP-42 `AUTH` challenge and rejects the event for now, inviting the
|
||||
client to prove who it is and try again. If the connection *has*
|
||||
authenticated but the authenticated key is not the event's author, the
|
||||
relay rejects the event outright — someone other than the author is
|
||||
trying to push it. Only an authenticated author gets through.
|
||||
|
||||
As with expiration, the marker is a cooperative request, not a guarantee.
|
||||
A relay is free to ignore it; a relay that has already stored the event
|
||||
keeps it; the signature stays valid regardless. The tag asks well-behaved
|
||||
relays to gate acceptance on authorship.
|
||||
|
||||
## The module
|
||||
|
||||
```rust {file=coracle-lib/src/lib.rs}
|
||||
pub mod protected;
|
||||
```
|
||||
|
||||
```rust {file=coracle-lib/src/protected.rs}
|
||||
//! NIP-70 protected events: the valueless `["-"]` marker that asks
|
||||
//! relays to accept an event only from its authenticated author.
|
||||
//!
|
||||
//! This module is the stateless half of NIP-70 — building the tag and
|
||||
//! detecting it. The relay-side enforcement it implies (a NIP-42 `AUTH`
|
||||
//! challenge followed by a pubkey comparison) needs a live, authenticated
|
||||
//! connection and is handled in the relay layer.
|
||||
|
||||
use crate::events::{Event, HashedEvent};
|
||||
use crate::tags::Tags;
|
||||
```
|
||||
|
||||
## Detecting the marker
|
||||
|
||||
Because the tag has no value, detection collapses to a single question:
|
||||
is a tag named `"-"` present? There is nothing to parse, so there is no
|
||||
`Option` and no malformed-versus-absent distinction to draw — the answer
|
||||
is a plain `bool`. `Tags::has` already answers exactly this.
|
||||
|
||||
```rust {file=coracle-lib/src/protected.rs}
|
||||
/// Whether the tag set carries the NIP-70 `["-"]` protected marker.
|
||||
///
|
||||
/// A protected event asks relays to accept it only from its
|
||||
/// authenticated author. This function reports the marker's presence;
|
||||
/// acting on it is the relay's job.
|
||||
pub fn is_protected(tags: &Tags) -> bool {
|
||||
tags.has("-")
|
||||
}
|
||||
```
|
||||
|
||||
## Building the tag
|
||||
|
||||
The constructor produces a tag with the name `"-"` and no values. We
|
||||
build it through `Tag::new` like every other tag, passing an empty value
|
||||
iterator so the result is the one-element `["-"]`.
|
||||
|
||||
```rust {file=coracle-lib/src/tags.rs}
|
||||
impl Tag {
|
||||
/// Build a NIP-70 `["-"]` protected tag.
|
||||
///
|
||||
/// The tag has a name (`"-"`) and no values; its presence asks
|
||||
/// relays to accept the event only from its authenticated author.
|
||||
pub fn protected() -> Tag {
|
||||
Tag::new("-", std::iter::empty::<String>())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Methods on event types
|
||||
|
||||
A caller holding an event wants to ask whether it is protected without
|
||||
reaching for its tags directly. Both `HashedEvent` and `Event` carry a
|
||||
`Tags` field, so the method facades delegate to the free function.
|
||||
|
||||
```rust {file=coracle-lib/src/protected.rs}
|
||||
impl HashedEvent {
|
||||
/// Whether this event carries the NIP-70 protected marker.
|
||||
pub fn is_protected(&self) -> bool {
|
||||
is_protected(&self.tags)
|
||||
}
|
||||
}
|
||||
|
||||
impl Event {
|
||||
/// Whether this event carries the NIP-70 protected marker.
|
||||
pub fn is_protected(&self) -> bool {
|
||||
is_protected(&self.tags)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage patterns
|
||||
|
||||
**Marking an event protected.** Attach the tag at template construction,
|
||||
just like any other:
|
||||
|
||||
```rust
|
||||
let event = EventContent::new()
|
||||
.content("members only")
|
||||
.tags(vec![Tag::protected()])
|
||||
.kind(1)
|
||||
.stamp(now)
|
||||
.own(pubkey);
|
||||
```
|
||||
|
||||
**Checking on receipt.** A relay or client deciding how to handle an
|
||||
incoming event asks the event directly:
|
||||
|
||||
```rust
|
||||
if event.is_protected() {
|
||||
// only accept from an authenticated author
|
||||
}
|
||||
```
|
||||
|
||||
## What's next
|
||||
|
||||
Marking an event protected is half of NIP-70; honoring the mark is the
|
||||
other half, and it lives where connections and authentication do. The
|
||||
[Relay Authentication](26-relay-authentication.md) chapter implements the
|
||||
`AUTH` exchange and the author check that turn this tag into a rule.
|
||||
|
||||
One consequence is worth flagging now: a protected event should not be
|
||||
re-broadcast by being embedded inside someone else's repost, since that
|
||||
would route it past the very acceptance check it asked for. Reposts are
|
||||
their own topic; the protected marker is simply something that logic will
|
||||
need to consult.
|
||||
@@ -0,0 +1,160 @@
|
||||
# Plan: Protected Events
|
||||
|
||||
## Topic Summary
|
||||
|
||||
NIP-70 defines an optional, valueless `["-"]` tag — the "protected" marker.
|
||||
Its presence asks relays to accept the event only when published directly by
|
||||
its author: the publishing connection must be NIP-42-authenticated and the
|
||||
authenticated pubkey must equal the event's `pubkey`. Relays receiving a
|
||||
protected event over an unauthenticated connection should issue an `AUTH`
|
||||
challenge and reject it for now; relays receiving one from an authenticated but
|
||||
non-matching connection should reject it outright.
|
||||
|
||||
Like NIP-40 expiration, this is a **behavior tag** — orthogonal to kind, a
|
||||
cooperative request rather than a guarantee. This chapter adds tag-layer support
|
||||
only: a constructor, a boolean predicate, and method facades. Relay-side
|
||||
enforcement is deferred to the Relay Authentication chapter (ch 26), which has
|
||||
the NIP-42 machinery. The tag carries no value, so — unlike expiration — there
|
||||
is nothing to parse and no `get_*` function.
|
||||
|
||||
## Chapter Outline
|
||||
|
||||
1. **Framing** — protected as a behavior tag, sibling to expiration; the
|
||||
`["-"]` tag has no value; the cooperative, relay-enforced (not cryptographic)
|
||||
nature of the request. Pick up the thread chapter 9 left dangling.
|
||||
2. **What the relay rule is** — state the NIP-42 auth-then-compare rule in prose
|
||||
so the reader understands what the tag *asks for*, and explain why none of
|
||||
that logic lives here (the tag layer is stateless; enforcement needs a
|
||||
connection and an authenticated pubkey — ch 26).
|
||||
3. **The module declaration** — `pub mod protected;` in `lib.rs` plus the module
|
||||
header doc comment.
|
||||
4. **The predicate** — `is_protected(&Tags) -> bool`. A thin wrapper over
|
||||
`Tags::has("-")`; justify it on consistency and discoverability (a named
|
||||
NIP-70 function), the same justification the expiration chapter used for its
|
||||
constructor. No value to parse means no `Option`, just `bool`.
|
||||
5. **Building the tag** — `Tag::protected() -> Tag` returning `["-"]`. One line;
|
||||
the narrative explains the unusual single-dash, valueless shape.
|
||||
6. **Methods on event types** — `is_protected(&self)` on both `HashedEvent` and
|
||||
`Event`, delegating to the free function.
|
||||
7. **Usage patterns** — building a protected event with the template builder;
|
||||
checking on receipt. Brief.
|
||||
8. **What's next** — bridge forward: enforcement lands with relay
|
||||
authentication (ch 26); note the NIP-18 repost interaction (a protected event
|
||||
shouldn't be embedded in a repost) as a forward reference, not implemented
|
||||
here.
|
||||
|
||||
## API Design
|
||||
|
||||
### Module `coracle-lib/src/protected.rs`
|
||||
|
||||
```rust
|
||||
use crate::events::{Event, HashedEvent};
|
||||
use crate::tags::Tags;
|
||||
|
||||
/// True if the tag set carries the NIP-70 `["-"]` protected marker.
|
||||
pub fn is_protected(tags: &Tags) -> bool;
|
||||
|
||||
impl HashedEvent {
|
||||
pub fn is_protected(&self) -> bool;
|
||||
}
|
||||
|
||||
impl Event {
|
||||
pub fn is_protected(&self) -> bool;
|
||||
}
|
||||
```
|
||||
|
||||
`is_protected(tags)` is `tags.has("-")`.
|
||||
|
||||
### Tag constructor in `coracle-lib/src/tags.rs`
|
||||
|
||||
```rust
|
||||
impl Tag {
|
||||
/// Build a NIP-70 `["-"]` protected tag.
|
||||
pub fn protected() -> Tag;
|
||||
}
|
||||
```
|
||||
|
||||
Implemented as `Tag::new("-", std::iter::empty::<String>())` — a tag with the
|
||||
name `"-"` and no values, serializing as `["-"]`.
|
||||
|
||||
## Code Organization
|
||||
|
||||
- **`coracle-lib/src/protected.rs`** — new module: the free function and the two
|
||||
method impls. Mirrors `expiration.rs`.
|
||||
- **`coracle-lib/src/lib.rs`** — add `pub mod protected;` (after `expiration`,
|
||||
matching SUMMARY order: ch 10 follows ch 9).
|
||||
- **`coracle-lib/src/tags.rs`** — append a `Tag::protected` constructor inside a
|
||||
new `impl Tag` block (code blocks targeting the same file concatenate; another
|
||||
`impl Tag` block at the end is valid Rust, exactly as `Tag::expiration` does).
|
||||
- **`coracle-lib/tests/protected.rs`** — hand-written integration test, not
|
||||
tangled. Covers the constructor shape, the predicate (present / absent), the
|
||||
method facades on both event types, and tag survival through hash + sign.
|
||||
|
||||
## Dependencies
|
||||
|
||||
None. Pure std and existing crate types. No new `Cargo.toml` entries, no new
|
||||
workspace members.
|
||||
|
||||
## Narrative Notes
|
||||
|
||||
- **Lead by connecting to expiration.** Chapter 9 ends with an explicit promise:
|
||||
"The next chapter introduces another — NIP-70's `protected` tag, which tells
|
||||
relays not to accept an event from anyone but its author." Open by fulfilling
|
||||
that promise. The reader already has the behavior-tag mental model.
|
||||
- **The valueless tag is the one novel thing.** Spend a sentence on why `["-"]`
|
||||
has no value and why that collapses the whole API to a boolean — there is no
|
||||
malformed-vs-absent distinction to make, no parse to fail.
|
||||
- **Be honest about enforcement.** This is the "hint, not guarantee" point from
|
||||
expiration, sharpened: the tag asks relays to require NIP-42 auth from the
|
||||
author. Without a cooperating relay, the tag does nothing. State the relay
|
||||
rule precisely (auth challenge, then pubkey comparison) so the reader knows
|
||||
what they're requesting, then say plainly that implementing it is ch 26's job.
|
||||
- **Justify the free function.** It is `tags.has("-")` and nothing more. Defend
|
||||
it the way ch 9 defended `Tag::expiration`: a named, discoverable NIP-70 entry
|
||||
point reads better at call sites and at the doc level than a bare string
|
||||
literal, and it keeps the protected-events surface symmetrical with the rest
|
||||
of the library (a free function on `&Tags` plus method sugar on the events).
|
||||
- **Keep it short.** This is the shortest behavior-tag chapter — no parsing, no
|
||||
timestamp comparison, no clock. Resist padding. The interest is conceptual
|
||||
(what the tag asks of the network), not mechanical.
|
||||
- **Forward references, not detours.** Mention enforcement (ch 26) and the
|
||||
repost interaction once each; do not explain them.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
- **Boolean, not `Option`.** The tag has no value; presence is the entire
|
||||
signal. `is_protected -> bool` is correct and matches every reference
|
||||
(`isProtectedEvent`, `IsProtected`, `Event::is_protected`).
|
||||
- **No `get_protected`.** Nothing to return but a bool, which `is_protected`
|
||||
already gives. A `get_*` would imply a value that does not exist.
|
||||
- **Free function on `&Tags` plus method facades.** Matches the
|
||||
`get_expiration`/`get_pow` pattern exactly. The free function is reusable by
|
||||
any caller holding a tag slice (e.g., a relay inspecting raw tags before
|
||||
reconstructing an `Event`); the methods are the ergonomic surface.
|
||||
- **Constructor in `tags.rs`, not `protected.rs`.** Lives with `Tag::new` and
|
||||
`Tag::expiration`; discoverable where callers build tags; no import from
|
||||
`protected` just to construct one. Identical placement reasoning to ch 9.
|
||||
- **Enforcement deferred to ch 26.** The acceptance rule needs an authenticated
|
||||
connection and a pubkey to compare against — state, not a stateless tag query.
|
||||
Putting a `should_accept`-style helper here (the option the user declined)
|
||||
would strand half the logic away from where the connection lives. The chapter
|
||||
describes the rule and points forward.
|
||||
- **No `set_protected(bool)` builder helper.** ndk/applesauce expose a
|
||||
toggle, but our builder surface is already chainable via
|
||||
`.tags(Tags::new().add(...))` / `Tag::protected()`. Adding a toggle would be
|
||||
ceremony, consistent with ch 9 declining `set_expiration`.
|
||||
- **Constructor uses an empty value iterator.** `Tag::new("-", iter::empty())`
|
||||
produces `["-"]`. Verified against `Tag::new`'s signature (name plus an
|
||||
`IntoIterator` of values); an empty iterator yields a one-element tag.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Whether to show the relay-enforcement pseudocode inline. Decision: no — a prose
|
||||
statement of the rule is enough; code belongs in ch 26 where the connection and
|
||||
auth state exist. Showing non-tangled relay code here would invite the reader
|
||||
to think enforcement is part of this layer.
|
||||
- Whether to add a `Tags`-level convenience beyond the free function. Skip, per
|
||||
ch 9 reasoning: keep `Tags` a generic container; the named function lives in
|
||||
`protected.rs`.
|
||||
- Whether to document the NIP-18 repost interaction in depth. Keep it to a single
|
||||
forward-reference sentence; reposts are their own topic.
|
||||
@@ -0,0 +1,230 @@
|
||||
# Research: Protected Events
|
||||
|
||||
## Topic Summary
|
||||
|
||||
NIP-70 defines an optional, valueless `["-"]` tag — the "protected" marker.
|
||||
Its presence asks relays to accept the event *only* when it is published
|
||||
directly by its author, where "directly by its author" means the publishing
|
||||
connection has authenticated via NIP-42 and the authenticated pubkey matches
|
||||
the event's `pubkey`. A relay that receives a protected event over an
|
||||
unauthenticated connection should issue a NIP-42 `AUTH` challenge and reject
|
||||
the event for now; a relay that receives one from an authenticated connection
|
||||
whose pubkey is *not* the author should reject it outright.
|
||||
|
||||
Like NIP-40 expiration, this is a **behavior tag**: orthogonal to the event's
|
||||
kind, it requests cooperation from the network rather than altering what the
|
||||
event means. It is a hint, not a guarantee — once an event is signed and held
|
||||
by anyone, no tag can claw it back.
|
||||
|
||||
This chapter adds tag-layer support only, mirroring the Expiring Events
|
||||
chapter: a `Tag::protected()` constructor, an `is_protected(&Tags)` predicate,
|
||||
and `is_protected()` method facades on `HashedEvent` and `Event`. Relay-side
|
||||
enforcement (the NIP-42 auth-then-compare logic) is deferred to the Relay
|
||||
Authentication chapter (ch 26). The unusual property of this tag — that it
|
||||
carries no value — is the only thing distinguishing the API from expiration:
|
||||
there is nothing to parse, so there is no `get_*` function, just a boolean.
|
||||
|
||||
## Philosophy
|
||||
|
||||
`ref/building-nostr` addresses NIP-70 directly and frames it the way this
|
||||
chapter should.
|
||||
|
||||
- **Behavior tags are cooperative requests.** The book classifies tags as
|
||||
data, filter, or behavior tags, and names NIP-70 explicitly: *"the NIP 70
|
||||
protected tag `['-']` has no values. The presence of the tag merely indicates
|
||||
that relays should only accept such event if they're published directly by
|
||||
their author."* Behavior tags "determine how implementations should handle an
|
||||
event, independent of the specification which defines the event's kind." This
|
||||
is exactly the framing already used for expiration — and a reader arriving
|
||||
from chapter 9 will recognize it.
|
||||
|
||||
- **Signed data cannot be unpublished.** *"Once it's published, there's no way
|
||||
to un-sign or un-publish the data. Anyone who has a copy can keep it."* The
|
||||
protected tag does not make an event secret; it asks relays to limit who they
|
||||
will *accept* it from going forward. The chapter must be honest that this is
|
||||
intent-signaling, not enforcement.
|
||||
|
||||
- **Access control is the relay's job, via AUTH.** *"There is one additional
|
||||
responsibility that relays can't really delegate: access control. Access
|
||||
control on Nostr is implemented by the `AUTH` verb."* This is why NIP-70's
|
||||
enforcement story belongs to the networking layer and NIP-42, not to the
|
||||
stateless tag layer. The library's job here is only to read and write the
|
||||
marker.
|
||||
|
||||
- **Censorship resistance vs. access control.** *"An architecture optimized for
|
||||
censorship resistance is necessarily not optimized for access controls or
|
||||
privacy."* Protected events ask relays to *limit* distribution, a deliberate
|
||||
deviation from nostr's broadcast default — useful framing for why this tag
|
||||
exists at all.
|
||||
|
||||
- **Routing implications.** The book notes that access-controlled content (it
|
||||
cites NIP-29 groups) should *not* be routed by the usual outbox heuristic,
|
||||
since that would "leak" content meant to be protected. A brief forward
|
||||
reference: protected events interact with relay selection, but that is a later
|
||||
concern.
|
||||
|
||||
## Reference Implementation Analysis
|
||||
|
||||
### applesauce
|
||||
|
||||
Supported, client-side only, in `applesauce-core`:
|
||||
|
||||
- `isProtectedEvent(event)` — `helpers/event.ts:160`:
|
||||
`return event.tags.some((t) => t[0] === "-");`
|
||||
- `setProtected(set = true)` — `operations/event.ts:91`: adds the `["-"]`
|
||||
singleton tag, or removes it, via `setSingletonTag(["-"])` /
|
||||
`removeSingletonTag("-")`.
|
||||
- `protected(isProtected)` — `factories/event.ts:181`: fluent builder method on
|
||||
`EventFactory`.
|
||||
|
||||
The `["-"]` tag is treated like any other singleton tag (`expiration`, `alt`),
|
||||
with no special-casing. No relay enforcement, no NIP-42 coupling. Notably,
|
||||
`isProtectedEvent` is never *called* elsewhere in the library except in tests —
|
||||
it is a primitive offered to consumers.
|
||||
|
||||
### ndk
|
||||
|
||||
Supported as a getter/setter on `NDKEvent` (`core/src/events/index.ts:868`):
|
||||
|
||||
```typescript
|
||||
set isProtected(val: boolean) { this.removeTag("-"); if (val) this.tags.push(["-"]); }
|
||||
get isProtected(): boolean { return this.hasTag("-"); }
|
||||
```
|
||||
|
||||
The one place protection is *consumed* is reposts (`repost.ts:24`): when
|
||||
building a NIP-18 repost, a protected original is **not** JSON-embedded in the
|
||||
repost content. No relay-side enforcement; NDK is a client library, so the
|
||||
relay rule is out of scope by design.
|
||||
|
||||
### nostr-gadgets
|
||||
|
||||
**Not implemented.** No references to nip70/protected/`["-"]` anywhere. The
|
||||
library is a high-level client utility layer (profile loading, lists, relay
|
||||
hints, outbox) that delegates event construction to `@nostr/tools` and never
|
||||
touches the protected tag.
|
||||
|
||||
### nostrlib
|
||||
|
||||
The most complete implementation, including relay enforcement. Dedicated
|
||||
`nip70/nip70.go`:
|
||||
|
||||
- `IsProtected(event) bool` — iterates tags, returns true if any tag is exactly
|
||||
`["-"]`.
|
||||
- `HasEmbeddedProtected(event) bool` — for reposts (kind 6/16), detects an
|
||||
embedded `["-"]` in the stringified content via substring search.
|
||||
|
||||
Relay enforcement lives in the khatru handler (`khatru/handlers.go:192`):
|
||||
|
||||
```go
|
||||
if nip70.IsProtected(env.Event) {
|
||||
authed, is := GetAuthed(ctx)
|
||||
if !is {
|
||||
RequestAuth(ctx) // NIP-42 challenge
|
||||
// OK:false "auth-required: must be published by authenticated event author"
|
||||
} else if authed != env.Event.PubKey {
|
||||
// OK:false "blocked: must be published by event author"
|
||||
}
|
||||
} else if nip70.HasEmbeddedProtected(env.Event) {
|
||||
// OK:false "blocked: can't repost nip70 protected"
|
||||
}
|
||||
```
|
||||
|
||||
There is also an opt-in policy `OnlyAllowNIP70ProtectedEvents`. No client-side
|
||||
builder — callers append `["-"]` by hand. The auth-then-compare flow is the
|
||||
canonical relay logic and is what our ch 26 will implement.
|
||||
|
||||
### nostr-tools
|
||||
|
||||
Minimal: the only handling is one inline check inside NIP-18 reposts
|
||||
(`nip18.ts:41`):
|
||||
|
||||
```typescript
|
||||
content: t.content === '' || reposted.tags?.find(tag => tag[0] === '-') ? '' : JSON.stringify(reposted),
|
||||
```
|
||||
|
||||
If a reposted event is protected, the repost content is emptied. No standalone
|
||||
detector, no builder, no relay rules — consistent with the library's low-level,
|
||||
minimal philosophy. A single test covers the repost-emptying behavior.
|
||||
|
||||
### rust-nostr
|
||||
|
||||
First-class and the closest analog to our target design. The `-` tag is a named
|
||||
enum variant rather than a generic string tag:
|
||||
|
||||
- `TagKind::Protected` ↔ `"-"` (`event/tag/kind.rs`).
|
||||
- `TagStandard::Protected`, parsed from `["-"]` (`event/tag/standard.rs`).
|
||||
- `Tag::protected() -> Self` (`event/tag/mod.rs:469`).
|
||||
- `Tag::is_protected(&self) -> bool` (`mod.rs:529`) —
|
||||
`matches!(self.as_standardized(), Some(TagStandard::Protected))`.
|
||||
- `Event::is_protected(&self) -> bool` (`event/mod.rs:263`) —
|
||||
`self.tags.find_standardized(TagKind::Protected).is_some()`.
|
||||
|
||||
Relay enforcement in `nostr-relay-builder` mirrors nostrlib: if protected and
|
||||
not NIP-42-authenticated, send AUTH; if authed pubkey ≠ event pubkey, reject
|
||||
with "this event may only be published by its author". A `test_protected_event`
|
||||
round-trips `{"tags":[["-"]]}` JSON. Clean, minimal, self-contained; no separate
|
||||
NIP-70 module because the feature is small enough to live in the tag system.
|
||||
|
||||
### welshman
|
||||
|
||||
**Not implemented.** No nip70/protected/`["-"]` handling anywhere. Welshman has
|
||||
generic tag utilities (`getTag`, `getTagValue`, specialized `p`/`e`/`a`/`t`
|
||||
helpers) and a NIP-42 auth event builder (`makeRelayAuth`), but nothing wires
|
||||
them to a protected-event rule. As the sibling library to this one, its absence
|
||||
is a small data point that NIP-70 is often skipped — but our chapter adds it.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
- **The tag is `["-"]`, valueless.** Every implementation that handles it agrees
|
||||
presence alone is the signal; there is nothing to parse. This is the single
|
||||
most important fact for our API: a boolean predicate, no `get_*`.
|
||||
- **Detection is a one-liner.** Universally "does any tag's first element equal
|
||||
`"-"`" — i.e., `tags.has("-")` in our existing `Tags` vocabulary.
|
||||
- **Construction is trivial.** A constructor that returns `["-"]`. rust-nostr
|
||||
gives it a named `Tag::protected()`; applesauce/ndk inline `["-"]`.
|
||||
- **Method on the event is the ergonomic surface.** rust-nostr, ndk, and
|
||||
applesauce all expose detection at the event level (`is_protected` /
|
||||
`isProtected`), which is where callers actually hold the data.
|
||||
- **Enforcement is relay-side and needs NIP-42.** nostrlib and rust-nostr
|
||||
implement the identical flow: protected + unauthenticated → AUTH challenge +
|
||||
reject; protected + authed-but-wrong-pubkey → reject. Client libraries
|
||||
(applesauce, ndk, nostr-tools, welshman) do *not* enforce; they only mark.
|
||||
- **The NIP-18 repost interaction recurs.** applesauce, ndk, nostr-tools, and
|
||||
nostrlib all special-case reposts so a protected event isn't re-broadcast by
|
||||
being embedded in someone else's repost content. This is a real, named
|
||||
consequence of the tag worth a forward reference, but it belongs to a reposts
|
||||
chapter, not here.
|
||||
- **Two libraries skip it entirely** (nostr-gadgets, welshman), confirming the
|
||||
feature is small and often deferred — which is why a focused, correct
|
||||
tag-layer treatment is worth having.
|
||||
|
||||
## Considerations for Our Implementation
|
||||
|
||||
- **Mirror the expiration chapter's shape.** Free function on `&Tags`, a
|
||||
`Tag::protected()` constructor in `tags.rs`, method facades on `HashedEvent`
|
||||
and `Event`, a new `protected.rs` module, a hand-written integration test.
|
||||
Consistency with ch 9 is a feature: the reader has just seen this exact shape.
|
||||
- **No `get_*`, no parsing.** The tag has no value. The whole API is a boolean.
|
||||
`is_protected(&Tags)` is `tags.has("-")` — our `Tags::has` already does the
|
||||
work. The chapter should acknowledge that the free function is a thin wrapper
|
||||
and justify it on consistency and discoverability grounds (a named NIP-70
|
||||
function the reader can find), the same justification used for the expiration
|
||||
constructor.
|
||||
- **No `Timestamp`/value type concerns.** Unlike expiration there is no number,
|
||||
so none of expiration's parsing/`<` discussion applies. The chapter is
|
||||
shorter.
|
||||
- **The constructor goes in `tags.rs`,** appended as another `impl Tag` block,
|
||||
exactly like `Tag::expiration`. Discoverable next to `Tag::new`; no import
|
||||
from `protected` just to build a tag.
|
||||
- **Defer enforcement explicitly.** State the relay rule in prose — protected +
|
||||
authed-author-only — and name where it will be implemented (Relay
|
||||
Authentication, ch 26, which needs the NIP-42 machinery). Do not write
|
||||
acceptance logic here; the tag layer is stateless and has no notion of a
|
||||
connection or an authenticated pubkey.
|
||||
- **Mention the repost consequence briefly,** as a forward reference, without
|
||||
implementing it: a protected event should not be embedded in a NIP-18 repost.
|
||||
- **No dependencies.** Pure std + existing crate types. No new `Cargo.toml`
|
||||
entries, no workspace changes.
|
||||
- **The `"-"` tag name is unusual** (a single dash, not a word). Worth one
|
||||
sentence: it is intentionally terse and visually distinct, and it has no
|
||||
values, which is why it does not look like other tags.
|
||||
@@ -15,7 +15,9 @@ fn fixed_secret() -> SecretKey {
|
||||
fn make_signed_event(kind: u16, tags: Vec<Tag>) -> coracle_lib::events::Event {
|
||||
let sk = fixed_secret();
|
||||
let pk = sk.public_key();
|
||||
let hashed = EventContent::new("test", tags)
|
||||
let hashed = EventContent::new()
|
||||
.content("test")
|
||||
.tags(tags)
|
||||
.kind(kind)
|
||||
.stamp(1_700_000_000)
|
||||
.own(pk)
|
||||
|
||||
@@ -13,7 +13,9 @@ fn fixed_secret() -> SecretKey {
|
||||
}
|
||||
|
||||
fn build_hashed(content: &str, tags: Vec<Tag>) -> HashedEvent {
|
||||
EventContent::new(content, tags)
|
||||
EventContent::new()
|
||||
.content(content)
|
||||
.tags(tags)
|
||||
.kind(1)
|
||||
.stamp(1_700_000_000)
|
||||
.own(fixed_secret().public_key())
|
||||
@@ -29,7 +31,7 @@ fn sign_fixture() -> Event {
|
||||
|
||||
#[test]
|
||||
fn pipeline_progresses_through_types() {
|
||||
let content = EventContent::new("hi", Tags::new());
|
||||
let content = EventContent::new().content("hi").tags(Tags::new());
|
||||
let template: EventTemplate = content.kind(1);
|
||||
let stamped: StampedEvent = template.stamp(1_700_000_000);
|
||||
let owned: OwnedEvent = stamped.own(fixed_secret().public_key());
|
||||
|
||||
@@ -15,7 +15,9 @@ fn fixed_secret() -> SecretKey {
|
||||
|
||||
fn make_event(kind: u16, timestamp: u64, tags: Vec<Tag>) -> Event {
|
||||
let sk = fixed_secret();
|
||||
let hashed = EventContent::new("hello", tags)
|
||||
let hashed = EventContent::new()
|
||||
.content("hello")
|
||||
.tags(tags)
|
||||
.kind(kind)
|
||||
.stamp(timestamp)
|
||||
.own(sk.public_key())
|
||||
|
||||
@@ -12,7 +12,9 @@ fn fixed_secret() -> SecretKey {
|
||||
}
|
||||
|
||||
fn test_event() -> OwnedEvent {
|
||||
EventContent::new("hello nostr", Tags::new())
|
||||
EventContent::new()
|
||||
.content("hello nostr")
|
||||
.tags(Tags::new())
|
||||
.kind(1)
|
||||
.stamp(1_700_000_000)
|
||||
.own(fixed_secret().public_key())
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
use coracle_lib::events::{EventContent, HashedEvent};
|
||||
use coracle_lib::keys::SecretKey;
|
||||
use coracle_lib::protected::is_protected;
|
||||
use coracle_lib::tags::{Tag, Tags};
|
||||
|
||||
fn fixed_secret() -> SecretKey {
|
||||
let bytes: [u8; 32] = [
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25,
|
||||
26, 27, 28, 29, 30, 31, 32,
|
||||
];
|
||||
SecretKey::from_hex(&hex::encode(bytes)).unwrap()
|
||||
}
|
||||
|
||||
fn build_hashed(tags: Tags) -> HashedEvent {
|
||||
EventContent::new()
|
||||
.content("hi")
|
||||
.tags(tags)
|
||||
.kind(1)
|
||||
.stamp(1_700_000_000)
|
||||
.own(fixed_secret().public_key())
|
||||
.hash()
|
||||
}
|
||||
|
||||
// --- Tag::protected constructor ---
|
||||
|
||||
#[test]
|
||||
fn tag_constructor_shape() {
|
||||
let t = Tag::protected();
|
||||
assert_eq!(t.name(), "-");
|
||||
assert_eq!(t.len(), 1);
|
||||
assert!(t.values().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_constructor_serializes_as_single_element() {
|
||||
let t = Tag::protected();
|
||||
assert_eq!(serde_json::to_string(&t).unwrap(), r#"["-"]"#);
|
||||
}
|
||||
|
||||
// --- is_protected ---
|
||||
|
||||
#[test]
|
||||
fn is_protected_false_when_tag_absent() {
|
||||
let tags = Tags::new();
|
||||
assert!(!is_protected(&tags));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_protected_false_with_unrelated_tags() {
|
||||
let tags = Tags::from(vec![Tag::new("t", ["nostr"]), Tag::expiration(100)]);
|
||||
assert!(!is_protected(&tags));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_protected_true_when_tag_present() {
|
||||
let tags = Tags::from(vec![Tag::protected()]);
|
||||
assert!(is_protected(&tags));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_protected_true_among_other_tags() {
|
||||
let tags = Tags::from(vec![
|
||||
Tag::new("t", ["nostr"]),
|
||||
Tag::protected(),
|
||||
Tag::expiration(100),
|
||||
]);
|
||||
assert!(is_protected(&tags));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_protected_detects_a_raw_dash_tag() {
|
||||
// The marker is identified by name alone; a hand-built ["-"] is
|
||||
// equivalent to Tag::protected().
|
||||
let tags = Tags::from(vec![Tag::new("-", Vec::<String>::new())]);
|
||||
assert!(is_protected(&tags));
|
||||
}
|
||||
|
||||
// --- HashedEvent method facade ---
|
||||
|
||||
#[test]
|
||||
fn hashed_event_reports_protected() {
|
||||
let event = build_hashed(Tags::from(vec![Tag::protected()]));
|
||||
assert!(event.is_protected());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hashed_event_reports_unprotected() {
|
||||
let event = build_hashed(Tags::new());
|
||||
assert!(!event.is_protected());
|
||||
}
|
||||
|
||||
// --- Event method facade ---
|
||||
|
||||
#[test]
|
||||
fn signed_event_reports_protected() {
|
||||
let sk = fixed_secret();
|
||||
let hashed = build_hashed(Tags::from(vec![Tag::protected()]));
|
||||
let sig = sk.sign(&hashed.id);
|
||||
let signed = hashed.sign(sig);
|
||||
|
||||
assert!(signed.is_protected());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protected_tag_survives_hashing_and_signing() {
|
||||
// The tag is part of the canonical hash input, so it must round-trip
|
||||
// through hash() and sign() intact and still verify.
|
||||
let sk = fixed_secret();
|
||||
let hashed = build_hashed(Tags::from(vec![Tag::protected()]));
|
||||
let sig = sk.sign(&hashed.id);
|
||||
let signed = hashed.sign(sig);
|
||||
|
||||
assert!(signed.verify_id());
|
||||
assert!(signed.is_protected());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unprotected_event_stays_unprotected_through_signing() {
|
||||
let sk = fixed_secret();
|
||||
let hashed = build_hashed(Tags::new());
|
||||
let sig = sk.sign(&hashed.id);
|
||||
let signed = hashed.sign(sig);
|
||||
|
||||
assert!(!signed.is_protected());
|
||||
}
|
||||
Reference in New Issue
Block a user