Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ survives every release.
- [x] Public timeline: landing feed + Discover tab backed by the global-index shard
- [x] Facade contract — stable bookmarkable URL that survives release contract-id rotation
- [ ] Wire follows UI to the user shard (Following tab exists but empty)
- [ ] Wire replies UI + delegate reply signing to the thread shard (`onThreadReply` is a no-op, pending #12)
- [x] Wire replies UI + delegate reply signing (`SignReply`) to the thread shard (#12; nested threads / `thread_root` still TODO)
- [ ] Wire notifications UI to the inbox shard (contract done; UI renders mock records)
- [ ] Handle registry: handle → pubkey resolution (no registry contract yet)
- [ ] Real "who to follow" + search backed by the social graph / an index (currently mock/local-filter)
Expand Down Expand Up @@ -157,11 +157,11 @@ It supports:
- **GetIdentity**: Retrieve the current identity
- **SignPost**: Sign post content for authenticity (assigns the content-addressed post id)
- **SignLike**: Sign a like/unlike record bound to a thread's root post id
- **SignReply**: Sign a reply (a `Post` with a non-empty `reply_to`, optional `quoted_post`) bound to a thread's root post id; `SignPost` stays byte-identical for top-level posts
- **ExportIdentity / ImportIdentity**: Transfer identity between devices (64-hex seed)

Reply signing (a `SignPost` with a non-empty `reply_to`) and notification delivery are not yet
wired from the UI — the thread shard verifies replies and the inbox shard accepts notifications,
but the client cutover for those surfaces lands in later ADR-0001 Phase 4 slices.
Notification delivery is not yet wired from the UI — the inbox shard accepts notifications,
but the client cutover for that surface lands in a later ADR-0001 Phase 4 slice.

The delegate key is computed as `BLAKE3(BLAKE3(wasm_bytes))` with empty parameters, and the code
hash as `BLAKE3(wasm_bytes)`. Both are required for the node to locate the delegate in its store.
Expand Down
17 changes: 14 additions & 3 deletions docs/adr/0001-implementation-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -460,9 +460,10 @@ makes everything silently no-op.
## Phase 4 decisions (thread shard — likes, slice 2)

Slice 2 wires the **thread shard** for one operation end-to-end — **likes** —
to prove the delegate→sign→thread-shard→UI loop for a non-post record. Replies,
quotes, the inbox shard, the notifications UI, and the legacy global-contract
teardown are each their own later slice.
to prove the delegate→sign→thread-shard→UI loop for a non-post record. Quotes
and replies followed in later slices (replies via `SignReply`, see below); the
inbox shard, the notifications UI, and the legacy global-contract teardown are
each their own later slice.

### Delegate signs non-post records via the same single trusted encoder

Expand All @@ -477,6 +478,16 @@ one audited place; the browser only assembles the returned fields into a
unaudited correctness surface into JavaScript.) Quotes/replies/notifications/
prunes follow this same per-record-message pattern in later slices.

Replies use the same pattern via a `SignReply{nonce, content, author_name,
author_handle, timestamp, reply_to, quoted_post}` → `SignedReply{…, post_id,
signature}` message (#12). A reply is structurally a `Post` with a non-empty
`reply_to`, so the delegate reuses the post encoder
(`common::Post::signing_payload`, which appends `reply_to`/`quoted_post` only
when non-empty). That keeps top-level `SignPost` output **byte-identical** —
existing post signatures are unaffected — while the thread shard's
`reply_is_acceptable` gate (`post.reply_to == root && post.verify()`) binds a
reply to its thread and makes rebinding to another root fail verification.

### Thread-shard key derivation: parameter is the UTF-8 id string

A thread shard is parameterized by its **root post id**, and the contract reads
Expand Down
179 changes: 179 additions & 0 deletions web/src/freenet-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ vi.mock("./identity", () => ({
signLike: vi.fn(() => true),
signRepost: vi.fn(() => true),
signQuoteRef: vi.fn(() => true),
signReply: vi.fn(() => true),
}));

import {
Expand All @@ -116,6 +117,7 @@ import {
type RepostState,
type QuoteState,
} from "./freenet-api";
import type { Post } from "./types";

// A real ML-DSA-65 VK is 1952 bytes → 3904 hex chars; setUser only initialises
// the user shard for a key of exactly that length (the offline 64-char fake is
Expand All @@ -130,6 +132,7 @@ function makeConnection() {
onLikeUpdated: ReturnType<typeof vi.fn>;
onRepostUpdated: ReturnType<typeof vi.fn>;
onQuoteUpdated: ReturnType<typeof vi.fn>;
onRepliesUpdated: ReturnType<typeof vi.fn>;
onGlobalPostsLoaded: ReturnType<typeof vi.fn>;
onNewGlobalPost: ReturnType<typeof vi.fn>;
} = {
Expand All @@ -139,6 +142,7 @@ function makeConnection() {
onLikeUpdated: vi.fn(),
onRepostUpdated: vi.fn(),
onQuoteUpdated: vi.fn(),
onRepliesUpdated: vi.fn(),
onGlobalPostsLoaded: vi.fn(),
onNewGlobalPost: vi.fn(),
};
Expand Down Expand Up @@ -979,6 +983,181 @@ describe("FreenetConnection", () => {
});
});

// -------------------------------------------------------------------------
// Reply path — mirrors the like/repost/quote test patterns exactly.
// -------------------------------------------------------------------------
describe("reply path", () => {
describe("dropPendingReply — nonce matching", () => {
it("returns false for an unknown nonce; true then false for a real pending reply", async () => {
const { conn, api } = makeConnection();
conn.setUser(OWNER_VK, "Alice", "alice");
// Drain all user-shard GETs (probe + loadUserShard) so the serialised
// GET chain is idle before replyPost (which itself awaits ensureThreadShard).
await drainGets(api);

// Seed a pending reply via replyPost. It calls ensureThreadShard (probe
// GET) then records the pending entry and calls signReply (mock -> true).
const replyPromise = conn.replyPost("root-reply-drop", "hello thread");
await flush();
api.getCalls[api.getCalls.length - 1].resolve({} as GetResponse); // probe exists
const ok = await replyPromise;
expect(ok).toBe(true);

// Unknown nonce is always false.
expect(conn.dropPendingReply("nonce-that-does-not-exist")).toBe(false);

// Retrieve the real nonce from the signReply mock call.
const signReplyMock = (await import("./identity"))
.signReply as unknown as ReturnType<typeof vi.fn>;
const nonce = signReplyMock.mock.calls[
signReplyMock.mock.calls.length - 1
][0] as string;

expect(conn.dropPendingReply(nonce)).toBe(true); // matched -> dropped
expect(conn.dropPendingReply(nonce)).toBe(false); // already gone
});
});

describe("optimistic reply revert", () => {
it("completeReply (matched nonce) sends a Replies delta then refreshes on SUCCESS", async () => {
const { conn, api } = makeConnection();
conn.setUser(OWNER_VK, "Alice", "alice");
// Drain all user-shard GETs so the chain is idle before replyPost.
await drainGets(api);

const signReplyMock = (await import("./identity"))
.signReply as unknown as ReturnType<typeof vi.fn>;
const replyPromise = conn.replyPost("root-reply-ok", "my reply");
await flush();
api.getCalls[api.getCalls.length - 1].resolve({} as GetResponse); // probe exists
await replyPromise;
// signReply(nonce, content, name, handle, timestamp, rootPostId) — first arg is nonce.
const nonce = signReplyMock.mock.calls[
signReplyMock.mock.calls.length - 1
][0] as string;

const getsBefore = api.getCalls.length;
const ok = await conn.completeReply({
nonce,
post_id: "reply-post-id",
signature: "sig",
public_key: OWNER_VK,
});
expect(ok).toBe(true);
// A DeltaUpdate was sent and a refresh GET followed.
expect(api.updateCalls.length).toBe(1);
expect(api.getCalls.length).toBe(getsBefore + 1);
// The matched nonce was consumed; dropping it again is a no-op.
expect(conn.dropPendingReply(nonce)).toBe(false);
});

it("completeReply with an UNMATCHED nonce returns false and does NOT call update", async () => {
const { conn, api } = makeConnection();
conn.setUser(OWNER_VK, "Alice", "alice");
// Drain all user-shard GETs so the chain is idle before replyPost.
await drainGets(api);

// Seed a real pending reply so the state isn't empty, but call
// completeReply with a foreign nonce — must no-op and return false.
const signReplyMock = (await import("./identity"))
.signReply as unknown as ReturnType<typeof vi.fn>;
const replyPromise = conn.replyPost("root-reply-unmatched", "content");
await flush();
api.getCalls[api.getCalls.length - 1].resolve({} as GetResponse);
await replyPromise;
const realNonce = signReplyMock.mock.calls[
signReplyMock.mock.calls.length - 1
][0] as string;

const updatesBefore = api.updateCalls.length;
const getsBefore = api.getCalls.length;
const res = await conn.completeReply({
nonce: "foreign-nonce",
post_id: "pid",
signature: "sig",
public_key: OWNER_VK,
});
expect(res).toBe(false);
expect(api.updateCalls.length).toBe(updatesBefore); // no update sent
expect(api.getCalls.length).toBe(getsBefore); // no refresh GET

// The real pending reply is still there: completing it now succeeds.
const res2 = await conn.completeReply({
nonce: realNonce,
post_id: "pid",
signature: "sig",
public_key: OWNER_VK,
});
expect(res2).toBe(true);
expect(api.updateCalls.length).toBe(updatesBefore + 1);
});
});

it("a thread-shard GET emits onRepliesUpdated with replies sorted oldest-first", async () => {
const { conn, api, callbacks } = makeConnection();
conn.setUser(OWNER_VK, "Alice", "alice");
await flush();
api.getCalls[0].resolve({} as GetResponse); // user-shard probe exists
await flush();
await flush();
if (api.getCalls[1]) api.getCalls[1].resolve({} as GetResponse); // loadUserShard GET
await flush();
await flush();

// Register the thread instance→root mapping via repostPost (cheapest path).
const rootId = "reply-thread-root";
const rp = conn.repostPost(rootId, true);
await flush();
const probe = api.getCalls[api.getCalls.length - 1];
probe.resolve({} as GetResponse);
await rp;

// Two replies with out-of-order timestamps — newer listed first in the
// map to prove the read side sorts oldest-first (asc by timestamp).
const replies = {
r2: {
id: "r2",
author_pubkey: OWNER_VK,
author_name: "Alice",
author_handle: "alice",
content: "second reply",
timestamp: 5000,
reply_to: rootId,
quoted_post: "",
signature: "s2",
},
r1: {
id: "r1",
author_pubkey: "cd".repeat(1952),
author_name: "Bob",
author_handle: "bob",
content: "first reply",
timestamp: 1000,
reply_to: rootId,
quoted_post: "",
signature: "s1",
},
};
const stateBytes = Array.from(
new TextEncoder().encode(JSON.stringify({ replies })),
);
callbacks.onRepliesUpdated.mockClear();
api.handler.onContractGet({
key: probe.req.key,
state: stateBytes,
} as unknown as GetResponse);

expect(callbacks.onRepliesUpdated).toHaveBeenCalledTimes(1);
const [emittedRootId, emittedReplies] =
callbacks.onRepliesUpdated.mock.calls[0] as [string, Post[]];
expect(emittedRootId).toBe(rootId);
// Oldest first (timestamp asc).
expect(emittedReplies.map((p) => p.id)).toEqual(["r1", "r2"]);
expect(emittedReplies[0].content).toBe("first reply");
expect(emittedReplies[1].content).toBe("second reply");
});
});

// -------------------------------------------------------------------------
// Shard PUT serialization (regression guard for the "field 8 must be set"
// bug, freenet/raven#56). Every browser-side shard instantiation goes through
Expand Down
56 changes: 32 additions & 24 deletions web/src/stores/freenet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,16 @@ export const connection = new FreenetConnection({
next.set(rootPostId, replies);
return next;
});
// Reply count comes from the authoritative thread-shard reply list —
// same cadence as like/quote aggregates: only refreshes when that post's
// thread shard is read.
posts.update((cur) => {
const post = cur.find((p) => p.id === rootPostId);
if (post) {
post.replies = replies.length;
}
return [...cur];
});
},
onDelegateResponse: (response: DelegateResponse) => {
const payloads = parseDelegateResponse(response);
Expand All @@ -251,9 +261,9 @@ export const connection = new FreenetConnection({
return;
}

// A signed post (or reply) came back from the delegate. Try completeReply
// first (matches by nonce against pendingReplies); if it returns false the
// nonce belongs to a regular publish — route there instead.
// A signed post (or reply) came back from the delegate. Route by the
// delegate-tagged response type: SignedReply → completeReply only;
// Signed → completePublish only. No fallback probing needed.
const signed = payload as {
type?: string;
nonce?: string;
Expand All @@ -268,27 +278,25 @@ export const connection = new FreenetConnection({
signed.signature &&
signed.public_key
) {
connection
.completeReply({
nonce: signed.nonce,
post_id: signed.post_id,
signature: signed.signature,
public_key: signed.public_key,
})
.then((handled) => {
if (!handled) {
// Not a reply — route to the regular publish path.
connection
.completePublish({
nonce: signed.nonce!,
post_id: signed.post_id!,
signature: signed.signature!,
public_key: signed.public_key!,
})
.catch((e) => console.error("[delegate] completePublish failed:", e));
}
})
.catch((e) => console.error("[delegate] completeReply failed:", e));
if (signed.type === "SignedReply") {
connection
.completeReply({
nonce: signed.nonce,
post_id: signed.post_id,
signature: signed.signature,
public_key: signed.public_key,
})
.catch((e) => console.error("[delegate] completeReply failed:", e));
} else {
connection
.completePublish({
nonce: signed.nonce,
post_id: signed.post_id,
signature: signed.signature,
public_key: signed.public_key,
})
.catch((e) => console.error("[delegate] completePublish failed:", e));
}
return;
}

Expand Down
11 changes: 10 additions & 1 deletion web/tests/node-e2e/reply-thread.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,17 @@ test("reply appears in thread view (same session)", async ({ page }) => {
// was not yet finalised; this test is intentionally SEPARATE so that test 1
// can pass even when the cross-session GET is broken.
// ---------------------------------------------------------------------------
test("reply persists across page reload (cross-session — #50 seam, may fail on single-node)", async ({
test("reply persists across page reload (cross-session — #50 seam, opt-in via E2E_RUN_CROSS_SESSION)", async ({
page,
}) => {
// This test exercises the #50 reload-GET seam which is not guaranteed to
// succeed on a single-node setup. Skip in normal CI; set
// E2E_RUN_CROSS_SESSION=1 to enforce it.
test.fixme(
!process.env.E2E_RUN_CROSS_SESSION,
"Cross-session reload exercises the #50 single-node reload-GET seam, which is not guaranteed on a single node; set E2E_RUN_CROSS_SESSION=1 to enforce it.",
);

// Boot the app and get to a known post; the shared delegate already has an
// identity from the same-session test above (serial workers:1 node).
const { logs } = instrument(page);
Expand Down Expand Up @@ -209,6 +217,7 @@ test("reply persists across page reload (cross-session — #50 seam, may fail on
await page.reload({ waitUntil: "domcontentloaded" });
const a2 = await ensureAppShell(page, "Reply Tester Reload");
await ensurePostExists(a2);
// TODO(#50): locate the replied-to post by a stable marker, not .first()
const firstPost2 = a2.locator(".feed__posts .post").first();
await expect(firstPost2).toBeVisible({ timeout: 20_000 });
await firstPost2.locator(".post-act--reply").click();
Expand Down
Loading