Add expiration chapter
This commit is contained in:
+27
-4
@@ -162,10 +162,33 @@ impl EventContent {
|
|||||||
|
|
||||||
### `StampedEvent`
|
### `StampedEvent`
|
||||||
|
|
||||||
Stamping adds the `created_at` timestamp. Callers usually pass
|
Stamping adds the `created_at` timestamp. The library stays out of clock
|
||||||
`SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs()`, but the
|
policy — the timestamp is whatever the caller says it is — but in practice
|
||||||
library stays out of clock policy — the timestamp is whatever the caller says
|
"whatever the caller says" is almost always "right now," so we give that
|
||||||
it is.
|
common case a name. The `now` helper lives in a `util` module, the home for
|
||||||
|
the small, stateless helpers that don't belong to any one type.
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/lib.rs}
|
||||||
|
pub mod util;
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/util.rs}
|
||||||
|
//! Small stateless helpers shared across the library.
|
||||||
|
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
/// The current time as a Unix timestamp in seconds.
|
||||||
|
pub fn now() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Stamping a template with the current time then reads as
|
||||||
|
`template.stamp(now())`. `stamp` itself takes a plain `u64`, leaving the
|
||||||
|
choice of clock to the caller.
|
||||||
|
|
||||||
```rust {file=coracle-lib/src/events.rs}
|
```rust {file=coracle-lib/src/events.rs}
|
||||||
/// A template with a `created_at` timestamp attached.
|
/// A template with a `created_at` timestamp attached.
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
# Expiring Events
|
||||||
|
|
||||||
|
Not every event is meant to last. A chat message, a transient status, a
|
||||||
|
time-limited invitation — these are events whose value decays to zero
|
||||||
|
at a known point in the future. NIP-40 gives them a way to say so: an
|
||||||
|
`expiration` tag carrying a Unix timestamp in seconds. After that
|
||||||
|
timestamp, the event is considered no longer valid.
|
||||||
|
|
||||||
|
The tag is a **behavior tag** — orthogonal to the event's kind. A kind
|
||||||
|
1 note with an expiration is still a note; the expiration is a request
|
||||||
|
that relays stop serving it and clients stop displaying it once the
|
||||||
|
time is up. The distinction matters. Nostr clients and relays already
|
||||||
|
agreed, per kind, on what the payload *means*. The behavior tag layer
|
||||||
|
lets authors add a separate instruction about what the network should
|
||||||
|
*do* with the event, without reopening the kind's semantics.
|
||||||
|
|
||||||
|
Expiration is a cooperative hint, not a cryptographic guarantee.
|
||||||
|
Anyone who has a copy of the event keeps that copy; the signature
|
||||||
|
remains valid forever. What the tag buys is a shared convention:
|
||||||
|
well-behaved relays will refuse to serve the event after the
|
||||||
|
timestamp, well-behaved clients will hide it, and storage layers that
|
||||||
|
care about disk space can sweep it. The library's job at this level is
|
||||||
|
only to parse the tag and answer "is this event expired?" — deciding
|
||||||
|
what to *do* about that answer belongs to code further up the stack.
|
||||||
|
|
||||||
|
## The module
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/lib.rs}
|
||||||
|
pub mod expiration;
|
||||||
|
```
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/expiration.rs}
|
||||||
|
//! NIP-40 expiring events: reading the `expiration` tag and checking
|
||||||
|
//! whether an event has expired.
|
||||||
|
//!
|
||||||
|
//! The free functions in this module operate on [`Tags`] so they can
|
||||||
|
//! be used wherever a tag slice is in hand. Method versions on
|
||||||
|
//! [`HashedEvent`] and [`Event`] delegate to them for a readable call
|
||||||
|
//! site. The tag itself is built with [`Tag::expiration`] from the
|
||||||
|
//! tags module.
|
||||||
|
|
||||||
|
use crate::events::{Event, HashedEvent};
|
||||||
|
use crate::tags::Tags;
|
||||||
|
use crate::util::now;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reading the tag
|
||||||
|
|
||||||
|
The shape on the wire is `["expiration", "<unix_seconds>"]`. Parsing it
|
||||||
|
means finding the first tag with that name and interpreting its value
|
||||||
|
as a `u64`. Two ways to read this can go wrong — the tag may be absent,
|
||||||
|
or its value may not parse as a number — and both collapse to the same
|
||||||
|
answer: `None`. A malformed tag is indistinguishable from no tag at
|
||||||
|
all; neither tells us when the event should be considered expired, so
|
||||||
|
neither should produce a different downstream behavior.
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/expiration.rs}
|
||||||
|
/// Return the expiration timestamp from a tag set, if present.
|
||||||
|
///
|
||||||
|
/// Reads the first `expiration` tag and parses its value as a Unix
|
||||||
|
/// timestamp in seconds. Returns `None` if the tag is missing or its
|
||||||
|
/// value does not parse as a `u64` — treating "malformed" the same as
|
||||||
|
/// "absent" avoids forcing every caller to distinguish a case they
|
||||||
|
/// cannot usefully act on.
|
||||||
|
pub fn get_expiration(tags: &Tags) -> Option<u64> {
|
||||||
|
tags.value("expiration").and_then(|v| v.parse().ok())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## A testable predicate
|
||||||
|
|
||||||
|
A predicate that reads the system clock is awkward to test. The
|
||||||
|
standard fix — take the clock as a parameter — costs nothing here
|
||||||
|
because the caller usually has a `now` value on hand anyway (a relay
|
||||||
|
checking a batch of incoming events picks a timestamp once and reuses
|
||||||
|
it, a UI rendering a timeline pins "now" at render time). The
|
||||||
|
two-function split keeps both audiences happy: callers who already
|
||||||
|
have a timestamp pass it in, and callers who just want to check
|
||||||
|
against wall-clock time call the shorter form that fills it in for
|
||||||
|
them.
|
||||||
|
|
||||||
|
The comparison is strict: `expiration < now`. An event whose
|
||||||
|
expiration equals the current time is still valid; it becomes expired
|
||||||
|
the moment `now` advances past it. "Expires at T" reads naturally as
|
||||||
|
"invalid after T," and that reading matches what every reference
|
||||||
|
implementation does.
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/expiration.rs}
|
||||||
|
/// Test whether the tags declare an expiration that has already
|
||||||
|
/// passed at the given `now` timestamp.
|
||||||
|
///
|
||||||
|
/// Returns `false` if no expiration tag is present. The comparison is
|
||||||
|
/// strict: an event with expiration equal to `now` is *not* yet
|
||||||
|
/// expired.
|
||||||
|
pub fn is_expired_at(tags: &Tags, now: u64) -> bool {
|
||||||
|
matches!(get_expiration(tags), Some(t) if t < now)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Against the system clock
|
||||||
|
|
||||||
|
The wall-clock form is a one-liner over `is_expired_at`, filling in the
|
||||||
|
timestamp from `crate::util::now`.
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/expiration.rs}
|
||||||
|
/// Test whether the tags declare an expiration that has already
|
||||||
|
/// passed, relative to the system clock.
|
||||||
|
///
|
||||||
|
/// For batch checks or testable callers, prefer [`is_expired_at`] and
|
||||||
|
/// pass an explicit timestamp.
|
||||||
|
pub fn is_expired(tags: &Tags) -> bool {
|
||||||
|
is_expired_at(tags, now())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building the tag
|
||||||
|
|
||||||
|
We can add a small convenience method for building this tag:
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/tags.rs}
|
||||||
|
impl Tag {
|
||||||
|
/// Build a NIP-40 `expiration` tag with the given Unix timestamp in seconds.
|
||||||
|
pub fn expiration(timestamp: u64) -> Tag {
|
||||||
|
Tag::new("expiration", [timestamp.to_string()])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Methods on event types
|
||||||
|
|
||||||
|
Both `HashedEvent` and `Event` carry a `Tags` field, and callers who
|
||||||
|
have an event in hand almost always want to ask questions of its tags
|
||||||
|
without a separate import. The method impls delegate to the free
|
||||||
|
functions verbatim.
|
||||||
|
|
||||||
|
```rust {file=coracle-lib/src/expiration.rs}
|
||||||
|
impl HashedEvent {
|
||||||
|
/// Return the expiration timestamp of this event, if any.
|
||||||
|
pub fn get_expiration(&self) -> Option<u64> {
|
||||||
|
get_expiration(&self.tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this event has expired relative to the given timestamp.
|
||||||
|
pub fn is_expired_at(&self, now: u64) -> bool {
|
||||||
|
is_expired_at(&self.tags, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this event has expired relative to the system clock.
|
||||||
|
pub fn is_expired(&self) -> bool {
|
||||||
|
is_expired(&self.tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Event {
|
||||||
|
/// Return the expiration timestamp of this event, if any.
|
||||||
|
pub fn get_expiration(&self) -> Option<u64> {
|
||||||
|
get_expiration(&self.tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this event has expired relative to the given timestamp.
|
||||||
|
pub fn is_expired_at(&self, now: u64) -> bool {
|
||||||
|
is_expired_at(&self.tags, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this event has expired relative to the system clock.
|
||||||
|
pub fn is_expired(&self) -> bool {
|
||||||
|
is_expired(&self.tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage patterns
|
||||||
|
|
||||||
|
**Building an expiring event.** Attach the tag at template
|
||||||
|
construction:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let event = EventContent::new()
|
||||||
|
.content("this link is good for an hour")
|
||||||
|
.tags(Tags::new().add_tag(Tag::expiration(now + 3600)))
|
||||||
|
.kind(1)
|
||||||
|
.stamp(now)
|
||||||
|
.own(pubkey);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filtering out expired events.** Relays and clients both might need to
|
||||||
|
remove expired events from a collection. Using `is_expired_at` avoids the
|
||||||
|
need to re-generate the current timestamp repeatedly:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let now = util::now();
|
||||||
|
incoming.retain(|e| !e.is_expired_at(now));
|
||||||
|
```
|
||||||
|
|
||||||
|
## What's next
|
||||||
|
|
||||||
|
Expiration is one kind of behavior tag. The next chapter introduces
|
||||||
|
another — NIP-70's `protected` tag, which tells relays not to accept
|
||||||
|
an event from anyone but its author.
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Plan: Expiring Events
|
||||||
|
|
||||||
|
## Topic Summary
|
||||||
|
|
||||||
|
NIP-40 defines an optional `expiration` tag:
|
||||||
|
`["expiration", "<unix_timestamp_seconds>"]`. It marks an event as no
|
||||||
|
longer valid after the given time. Relays SHOULD stop serving expired
|
||||||
|
events and MAY drop them; clients SHOULD hide them. Expiration is a
|
||||||
|
cooperative hint, not a cryptographic guarantee.
|
||||||
|
|
||||||
|
This chapter adds tag-layer support: a parser, a predicate, a
|
||||||
|
constructor, and the usual method facades on `HashedEvent` and `Event`.
|
||||||
|
Enforcement (storage sweeps, UI hiding) is out of scope.
|
||||||
|
|
||||||
|
## Chapter Outline
|
||||||
|
|
||||||
|
1. **Framing** — expiration as a behavior tag; why it lives on `Tags`
|
||||||
|
rather than on kind; cooperative nature of the feature.
|
||||||
|
2. **The module declaration** — `pub mod expiration;` in `lib.rs` plus
|
||||||
|
the module header.
|
||||||
|
3. **Reading the tag** — `get_expiration(&Tags) -> Option<u64>`.
|
||||||
|
Explain missing-vs-malformed both collapsing to `None`.
|
||||||
|
4. **Testable predicate** — `is_expired_at(&Tags, now: u64) -> bool`.
|
||||||
|
Explain the strict-`<` comparison.
|
||||||
|
5. **System-clock predicate** — `is_expired(&Tags) -> bool`, plus the
|
||||||
|
private `now_seconds()` helper.
|
||||||
|
6. **Constructor** — `Tag::expiration(ts: u64) -> Tag`. Explain why we
|
||||||
|
bother: a chapter's worth of call sites read better with a named
|
||||||
|
constructor than with `Tag::new("expiration", [ts.to_string()])`.
|
||||||
|
7. **Methods on event types** — `get_expiration`, `is_expired`,
|
||||||
|
`is_expired_at` on both `HashedEvent` and `Event`.
|
||||||
|
8. **Usage patterns** — the expected call sites (a relay filter, a UI
|
||||||
|
check, a builder attaching a tag), very briefly.
|
||||||
|
9. **What's next** — bridge to protected events (chapter 10).
|
||||||
|
|
||||||
|
## API Design
|
||||||
|
|
||||||
|
### Module `coracle-lib/src/expiration.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn get_expiration(tags: &Tags) -> Option<u64>;
|
||||||
|
pub fn is_expired_at(tags: &Tags, now: u64) -> bool;
|
||||||
|
pub fn is_expired(tags: &Tags) -> bool;
|
||||||
|
|
||||||
|
impl HashedEvent {
|
||||||
|
pub fn get_expiration(&self) -> Option<u64>;
|
||||||
|
pub fn is_expired_at(&self, now: u64) -> bool;
|
||||||
|
pub fn is_expired(&self) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Event {
|
||||||
|
pub fn get_expiration(&self) -> Option<u64>;
|
||||||
|
pub fn is_expired_at(&self, now: u64) -> bool;
|
||||||
|
pub fn is_expired(&self) -> bool;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tag constructor in `coracle-lib/src/tags.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
impl Tag {
|
||||||
|
pub fn expiration(timestamp: u64) -> Tag;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rationale for placement: the constructor lives with `Tag` (not in
|
||||||
|
`expiration.rs`) because it belongs to the tag-construction surface,
|
||||||
|
which callers already import when they build tags. This matches how the
|
||||||
|
nonce tag is handled — built inline at its mining site — but gives
|
||||||
|
`expiration` a nicer call site because building this tag is common.
|
||||||
|
|
||||||
|
## Code Organization
|
||||||
|
|
||||||
|
- **`coracle-lib/src/expiration.rs`** — new module for the free
|
||||||
|
functions and the method impls.
|
||||||
|
- **`coracle-lib/src/lib.rs`** — add `pub mod expiration;`.
|
||||||
|
- **`coracle-lib/src/tags.rs`** — add the `Tag::expiration` constructor
|
||||||
|
inside an existing `impl Tag` block, tangled from the expiration
|
||||||
|
chapter (code blocks targeting the same file concatenate; the new
|
||||||
|
block adds another `impl Tag` block appended at the end, which is
|
||||||
|
valid Rust).
|
||||||
|
- **`coracle-lib/tests/expiration.rs`** — integration test (hand-
|
||||||
|
written, not tangled).
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
None. `std::time::{SystemTime, UNIX_EPOCH}` is already in std. No new
|
||||||
|
crate dependencies, no new workspace entries.
|
||||||
|
|
||||||
|
## Narrative Notes
|
||||||
|
|
||||||
|
- Lead with the behavior-tag framing. A reader who has just finished
|
||||||
|
proof of work will recognize the pattern: a tag that *requests*
|
||||||
|
downstream cooperation. Expiration is the cleaner example.
|
||||||
|
- Make the "hint, not guarantee" point explicitly. The chapter is not
|
||||||
|
asserting that expired events are gone — it's giving callers the
|
||||||
|
primitive they need to act on the hint.
|
||||||
|
- Strict-`<` deserves a sentence. An event whose expiration equals
|
||||||
|
`now` is still valid; it becomes expired the moment `now` advances.
|
||||||
|
- The `get_expiration` / `is_expired` split mirrors the pattern from
|
||||||
|
`get_pow` in the previous chapter: a free function that takes tags,
|
||||||
|
plus method sugar on the event types. Call this out briefly — the
|
||||||
|
consistency is the point.
|
||||||
|
- Don't over-design `is_expired`. The `SystemTime::now()` fallback to
|
||||||
|
`0` on a clock error means an unreachable case maps to "never
|
||||||
|
expired," which is the safer default than "always expired".
|
||||||
|
- Keep the constructor section short. It's one line of code; the
|
||||||
|
narrative justifies adding it at all.
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
- **No `Timestamp` newtype.** `created_at` is `u64` everywhere; this
|
||||||
|
stays consistent.
|
||||||
|
- **No clock injection.** The testable `is_expired_at(now)` is enough;
|
||||||
|
a `Clock` trait would be ceremony in a library this size.
|
||||||
|
- **Strict `<` not `<=`.** Matches rust-nostr, welshman, applesauce.
|
||||||
|
"Expires at T" reads as "invalid after T".
|
||||||
|
- **Free functions take `&Tags` only.** The predicate doesn't need the
|
||||||
|
event id or pubkey. Keeping it at the `Tags` level makes it reusable
|
||||||
|
for any caller holding a tag slice (e.g., a relay streaming raw JSON
|
||||||
|
can check without reconstructing an `Event`).
|
||||||
|
- **No background sweeper, no storage integration.** That belongs in
|
||||||
|
a later storage chapter, if at all. This chapter is the parse layer.
|
||||||
|
- **Tag constructor in `tags.rs`.** Discoverable next to
|
||||||
|
`Tag::new`; avoids an import from `expiration` just to build a tag.
|
||||||
|
- **No `set_expiration(ts)` helper on event builders.** Callers can
|
||||||
|
write `.tags(tags.add_tag(Tag::expiration(ts)))` or the equivalent;
|
||||||
|
the builder surface is already chainable enough.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Whether to document the interaction with NIP-09 (deletion requests)
|
||||||
|
in this chapter. Keep it out — deletion is its own chapter's problem,
|
||||||
|
and entangling them here dilutes the focus.
|
||||||
|
- Whether to add a convenience `is_expired_now` on `Tags` directly.
|
||||||
|
Skip it; the free function is already named clearly, and adding
|
||||||
|
methods on `Tags` blurs the "tags are a generic container" line.
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# Research: Expiring Events
|
||||||
|
|
||||||
|
## Topic Summary
|
||||||
|
|
||||||
|
NIP-40 defines an optional `expiration` tag of the form
|
||||||
|
`["expiration", "<unix_timestamp_seconds>"]`. When present, it marks the
|
||||||
|
event as no longer valid after the given time. Relays SHOULD stop
|
||||||
|
serving expired events and MAY drop them from storage. Clients SHOULD
|
||||||
|
not display expired events. Expiration is a hint, not a cryptographic
|
||||||
|
guarantee — anyone who already has a copy can keep it.
|
||||||
|
|
||||||
|
This chapter should cover:
|
||||||
|
- Reading the `expiration` tag from an event's tags
|
||||||
|
- A predicate `is_expired` (and a testable `is_expired_at(now)` variant)
|
||||||
|
- Constructing an expiration tag
|
||||||
|
- Attaching free-function accessors to `HashedEvent` and `Event`
|
||||||
|
- Handling missing and malformed tags (treated as never-expires)
|
||||||
|
|
||||||
|
Mining, storage filtering, and relay-side enforcement are out of scope —
|
||||||
|
the chapter stays at the tag-parsing layer.
|
||||||
|
|
||||||
|
## Philosophy
|
||||||
|
|
||||||
|
From `ref/building-nostr`:
|
||||||
|
|
||||||
|
- The `expiration` tag is a **behavior tag** — orthogonal to kind,
|
||||||
|
applicable to any event. It requests special handling by relays
|
||||||
|
rather than encoding meaning in the payload.
|
||||||
|
- Expiration is a *superset* of ephemeral-kind (2XXXX) functionality.
|
||||||
|
Putting retention policy in a tag frees it from the kind range.
|
||||||
|
- Expiration, like deletion (kind 5), is a **request, not a guarantee**:
|
||||||
|
"once it's published, there's no way to un-sign or un-publish".
|
||||||
|
Clients and relays cooperate voluntarily.
|
||||||
|
- Different content has different natural lifespans. Expiration is a
|
||||||
|
design choice reflecting use case — chat messages can expire; wiki
|
||||||
|
entries shouldn't.
|
||||||
|
|
||||||
|
The framing matters for the chapter: we are not building a deletion
|
||||||
|
mechanism. We are parsing an opt-in hint that downstream layers
|
||||||
|
(storage, UI, relay policy) may act on.
|
||||||
|
|
||||||
|
## Reference Implementation Analysis
|
||||||
|
|
||||||
|
### applesauce
|
||||||
|
|
||||||
|
- `getExpirationTimestamp(event)` reads the first `expiration` tag and
|
||||||
|
parses its value as a number; caches the parsed value via a Symbol.
|
||||||
|
- `isExpired(event)` compares the cached timestamp to `unixNow()`
|
||||||
|
(`Math.round(Date.now() / 1000)`). Missing tag → returns `false`.
|
||||||
|
- `setExpirationTimestamp(n)` is an event-builder operation that
|
||||||
|
replaces any existing expiration tag with `["expiration", n.toString()]`.
|
||||||
|
- `ExpirationManager` is an injectable storage-layer component that
|
||||||
|
tracks non-expired events and emits an `expired$` observable; an
|
||||||
|
`EventStore` filters expired events out of queries unless
|
||||||
|
`keepExpired: true`.
|
||||||
|
- Time is wall-clock only (`Date.now()`).
|
||||||
|
|
||||||
|
### ndk
|
||||||
|
|
||||||
|
- Minimal. Expiration is handled at the model level (e.g., a
|
||||||
|
`P2POrderEvent` class has `expiration` getter/setter); no
|
||||||
|
framework-wide `isExpired()` method.
|
||||||
|
- No storage-layer filtering of expired events.
|
||||||
|
|
||||||
|
### nostr-gadgets
|
||||||
|
|
||||||
|
- Nothing. Zero references to `expiration` or NIP-40.
|
||||||
|
|
||||||
|
### nostrlib (Go)
|
||||||
|
|
||||||
|
- `nip40.GetExpiration(tags) Timestamp` returns -1 on missing/malformed
|
||||||
|
tag. Plain int64 parse.
|
||||||
|
- `khatru/expiration.go` is a separate opt-in relay-side manager:
|
||||||
|
min-heap of expiring events, periodic sweep (default 1 hour), deletes
|
||||||
|
on expiration. Not used at query time — strictly a background cleaner.
|
||||||
|
- Uses `nostr.Now()` = `time.Now().Unix()`; no injection.
|
||||||
|
|
||||||
|
### nostr-tools
|
||||||
|
|
||||||
|
- `getExpiration(event)` → `Date` object (from unix seconds × 1000).
|
||||||
|
- `isEventExpired(event)` compares via `Date.now()`; missing tag → false.
|
||||||
|
- Bonus: `waitForExpire(event)` / `onExpire(event, cb)` schedule a
|
||||||
|
`setTimeout` to fire when the event expires — async sugar, not
|
||||||
|
essential to the core API.
|
||||||
|
- No tag constructor.
|
||||||
|
|
||||||
|
### rust-nostr
|
||||||
|
|
||||||
|
The most structured implementation:
|
||||||
|
|
||||||
|
- Tag modeled as `TagStandard::Expiration(Timestamp)` variant and
|
||||||
|
`TagKind::Expiration` kind.
|
||||||
|
- Constructor: `Tag::expiration(timestamp: Timestamp) -> Tag`.
|
||||||
|
- Query: `Tags::expiration() -> Option<&Timestamp>`.
|
||||||
|
- Predicates on `Event`:
|
||||||
|
- `is_expired_at(&self, now: &Timestamp) -> bool` — compares strictly
|
||||||
|
with `<` (expiration equal to now is not yet expired).
|
||||||
|
- `is_expired(&self) -> bool` (std only) — delegates to `now()`.
|
||||||
|
- `Timestamp` is a `u64` newtype in seconds. `Timestamp::now()` uses
|
||||||
|
`SystemTime` with an unwrap-to-default fallback; no clock trait.
|
||||||
|
- Storage feature flag `event_expiration: bool` in the database trait;
|
||||||
|
currently all backends set it to `false`, so filtering is
|
||||||
|
application-level.
|
||||||
|
|
||||||
|
### welshman
|
||||||
|
|
||||||
|
- `Repository` maintains a separate `expired: Map<eventId, timestamp>`
|
||||||
|
populated at insert time (parses `expiration` tag, skips NaN).
|
||||||
|
- `isExpired(event)` looks up in the map; strict `<` comparison.
|
||||||
|
- No builder for the tag; no automatic filtering in query paths.
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
- **Graceful absence.** Every implementation treats a missing
|
||||||
|
expiration tag as "never expires". None returns an error on absent
|
||||||
|
tags; most surface `Option` / `null` / a sentinel.
|
||||||
|
- **Graceful malformed.** Most implementations silently ignore tags
|
||||||
|
whose value doesn't parse. None treats a malformed tag as expired.
|
||||||
|
- **Strict `<` comparison.** rust-nostr, welshman, and applesauce all
|
||||||
|
use strict-less-than: an event whose expiration equals `now` is still
|
||||||
|
valid. This is the natural reading of "expires at time T" (the event
|
||||||
|
is invalid *after* T).
|
||||||
|
- **Wall-clock time.** No implementation injects a clock. Time comes
|
||||||
|
from `SystemTime::now()` / `Date.now()` / `time.Now()`. Testable
|
||||||
|
variants (passing `now` explicitly) are common.
|
||||||
|
- **Separation of parse from enforcement.** Parsing the tag is cheap
|
||||||
|
and always available. Doing something about it (removing expired
|
||||||
|
events from storage, hiding them in UI, sweeping a relay database)
|
||||||
|
is a separate concern layered on top.
|
||||||
|
- **Minimal tag constructor.** Rust-nostr has one; most TS libraries
|
||||||
|
expect the caller to build the tag by hand. Adding one is trivial
|
||||||
|
and makes call sites readable.
|
||||||
|
|
||||||
|
## Considerations for Our Implementation
|
||||||
|
|
||||||
|
**Scope.** This crate is `coracle-lib`, which owns nostr types and
|
||||||
|
stateless utilities. The chapter stays at that layer: tag accessor,
|
||||||
|
predicate, tag constructor. Storage sweeping and UI hiding belong in
|
||||||
|
downstream crates (`coracle-storage`, future application code).
|
||||||
|
|
||||||
|
**API shape, closely tracking rust-nostr but flatter.** The library has
|
||||||
|
deliberately avoided a `TagStandard` enum in earlier chapters — `Tag`
|
||||||
|
is a thin `Vec<String>` wrapper with accessor methods. Following that
|
||||||
|
style:
|
||||||
|
|
||||||
|
- `get_expiration(tags: &Tags) -> Option<u64>` — free function.
|
||||||
|
- `is_expired_at(tags: &Tags, now: u64) -> bool`.
|
||||||
|
- `is_expired(tags: &Tags) -> bool` — uses `SystemTime::now()`.
|
||||||
|
- Methods `get_expiration / is_expired / is_expired_at` on both
|
||||||
|
`HashedEvent` and `Event`, delegating to the free functions.
|
||||||
|
- A tag constructor. Two plausible shapes:
|
||||||
|
- `Tag::expiration(ts: u64) -> Tag` — parallels `Tag::new` but
|
||||||
|
specialized.
|
||||||
|
- Just document that callers can write `Tag::new("expiration",
|
||||||
|
[ts.to_string()])`.
|
||||||
|
The existing `pow.rs` module builds the nonce tag inline via
|
||||||
|
`Tag::new`, so there is a precedent for the inline form. But an
|
||||||
|
expiration-specific constructor is small and keeps call sites more
|
||||||
|
readable, and there's nothing nonstandard to hide. Lean toward
|
||||||
|
providing it.
|
||||||
|
|
||||||
|
**Type for timestamps.** The rest of the library uses plain `u64` for
|
||||||
|
`created_at`. Consistency argues for `u64` here too; no new
|
||||||
|
`Timestamp` newtype.
|
||||||
|
|
||||||
|
**Comparison semantics.** Use strict `<`. Document it in the rustdoc.
|
||||||
|
|
||||||
|
**Graceful parsing.** Missing tag or unparseable value → `None` /
|
||||||
|
`false`. No errors surfaced to callers.
|
||||||
|
|
||||||
|
**Time source.** Use `SystemTime::now().duration_since(UNIX_EPOCH)`
|
||||||
|
directly in `is_expired`. Keep `is_expired_at` available as the
|
||||||
|
testable primitive. No clock trait — it would cost more than it buys
|
||||||
|
in a library this size.
|
||||||
|
|
||||||
|
**Dependencies.** None beyond what's already pulled in (`std::time`).
|
||||||
|
|
||||||
|
**Narrative.** The chapter should build on the proof-of-work chapter's
|
||||||
|
structure — free functions plus methods on event types — and lean on
|
||||||
|
the philosophy framing: expiration is a **behavior tag**, a request,
|
||||||
|
not a cryptographic property of the event. That framing prepares the
|
||||||
|
reader for later chapters where storage and relay layers act on it.
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
use coracle_lib::events::{EventContent, HashedEvent};
|
||||||
|
use coracle_lib::expiration::{get_expiration, is_expired, is_expired_at};
|
||||||
|
use coracle_lib::keys::SecretKey;
|
||||||
|
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::expiration constructor ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tag_constructor_shape() {
|
||||||
|
let t = Tag::expiration(1_700_000_000);
|
||||||
|
assert_eq!(t.name(), "expiration");
|
||||||
|
assert_eq!(t.value(), "1700000000");
|
||||||
|
assert_eq!(t.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- get_expiration ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_expiration_returns_none_when_tag_absent() {
|
||||||
|
let tags = Tags::new();
|
||||||
|
assert_eq!(get_expiration(&tags), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_expiration_parses_numeric_value() {
|
||||||
|
let tags = Tags::from(vec![Tag::expiration(1_700_000_000)]);
|
||||||
|
assert_eq!(get_expiration(&tags), Some(1_700_000_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_expiration_returns_none_when_malformed() {
|
||||||
|
let tags = Tags::from(vec![Tag::new("expiration", ["not-a-number"])]);
|
||||||
|
assert_eq!(get_expiration(&tags), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_expiration_reads_first_matching_tag() {
|
||||||
|
let tags = Tags::from(vec![
|
||||||
|
Tag::new("t", ["nostr"]),
|
||||||
|
Tag::expiration(100),
|
||||||
|
Tag::expiration(200),
|
||||||
|
]);
|
||||||
|
assert_eq!(get_expiration(&tags), Some(100));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- is_expired_at ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_expired_at_false_when_tag_absent() {
|
||||||
|
let tags = Tags::new();
|
||||||
|
assert!(!is_expired_at(&tags, u64::MAX));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_expired_at_false_when_now_before_expiration() {
|
||||||
|
let tags = Tags::from(vec![Tag::expiration(100)]);
|
||||||
|
assert!(!is_expired_at(&tags, 99));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_expired_at_false_when_now_equals_expiration() {
|
||||||
|
// Strict `<`: equal is not yet expired.
|
||||||
|
let tags = Tags::from(vec![Tag::expiration(100)]);
|
||||||
|
assert!(!is_expired_at(&tags, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_expired_at_true_when_now_past_expiration() {
|
||||||
|
let tags = Tags::from(vec![Tag::expiration(100)]);
|
||||||
|
assert!(is_expired_at(&tags, 101));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_expired_at_false_when_malformed() {
|
||||||
|
let tags = Tags::from(vec![Tag::new("expiration", ["nope"])]);
|
||||||
|
assert!(!is_expired_at(&tags, u64::MAX));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- is_expired (system clock) ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_expired_true_for_unix_epoch_tag() {
|
||||||
|
// Unless the system clock is wildly wrong, `now` is far past 1.
|
||||||
|
let tags = Tags::from(vec![Tag::expiration(1)]);
|
||||||
|
assert!(is_expired(&tags));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_expired_false_for_far_future_tag() {
|
||||||
|
let tags = Tags::from(vec![Tag::expiration(u64::MAX)]);
|
||||||
|
assert!(!is_expired(&tags));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_expired_false_when_tag_absent() {
|
||||||
|
let tags = Tags::new();
|
||||||
|
assert!(!is_expired(&tags));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HashedEvent method facades ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hashed_event_reads_expiration() {
|
||||||
|
let tags = Tags::from(vec![Tag::expiration(1_700_000_500)]);
|
||||||
|
let event = build_hashed(tags);
|
||||||
|
assert_eq!(event.get_expiration(), Some(1_700_000_500));
|
||||||
|
assert!(!event.is_expired_at(1_700_000_500));
|
||||||
|
assert!(event.is_expired_at(1_700_000_501));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hashed_event_no_expiration_without_tag() {
|
||||||
|
let event = build_hashed(Tags::new());
|
||||||
|
assert_eq!(event.get_expiration(), None);
|
||||||
|
assert!(!event.is_expired_at(u64::MAX));
|
||||||
|
assert!(!event.is_expired());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Event method facades ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signed_event_reads_expiration() {
|
||||||
|
let sk = fixed_secret();
|
||||||
|
let tags = Tags::from(vec![Tag::expiration(1_700_000_500)]);
|
||||||
|
let hashed = build_hashed(tags);
|
||||||
|
let sig = sk.sign(&hashed.id);
|
||||||
|
let signed = hashed.sign(sig);
|
||||||
|
|
||||||
|
assert_eq!(signed.get_expiration(), Some(1_700_000_500));
|
||||||
|
assert!(signed.is_expired_at(1_700_000_501));
|
||||||
|
assert!(!signed.is_expired_at(1_700_000_499));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expiration_tag_survives_hashing_and_signing() {
|
||||||
|
// The tag is part of the canonical hash input, so it has to
|
||||||
|
// round-trip through `hash()` and `sign()` intact.
|
||||||
|
let sk = fixed_secret();
|
||||||
|
let ts = 1_700_000_500;
|
||||||
|
let hashed = build_hashed(Tags::from(vec![Tag::expiration(ts)]));
|
||||||
|
let sig = sk.sign(&hashed.id);
|
||||||
|
let signed = hashed.sign(sig);
|
||||||
|
|
||||||
|
assert!(signed.verify_id());
|
||||||
|
assert_eq!(signed.get_expiration(), Some(ts));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user