Phase 2e: rich comments — optional ref_post_id with signed preview

A comment can now reference a separate Post that carries the full body
(long text, attachments, rich formatting). The inline comment's `content`
becomes a short preview string; the referenced post propagates through the
normal CDN and readers fetch it lazily when rendering the expanded view.

Type change:
- `InlineComment` gains `ref_post_id: Option<PostId>` (#[serde(default)]).
  When None, `content` is the full comment text (v0.6.1 shape — unchanged on
  the wire). When Some, `content` is the preview.

Signature binding:
- `crypto::sign_comment` / `verify_comment_signature` now take
  `ref_post_id: Option<&PostId>`. The signed digest appends
  `b"ref:" || ref_post_id` only when a ref is present, so plain comments
  produce the same digest as the v0.6.1 scheme and remain verifiable
  without a migration. When a ref is present the signature binds it, so a
  peer can't strip or swap the reference without re-signing.

Storage:
- `comments` table gets a `ref_post_id BLOB` column (nullable). Added to
  both the CREATE TABLE statement and a conditional ALTER TABLE migration
  so upgraded DBs pick it up automatically.
- `store_comment`, `get_comments`, `get_comments_with_tombstones` read and
  write the column.

Node API:
- `comment_on_post` stays as the plain-comment entry point (calls the
  inner helper with `ref_post_id = None`).
- New `comment_on_post_with_ref(post_id, preview, ref_post_id)` for rich
  comments. Both share a single inner helper that signs, stores, and
  propagates via BlobHeaderDiff.

connection.rs BlobHeaderDiff handler passes `comment.ref_post_id.as_ref()`
to the signature verify so forged or rewritten refs are rejected.

Tests: new crypto test asserting the signature binds ref_post_id (strip /
swap / drop all fail); new storage test asserting ref_post_id roundtrips
through live + tombstone reads. 116 / 116 core tests pass.

Client-side UX (pulling the ref post on expand, composing rich comments)
is frontend work that will land with the next UI iteration.
This commit is contained in:
Scott Reimers 2026-04-22 22:46:24 -04:00
parent 8b2881d84a
commit 88d5cc9f23
5 changed files with 166 additions and 27 deletions

View file

@ -347,6 +347,7 @@ impl Storage {
content TEXT NOT NULL,
timestamp_ms INTEGER NOT NULL,
signature BLOB NOT NULL,
ref_post_id BLOB,
PRIMARY KEY (author, post_id, timestamp_ms)
);
CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_id);
@ -636,6 +637,17 @@ impl Storage {
)?;
}
// v0.6.2: add ref_post_id for rich comments (preview-inline,
// full-body-in-referenced-post). NULL for plain comments.
let has_ref_post_id = self.conn.prepare(
"SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='ref_post_id'"
)?.query_row([], |row| row.get::<_, i64>(0))?;
if has_ref_post_id == 0 {
self.conn.execute_batch(
"ALTER TABLE comments ADD COLUMN ref_post_id BLOB DEFAULT NULL;"
)?;
}
// Add device_role column to peers if missing (Active CDN replication)
let has_device_role = self.conn.prepare(
"SELECT COUNT(*) FROM pragma_table_info('peers') WHERE name='device_role'"
@ -4541,11 +4553,12 @@ impl Storage {
/// deleted_at tombstone, store it so the tombstone propagates.
pub fn store_comment(&self, comment: &InlineComment) -> anyhow::Result<()> {
self.conn.execute(
"INSERT INTO comments (author, post_id, content, timestamp_ms, signature, deleted_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
"INSERT INTO comments (author, post_id, content, timestamp_ms, signature, deleted_at, ref_post_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
ON CONFLICT(author, post_id, timestamp_ms) DO UPDATE SET
content = CASE WHEN excluded.deleted_at IS NOT NULL THEN content ELSE excluded.content END,
deleted_at = CASE WHEN excluded.deleted_at IS NOT NULL THEN excluded.deleted_at ELSE deleted_at END",
deleted_at = CASE WHEN excluded.deleted_at IS NOT NULL THEN excluded.deleted_at ELSE deleted_at END,
ref_post_id = COALESCE(excluded.ref_post_id, ref_post_id)",
params![
comment.author.as_slice(),
comment.post_id.as_slice(),
@ -4553,6 +4566,7 @@ impl Storage {
comment.timestamp_ms as i64,
comment.signature,
comment.deleted_at.map(|v| v as i64),
comment.ref_post_id.as_ref().map(|r| r.as_slice()),
],
)?;
Ok(())
@ -4579,7 +4593,7 @@ impl Storage {
/// Get live (non-tombstoned) comments for a post. Used for UI display.
pub fn get_comments(&self, post_id: &PostId) -> anyhow::Result<Vec<InlineComment>> {
let mut stmt = self.conn.prepare(
"SELECT author, post_id, content, timestamp_ms, signature
"SELECT author, post_id, content, timestamp_ms, signature, ref_post_id
FROM comments WHERE post_id = ?1 AND deleted_at IS NULL ORDER BY timestamp_ms ASC"
)?;
let rows = stmt.query_map(params![post_id.as_slice()], |row| {
@ -4588,13 +4602,18 @@ impl Storage {
let content: String = row.get(2)?;
let ts: i64 = row.get(3)?;
let sig: Vec<u8> = row.get(4)?;
Ok((author, pid, content, ts, sig))
let ref_post: Option<Vec<u8>> = row.get(5)?;
Ok((author, pid, content, ts, sig, ref_post))
})?;
let mut result = Vec::new();
for row in rows {
let (author_bytes, pid_bytes, content, ts, sig) = row?;
let (author_bytes, pid_bytes, content, ts, sig, ref_post) = row?;
let author = blob_to_nodeid(author_bytes)?;
let post_id = blob_to_postid(pid_bytes)?;
let ref_post_id = match ref_post {
Some(b) => Some(blob_to_postid(b)?),
None => None,
};
result.push(InlineComment {
author,
post_id,
@ -4602,6 +4621,7 @@ impl Storage {
timestamp_ms: ts as u64,
signature: sig,
deleted_at: None,
ref_post_id,
});
}
Ok(result)
@ -4611,7 +4631,7 @@ impl Storage {
/// so tombstones propagate through pull-based sync.
pub fn get_comments_with_tombstones(&self, post_id: &PostId) -> anyhow::Result<Vec<InlineComment>> {
let mut stmt = self.conn.prepare(
"SELECT author, post_id, content, timestamp_ms, signature, deleted_at
"SELECT author, post_id, content, timestamp_ms, signature, deleted_at, ref_post_id
FROM comments WHERE post_id = ?1 ORDER BY timestamp_ms ASC"
)?;
let rows = stmt.query_map(params![post_id.as_slice()], |row| {
@ -4621,13 +4641,18 @@ impl Storage {
let ts: i64 = row.get(3)?;
let sig: Vec<u8> = row.get(4)?;
let del: Option<i64> = row.get(5)?;
Ok((author, pid, content, ts, sig, del))
let ref_post: Option<Vec<u8>> = row.get(6)?;
Ok((author, pid, content, ts, sig, del, ref_post))
})?;
let mut result = Vec::new();
for row in rows {
let (author_bytes, pid_bytes, content, ts, sig, del) = row?;
let (author_bytes, pid_bytes, content, ts, sig, del, ref_post) = row?;
let author = blob_to_nodeid(author_bytes)?;
let post_id = blob_to_postid(pid_bytes)?;
let ref_post_id = match ref_post {
Some(b) => Some(blob_to_postid(b)?),
None => None,
};
result.push(InlineComment {
author,
post_id,
@ -4635,6 +4660,7 @@ impl Storage {
timestamp_ms: ts as u64,
signature: sig,
deleted_at: del.map(|v| v as u64),
ref_post_id,
});
}
Ok(result)
@ -6045,6 +6071,7 @@ mod tests {
timestamp_ms: 1000,
signature: vec![0u8; 64],
deleted_at: None,
ref_post_id: None,
}).unwrap();
s.store_comment(&InlineComment {
@ -6054,6 +6081,7 @@ mod tests {
timestamp_ms: 1001,
signature: vec![1u8; 64],
deleted_at: None,
ref_post_id: None,
}).unwrap();
let comments = s.get_comments(&post_id).unwrap();
@ -6063,6 +6091,33 @@ mod tests {
assert_eq!(s.get_comment_count(&post_id).unwrap(), 2);
}
#[test]
fn rich_comment_ref_post_id_roundtrip() {
use crate::types::InlineComment;
let s = temp_storage();
let post_id = make_post_id(1);
let author = make_node_id(5);
let ref_post = make_post_id(42);
s.store_comment(&InlineComment {
author,
post_id,
content: "(preview of a long body)".to_string(),
timestamp_ms: 2000,
signature: vec![9u8; 64],
deleted_at: None,
ref_post_id: Some(ref_post),
}).unwrap();
let live = s.get_comments(&post_id).unwrap();
assert_eq!(live.len(), 1);
assert_eq!(live[0].ref_post_id, Some(ref_post));
let all = s.get_comments_with_tombstones(&post_id).unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].ref_post_id, Some(ref_post));
}
#[test]
fn comment_policy_crud() {
use crate::types::{CommentPermission, CommentPolicy, ModerationMode, ReactPermission};