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>
This commit is contained in:
@@ -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 ({
|
||||
|
||||
Reference in New Issue
Block a user