2 Commits

Author SHA1 Message Date
Alejandro Gutiérrez
82ebd2b6be chore(broker): wire mentions through WS topic_send + dedupe imports
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
WSSendMessage gains an optional mentions field; the broker forwards
it into appendTopicMessage so WS-driven topic sends get the same
write-time fan-out path as REST POST /v1/messages. v1 messages
(today's plaintext-base64) still fall back to a body regex when the
field is omitted, so existing CLIs aren't broken; v2 ciphertext
clients in phase 3 will populate it.

Also drops the duplicate meshMember import (kept the meshMember-as-
memberTable alias which the rest of the file uses).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:45:57 +01:00
Alejandro Gutiérrez
b70536195a fix(api): ensureGeneralTopic generates a topic key + seals for owner
The web mesh-creation path went straight through db.insert(meshTopic)
and bypassed the broker's createTopic, so the v0.3.0 phase-2 key
generation never ran for #general topics created via the dashboard.
Result: GET /v1/topics/general/key returned 409 topic_unencrypted
on every web-created mesh.

Mirrors the broker's createTopic flow inline: generate a 32-byte
topic key + ephemeral x25519 sender keypair, persist the public
half on topic.encrypted_key_pubkey, seal a copy for the oldest
non-revoked member (the owner — owner-as-member rows are minted
at mesh creation per a prior fix), and let the topicKey leave
memory.

Existing meshes with already-created (and unencrypted) #general
topics aren't backfilled; they stay v0.2.0 plaintext until the
phase 3 client encrypt path lands. New meshes get encrypted
topics from this commit forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:44:26 +01:00
4 changed files with 70 additions and 9 deletions

View File

@@ -38,7 +38,6 @@ import {
meshFileKey,
meshContext,
meshMember as memberTable,
meshMember,
meshMemory,
meshNotification,
meshState,
@@ -896,12 +895,12 @@ async function fanOutMentions(args: {
const recipients = await db
.select({
id: meshMember.id,
displayName: meshMember.displayName,
id: memberTable.id,
displayName: memberTable.displayName,
})
.from(meshMember)
.from(memberTable)
.where(
and(eq(meshMember.meshId, topic.meshId), isNull(meshMember.revokedAt)),
and(eq(memberTable.meshId, topic.meshId), isNull(memberTable.revokedAt)),
);
const tokenSet = new Set(tokens);
const targets = recipients

View File

@@ -1952,6 +1952,7 @@ async function handleSend(
senderSessionPubkey: conn.sessionPubkey ?? undefined,
nonce: msg.nonce,
ciphertext: msg.ciphertext,
mentions: msg.mentions,
}).catch((e) =>
log.warn("appendTopicMessage failed", { topic_id: topicId, err: String(e) }),
);

View File

@@ -98,6 +98,14 @@ export interface WSSendMessage {
nonce: string; // base64
ciphertext: string; // base64
id?: string; // client-side correlation id
/**
* Optional client-extracted `@-mention` display names (lowercased,
* no `@` prefix, max 16). Required when `body_version: 2` cipher
* lands in v0.3.0 phase 3 — the server can't read v2 ciphertext to
* regex-match. Today's v1 plaintext path falls back to a regex on
* the body when this is absent.
*/
mentions?: string[];
}
/** Broker → client: an envelope addressed to this peer. */

View File

@@ -2,13 +2,14 @@ import { randomBytes } from "node:crypto";
import sodium from "libsodium-wrappers";
import { and, eq, isNull } from "@turbostarter/db";
import { and, asc, eq, isNull } from "@turbostarter/db";
import {
invite,
mesh,
meshMember,
meshTopic,
meshTopicMember,
meshTopicMemberKey,
pendingInvite,
} from "@turbostarter/db/schema";
import { db } from "@turbostarter/db/server";
@@ -206,11 +207,24 @@ export const ensureGeneralTopic = async (
meshId: string,
): Promise<{ id: string } | null> => {
const [existing] = await db
.select({ id: meshTopic.id })
.select({
id: meshTopic.id,
encryptedKeyPubkey: meshTopic.encryptedKeyPubkey,
})
.from(meshTopic)
.where(and(eq(meshTopic.meshId, meshId), eq(meshTopic.name, "general")))
.limit(1);
if (existing) return existing;
if (existing) return { id: existing.id };
// Generate the topic's symmetric key + an ephemeral sender keypair
// for v0.3.0 phase 2 sealing. Mirrors the broker's createTopic path
// so web-created topics aren't stuck as unencrypted v0.2.0 placeholders.
// The plaintext topicKey leaves memory after sealing one copy for
// the mesh owner — the broker never persists it.
await sodium.ready;
const topicKey = sodium.randombytes_buf(32);
const senderKp = sodium.crypto_box_keypair();
const [row] = await db
.insert(meshTopic)
.values({
@@ -218,10 +232,49 @@ export const ensureGeneralTopic = async (
name: "general",
description: "Default mesh-wide channel. Every member can read and post.",
visibility: "public",
encryptedKeyPubkey: sodium.to_hex(senderKp.publicKey),
})
.onConflictDoNothing()
.returning({ id: meshTopic.id });
return row ?? null;
if (!row) return null;
// Seal a copy for the oldest non-revoked member (the owner, by
// construction — owner-as-member rows are minted at mesh creation
// time, ahead of this call).
const [owner] = await db
.select({
id: meshMember.id,
peerPubkey: meshMember.peerPubkey,
})
.from(meshMember)
.where(and(eq(meshMember.meshId, meshId), isNull(meshMember.revokedAt)))
.orderBy(asc(meshMember.joinedAt))
.limit(1);
if (owner) {
try {
const recipientX25519 = sodium.crypto_sign_ed25519_pk_to_curve25519(
sodium.from_hex(owner.peerPubkey),
);
const nonce = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES);
const sealed = sodium.crypto_box_easy(
topicKey,
nonce,
recipientX25519,
senderKp.privateKey,
);
await db.insert(meshTopicMemberKey).values({
topicId: row.id,
memberId: owner.id,
encryptedKey: sodium.to_base64(sealed, sodium.base64_variants.ORIGINAL),
nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL),
}).onConflictDoNothing();
} catch {
// Owner pubkey isn't a valid ed25519 key (legacy data?). Topic
// is still created — phase 3 re-seal flow will handle it.
}
}
return row;
};
export const archiveMyMesh = async ({