# 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.