Skip to content

Commit d43a642

Browse files
wesbillmanbaxen
authored andcommitted
feat: ephemeral channels with TTL-based auto-archiving
Add ephemeral channels that auto-archive after a configurable period of inactivity. Full vertical slice: schema → DB → relay → REST API → desktop UI. Schema: - Add ttl_seconds (INT) and ttl_deadline (TIMESTAMPTZ) to channels table - Add partial index for efficient reaper queries Database (sprout-db): - Extend ChannelRecord with ttl_seconds and ttl_deadline fields - Add bump_ttl_deadline() to reset deadline on channel activity - Add reap_expired_ephemeral_channels() to archive expired channels - Thread ttl_seconds through create_channel and create_channel_with_id Relay (sprout-relay): - Parse 'ttl' tag from NIP-29 create-group events - Bump TTL deadline on every channel-scoped event (skip kind:9007 create) - Add background reaper task (configurable interval, default 60s) - Support SPROUT_EPHEMERAL_TTL_OVERRIDE env var for testing - Emit system messages and discovery events on auto-archive Desktop: - Add ephemeral checkbox to channel/forum creation forms - Thread ttl_seconds through Tauri commands and event builders - Expose ttl_seconds and ttl_deadline in TypeScript types and API layer - Bump check-file-sizes override for types.ts
1 parent 35588a0 commit d43a642

20 files changed

Lines changed: 327 additions & 16 deletions

File tree

.env.example

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,17 @@ OKTA_AUDIENCE=sprout-desktop
8181
# OKTA_AUDIENCE=sprout-api
8282
# OKTA_PUBKEY_CLAIM=nostr_pubkey
8383

84+
# -----------------------------------------------------------------------------
85+
# Ephemeral Channels (TTL testing)
86+
# -----------------------------------------------------------------------------
87+
# Override the TTL for all ephemeral channels (in seconds). When set, any
88+
# channel created with a TTL tag will use this value instead of the
89+
# client-provided one. Unset to use the client-provided TTL.
90+
# SPROUT_EPHEMERAL_TTL_OVERRIDE=60
91+
92+
# How often the reaper checks for expired ephemeral channels (default: 60s).
93+
# SPROUT_REAPER_INTERVAL_SECS=5
94+
8495
# -----------------------------------------------------------------------------
8596
# Logging / Tracing
8697
# -----------------------------------------------------------------------------

crates/sprout-db/src/channel.rs

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ pub struct ChannelRecord {
5858
pub purpose_set_by: Option<Vec<u8>>,
5959
/// When the purpose was last set.
6060
pub purpose_set_at: Option<DateTime<Utc>>,
61+
/// TTL in seconds for ephemeral channels. `None` means permanent.
62+
pub ttl_seconds: Option<i32>,
63+
/// Deadline by which a new message must arrive or the channel is auto-archived.
64+
pub ttl_deadline: Option<DateTime<Utc>>,
6165
}
6266

