Add expiration chapter
This commit is contained in:
+27
-4
@@ -162,10 +162,33 @@ impl EventContent {
|
||||
|
||||
### `StampedEvent`
|
||||
|
||||
Stamping adds the `created_at` timestamp. Callers usually pass
|
||||
`SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs()`, but the
|
||||
library stays out of clock policy — the timestamp is whatever the caller says
|
||||
it is.
|
||||
Stamping adds the `created_at` timestamp. The library stays out of clock
|
||||
policy — the timestamp is whatever the caller says it is — but in practice
|
||||
"whatever the caller says" is almost always "right now," so we give that
|
||||
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}
|
||||
/// 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