6367
/// A channel membership row as returned from the database.
@@ -85,6 +89,7 @@ pub async fn create_channel(
8589
visibility: ChannelVisibility,
8690
description: Option<&str>,
8791
created_by: &[u8],
92+
ttl_seconds: Option<i32>,
8893
) -> Result<ChannelRecord> {
8994
if created_by.len() != 32 {
9095
return Err(DbError::InvalidData(format!(
@@ -99,8 +104,9 @@ pub async fn create_channel(
99104

100105
sqlx::query(
101106
r#"
102-
INSERT INTO channels (id, name, channel_type, visibility, description, created_by)
103-
VALUES ($1, $2, $3::channel_type, $4::channel_visibility, $5, $6)
107+
INSERT INTO channels (id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline)
108+
VALUES ($1, $2, $3::channel_type, $4::channel_visibility, $5, $6, $7,
109+
CASE WHEN $7 IS NOT NULL THEN NOW() + ($7 || ' seconds')::interval ELSE NULL END)
104110
"#,
105111
)
106112
.bind(id)
@@ -109,6 +115,7 @@ pub async fn create_channel(
109115
.bind(visibility.as_str())
110116
.bind(description)
111117
.bind(created_by)
118+
.bind(ttl_seconds)
112119
.execute(&mut *tx)
113120
.await?;
114121

@@ -135,7 +142,8 @@ pub async fn create_channel(
135142
created_by, created_at, updated_at, archived_at, deleted_at,
136143
nip29_group_id, topic_required, max_members,
137144
topic, topic_set_by, topic_set_at,
138-
purpose, purpose_set_by, purpose_set_at
145+
purpose, purpose_set_by, purpose_set_at,
146+
ttl_seconds, ttl_deadline
139147
FROM channels WHERE id = $1
140148
"#,
141149
)
@@ -152,6 +160,7 @@ pub async fn create_channel(
152160
///
153161
/// Returns `(record, true)` if the channel was newly created, or `(record, false)` if a
154162
/// channel with `channel_id` already exists (duplicate — caller should reject the event).
163+
#[allow(clippy::too_many_arguments)]
155164
pub async fn create_channel_with_id(
156165
pool: &PgPool,
157166
channel_id: Uuid,
@@ -160,6 +169,7 @@ pub async fn create_channel_with_id(
160169
visibility: ChannelVisibility,
161170
description: Option<&str>,
162171
created_by: &[u8],
172+
ttl_seconds: Option<i32>,
163173
) -> Result<(ChannelRecord, bool)> {
164174
if created_by.len() != 32 {
165175
return Err(DbError::InvalidData(format!(
@@ -172,8 +182,9 @@ pub async fn create_channel_with_id(
172182

173183
let rows_affected = sqlx::query(
174184
r#"
175-
INSERT INTO channels (id, name, channel_type, visibility, description, created_by)
176-
VALUES ($1, $2, $3::channel_type, $4::channel_visibility, $5, $6)
185+
INSERT INTO channels (id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline)
186+
VALUES ($1, $2, $3::channel_type, $4::channel_visibility, $5, $6, $7,
187+
CASE WHEN $7 IS NOT NULL THEN NOW() + ($7 || ' seconds')::interval ELSE NULL END)
177188
ON CONFLICT (id) DO NOTHING
178189
"#,
179190
)
@@ -183,6 +194,7 @@ pub async fn create_channel_with_id(
183194
.bind(visibility.as_str())
184195
.bind(description)
185196
.bind(created_by)
197+
.bind(ttl_seconds)
186198
.execute(&mut *tx)
187199
.await?
188200
.rows_affected();
@@ -215,7 +227,8 @@ pub async fn create_channel_with_id(
215227
created_by, created_at, updated_at, archived_at, deleted_at,
216228
nip29_group_id, topic_required, max_members,
217229
topic, topic_set_by, topic_set_at,
218-
purpose, purpose_set_by, purpose_set_at
230+
purpose, purpose_set_by, purpose_set_at,
231+
ttl_seconds, ttl_deadline
219232
FROM channels WHERE id = $1
220233
"#,
221234
)
@@ -237,7 +250,8 @@ pub async fn get_channel(pool: &PgPool, channel_id: Uuid) -> Result<ChannelRecor
237250
created_by, created_at, updated_at, archived_at, deleted_at,
238251
nip29_group_id, topic_required, max_members,
239252
topic, topic_set_by, topic_set_at,
240-
purpose, purpose_set_by, purpose_set_at
253+
purpose, purpose_set_by, purpose_set_at,
254+
ttl_seconds, ttl_deadline
241255
FROM channels WHERE id = $1 AND deleted_at IS NULL
242256
"#,
243257
)
@@ -535,7 +549,8 @@ pub async fn list_channels(pool: &PgPool, visibility: Option<&str>) -> Result<Ve
535549
created_by, created_at, updated_at, archived_at, deleted_at,
536550
nip29_group_id, topic_required, max_members,
537551
topic, topic_set_by, topic_set_at,
538-
purpose, purpose_set_by, purpose_set_at
552+
purpose, purpose_set_by, purpose_set_at,
553+
ttl_seconds, ttl_deadline
539554
FROM channels
540555
WHERE deleted_at IS NULL AND visibility::text = $1
541556
ORDER BY created_at DESC
@@ -553,7 +568,8 @@ pub async fn list_channels(pool: &PgPool, visibility: Option<&str>) -> Result<Ve
553568
created_by, created_at, updated_at, archived_at, deleted_at,
554569
nip29_group_id, topic_required, max_members,
555570
topic, topic_set_by, topic_set_at,
556-
purpose, purpose_set_by, purpose_set_at
571+
purpose, purpose_set_by, purpose_set_at,
572+
ttl_seconds, ttl_deadline
557573
FROM channels
558574
WHERE deleted_at IS NULL
559575
ORDER BY created_at DESC
@@ -596,7 +612,8 @@ async fn get_channel_tx(
596612
created_by, created_at, updated_at, archived_at, deleted_at,
597613
nip29_group_id, topic_required, max_members,
598614
topic, topic_set_by, topic_set_at,
599-
purpose, purpose_set_by, purpose_set_at
615+
purpose, purpose_set_by, purpose_set_at,
616+
ttl_seconds, ttl_deadline
600617
FROM channels WHERE id = $1 AND deleted_at IS NULL
601618
"#,
602619
)
@@ -685,6 +702,7 @@ pub async fn get_accessible_channels(
685702
c.nip29_group_id, c.topic_required, c.max_members,
686703
c.topic, c.topic_set_by, c.topic_set_at,
687704
c.purpose, c.purpose_set_by, c.purpose_set_at,
705+
c.ttl_seconds, c.ttl_deadline,
688706
(cm.channel_id IS NOT NULL) AS is_member
689707
FROM channels c
690708
LEFT JOIN channel_members cm
@@ -807,6 +825,8 @@ fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result<ChannelRecord> {
807825
let purpose: Option<String> = row.try_get("purpose").unwrap_or(None);
808826
let purpose_set_by: Option<Vec<u8>> = row.try_get("purpose_set_by").unwrap_or(None);
809827
let purpose_set_at: Option<DateTime<Utc>> = row.try_get("purpose_set_at").unwrap_or(None);
828+
let ttl_seconds: Option<i32> = row.try_get("ttl_seconds").unwrap_or(None);
829+
let ttl_deadline: Option<DateTime<Utc>> = row.try_get("ttl_deadline").unwrap_or(None);
810830

811831
Ok(ChannelRecord {
812832
id,
@@ -829,6 +849,8 @@ fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result<ChannelRecord> {
829849
purpose,
830850
purpose_set_by,
831851
purpose_set_at,
852+
ttl_seconds,
853+
ttl_deadline,
832854
})
833855
}
834856

@@ -1086,3 +1108,42 @@ pub async fn get_member_role(
10861108
.await?;
10871109
Ok(row.map(|r| r.try_get("role")).transpose()?)
10881110
}
1111+
1112+
/// Bump the TTL deadline for an ephemeral channel after a new message.
1113+
///
1114+
/// No-op for permanent channels or channels that are already archived/deleted.
1115+
pub async fn bump_ttl_deadline(pool: &PgPool, channel_id: Uuid) -> Result<()> {
1116+
sqlx::query(
1117+
"UPDATE channels SET ttl_deadline = NOW() + (ttl_seconds || ' seconds')::interval \
1118+
WHERE id = $1 AND ttl_seconds IS NOT NULL AND archived_at IS NULL AND deleted_at IS NULL",
1119+
)
1120+
.bind(channel_id)
1121+
.execute(pool)
1122+
.await?;
1123+
Ok(())
1124+
}
1125+
1126+
/// Archive ephemeral channels whose TTL deadline has passed.
1127+
///
1128+
/// Returns the list of channel IDs that were archived. Idempotent — the
1129+
/// `archived_at IS NULL` guard prevents double-archiving even if called
1130+
/// concurrently from multiple relay pods.
1131+
pub async fn reap_expired_ephemeral_channels(pool: &PgPool) -> Result<Vec<Uuid>> {
1132+
let rows = sqlx::query(
1133+
"UPDATE channels SET archived_at = NOW() \
1134+
WHERE ttl_seconds IS NOT NULL \
1135+
AND ttl_deadline < NOW() \
1136+
AND archived_at IS NULL \
1137+
AND deleted_at IS NULL \
1138+
RETURNING id",
1139+
)
1140+
.fetch_all(pool)
1141+
.await?;
1142+
1143+
rows.into_iter()
1144+
.map(|row| {
1145+
let id: Uuid = row.try_get("id")?;
1146+
Ok(id)
1147+
})
1148+
.collect()
1149+
}

crates/sprout-db/src/dm.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,8 @@ fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result<ChannelRecord> {
439439
purpose: row.try_get("purpose").unwrap_or(None),
440440
purpose_set_by: row.try_get("purpose_set_by").unwrap_or(None),
441441
purpose_set_at: row.try_get("purpose_set_at").unwrap_or(None),
442+
ttl_seconds: row.try_get("ttl_seconds").unwrap_or(None),
443+
ttl_deadline: row.try_get("ttl_deadline").unwrap_or(None),
442444
})
443445
}
444446

crates/sprout-db/src/lib.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,7 @@ impl Db {
298298
visibility: channel::ChannelVisibility,
299299
description: Option<&str>,
300300
created_by: &[u8],
301+
ttl_seconds: Option<i32>,
301302
) -> Result<channel::ChannelRecord> {
302303
channel::create_channel(
303304
&self.pool,
@@ -306,13 +307,15 @@ impl Db {
306307
visibility,
307308
description,
308309
created_by,
310+
ttl_seconds,
309311
)
310312
.await
311313
}
312314

313315
/// Creates a channel with a client-supplied UUID.
314316
///
315317
/// Returns `(record, true)` if newly created, `(record, false)` if already exists.
318+
#[allow(clippy::too_many_arguments)]
316319
pub async fn create_channel_with_id(
317320
&self,
318321
channel_id: Uuid,
@@ -321,6 +324,7 @@ impl Db {
321324
visibility: channel::ChannelVisibility,
322325
description: Option<&str>,
323326
created_by: &[u8],
327+
ttl_seconds: Option<i32>,
324328
) -> Result<(channel::ChannelRecord, bool)> {
325329
channel::create_channel_with_id(
326330
&self.pool,
@@ -330,6 +334,7 @@ impl Db {
330334
visibility,
331335
description,
332336
created_by,
337+
ttl_seconds,
333338
)
334339
.await
335340
}
@@ -465,6 +470,16 @@ impl Db {
465470
channel::get_member_role(&self.pool, channel_id, pubkey).await
466471
}
467472

473+
/// Bump the TTL deadline for an ephemeral channel after a new message.
474+
pub async fn bump_ttl_deadline(&self, channel_id: Uuid) -> Result<()> {
475+
channel::bump_ttl_deadline(&self.pool, channel_id).await
476+
}
477+
478+
/// Archive ephemeral channels whose TTL deadline has passed.
479+
pub async fn reap_expired_ephemeral_channels(&self) -> Result<Vec<Uuid>> {
480+
channel::reap_expired_ephemeral_channels(&self.pool).await
481+
}
482+
468483
// ── Users ────────────────────────────────────────────────────────────────
469484

470485
/// Ensure a user record exists (upsert).

crates/sprout-relay/src/api/channels.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ fn channel_record_to_json(
112112
"participants": participants,
113113
"participant_pubkeys": participant_pubkeys,
114114
"is_member": is_member,
115+
"ttl_seconds": channel.ttl_seconds,
116+
"ttl_deadline": channel.ttl_deadline.map(|t| t.to_rfc3339()),
115117
})
116118
}
117119

crates/sprout-relay/src/api/channels_metadata.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ fn channel_detail_to_json(record: &ChannelRecord, member_count: i64) -> serde_js
5050
"topic_required": record.topic_required,
5151
"max_members": record.max_members,
5252
"nip29_group_id": record.nip29_group_id,
53+
"ttl_seconds": record.ttl_seconds,
54+
"ttl_deadline": record.ttl_deadline.map(|t| t.to_rfc3339()),
5355
})
5456
}
5557

crates/sprout-relay/src/config.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,13 @@ pub struct Config {
6161
pub pubkey_allowlist_enabled: bool,
6262
/// Media storage configuration (S3/MinIO).
6363
pub media: sprout_media::MediaConfig,
64+
65+
/// Optional override for ephemeral channel TTL (in seconds).
66+
/// When set, any channel created with a TTL tag will use this value instead
67+
/// of the client-provided one. Useful for testing ephemeral expiry quickly.
68+
/// Example: `SPROUT_EPHEMERAL_TTL_OVERRIDE=60` → all ephemeral channels expire
69+
/// 60 seconds after the last message.
70+
pub ephemeral_ttl_override: Option<i32>,
6471
}
6572

6673
impl Config {
@@ -194,6 +201,18 @@ impl Config {
194201
}),
195202
};
196203

204+
let ephemeral_ttl_override = std::env::var("SPROUT_EPHEMERAL_TTL_OVERRIDE")
205+
.ok()
206+
.and_then(|v| v.parse::<i32>().ok())
207+
.filter(|&v| v > 0);
208+
209+
if let Some(ttl) = ephemeral_ttl_override {
210+
warn!(
211+
"SPROUT_EPHEMERAL_TTL_OVERRIDE={ttl}s — all ephemeral channels will use \
212+
this TTL instead of the client-provided value."
213+
);
214+
}
215+
197216
Ok(Self {
198217
bind_addr,
199218
database_url,
@@ -213,6 +232,7 @@ impl Config {
213232
metrics_port,
214233
pubkey_allowlist_enabled,
215234
media,
235+
ephemeral_ttl_override,
216236
})
217237
}
218238
}

crates/sprout-relay/src/handlers/ingest.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1029,6 +1029,8 @@ pub async fn ingest_event(
10291029
}
10301030
});
10311031

1032+
let ttl_seconds = super::resolve_ttl(&event, state.config.ephemeral_ttl_override);
1033+
10321034
let actor_bytes = event.pubkey.serialize().to_vec();
10331035
let (_, was_created) = state
10341036
.db
@@ -1039,6 +1041,7 @@ pub async fn ingest_event(
10391041
visibility,
10401042
description.as_deref(),
10411043
&actor_bytes,
1044+
ttl_seconds,
10421045
)
10431046
.await
10441047
.map_err(|e| IngestError::Internal(format!("error: {e}")))?;
@@ -1304,6 +1307,17 @@ pub async fn ingest_event(
13041307
});
13051308
}
13061309

1310+
// ── 20b. Bump ephemeral channel TTL deadline ──────────────────────
1311+
// Any successfully stored channel-scoped event keeps the channel alive.
1312+
// Skip kind:9007 (create) — the deadline was just set during creation.
1313+
if let Some(ch_id) = channel_id {
1314+
if kind_u32 != KIND_NIP29_CREATE_GROUP {
1315+
if let Err(e) = state.db.bump_ttl_deadline(ch_id).await {
1316+
warn!(channel = %ch_id, "TTL deadline bump failed: {e}");
1317+
}
1318+
}
1319+
}
1320+
13071321
// ── 21. Side effects ─────────────────────────────────────────────────
13081322
if crate::handlers::side_effects::is_side_effect_kind(kind_u32) {
13091323
if let Err(e) =

0 commit comments

Comments
 (0)