feat(broker+api): per-topic symmetric keys — schema + creator seal
Phase 2 (infra layer) of v0.3.0. Topics now generate a 32-byte
XSalsa20-Poly1305 key on creation; the broker seals one copy via
crypto_box for the topic creator using an ephemeral x25519
sender keypair (whose public half lives on
topic.encrypted_key_pubkey). Topic key plaintext leaves memory
immediately after the creator's seal — the broker can't read it.
Schema 0026:
+ topic.encrypted_key_pubkey (text, nullable for legacy v0.2.0)
+ topic_message.body_version (integer, 1=plaintext / 2=v2 cipher)
+ topic_member_key (id, topic_id, member_id,
encrypted_key, nonce, rotated_at)
API:
+ GET /v1/topics/:name/key — return the calling member's sealed
copy. 404 if no copy exists yet (joined post-creation, no peer
has re-sealed). 409 if the topic is legacy unencrypted.
Open question parked: how new joiners get their sealed copy
without ceding plaintext to the broker. Spec at
.artifacts/specs/2026-05-02-topic-key-onboarding.md picks
member-driven re-seal (Option B). Pending-seals endpoint, seal
POST, and the actual on-the-wire encryption ship in phase 3.
Mention fan-out from phase 1 (notification table) is decoupled
from ciphertext, so /v1/notifications + MentionsSection keep
working unchanged through both phases.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
138
.artifacts/specs/2026-05-02-topic-key-onboarding.md
Normal file
138
.artifacts/specs/2026-05-02-topic-key-onboarding.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# Topic-key onboarding — v0.3.0 phase 2
|
||||
|
||||
The schema for per-topic encryption is shipped (migration 0026). The
|
||||
broker generates a 32-byte XSalsa20-Poly1305 key when a topic is
|
||||
created and seals one copy for the creator via `crypto_box`. The open
|
||||
question is **how new joiners get their sealed copy** without giving
|
||||
the broker the plaintext.
|
||||
|
||||
This spec covers the three live options, picks one for v0.3.0 phase 2,
|
||||
and parks the rest as future cuts. Implementation is **not in this
|
||||
spec** — that follows once we ship the chosen flow.
|
||||
|
||||
---
|
||||
|
||||
## The constraint
|
||||
|
||||
The broker holds:
|
||||
|
||||
- `topic.encrypted_key_pubkey` — the ephemeral x25519 pubkey used to
|
||||
seal each member's copy. Public. The matching secret is **discarded
|
||||
immediately after creation** — only the topic creator's session
|
||||
knows the topic key briefly during sealing, then it leaves memory.
|
||||
- `topic_member_key.(encrypted_key, nonce)` — per-member sealed
|
||||
ciphertext.
|
||||
|
||||
The broker **must not** be able to decrypt any sealed copy. So when a
|
||||
new member joins a topic that already exists, the broker can't seal a
|
||||
copy for them by itself.
|
||||
|
||||
## Option A — server-side escrow (REJECTED)
|
||||
|
||||
Broker holds the topic key encrypted under its own service key + per-
|
||||
member sealed copies. Re-sealing for new members is a server-only
|
||||
operation.
|
||||
|
||||
**Why rejected:** the broker can read every message in every topic
|
||||
forever. Calling that "per-topic encryption" misleads users. Worse
|
||||
than today's plaintext-base64 because it implies a security property
|
||||
the design doesn't deliver.
|
||||
|
||||
## Option B — member-driven re-seal (CHOSEN for phase 2)
|
||||
|
||||
When a new member joins, an existing member's CLIENT decrypts their
|
||||
own sealed copy of the topic key, then seals a new copy for the
|
||||
joiner and POSTs it to the broker.
|
||||
|
||||
**Wire:**
|
||||
|
||||
1. New member joins via `claudemesh topic join <topic>` — broker
|
||||
inserts `topic_member` row, no `topic_member_key` row.
|
||||
2. New member calls `GET /v1/topics/:name/key` → 404 with
|
||||
`key_not_sealed_for_member`.
|
||||
3. Existing online members (any of them) periodically poll
|
||||
`GET /v1/topics/:name/pending-seals` (new endpoint) and see the
|
||||
new joiner.
|
||||
4. Existing member's client:
|
||||
- Decrypts their own sealed copy via `crypto_box_open` with their
|
||||
x25519 secret + `topic.encrypted_key_pubkey`.
|
||||
- Generates a fresh ephemeral x25519 keypair.
|
||||
- Seals the topic key for the joiner via `crypto_box` with the
|
||||
joiner's pubkey + the new ephemeral.
|
||||
- POSTs the result to `POST /v1/topics/:name/seal`.
|
||||
5. Broker stores the new `topic_member_key` row.
|
||||
6. New member's `GET /v1/topics/:name/key` now returns 200.
|
||||
|
||||
**Trust model:** broker never sees plaintext. Assumes at least one
|
||||
existing member is online when the joiner connects. Worst case the
|
||||
joiner waits — UI shows "waiting for a peer to share the topic key"
|
||||
until somebody seals.
|
||||
|
||||
**Open detail — sender pubkey identity:** each re-seal uses a fresh
|
||||
ephemeral pubkey. Either:
|
||||
|
||||
(a) Store ALL ephemeral pubkeys ever used to seal copies of this
|
||||
topic, indexed by member, so the joiner can pick the right one
|
||||
when decrypting. Adds a new table.
|
||||
(b) Embed the ephemeral pubkey in the sealed payload itself (
|
||||
`encrypted_key` becomes `<32-byte ephem_pubkey><crypto_box_easy>`).
|
||||
Decoder pulls the prefix, uses it as the sender pubkey. No schema
|
||||
change beyond what 0026 already ships.
|
||||
|
||||
(b) wins on simplicity. Phase 2 implementation uses it.
|
||||
|
||||
## Option C — leaderless protocol (DEFERRED)
|
||||
|
||||
MLS, TreeKEM, or similar continuous group key agreement. Right answer
|
||||
for groups >50 members. Overkill for v0.3.0 — implementation cost is
|
||||
4-6 weeks of focused work, and the threat model gain over Option B
|
||||
only matters if we believe a member's machine can be silently
|
||||
compromised long enough to leak the topic key but short enough that
|
||||
they aren't kicked from the topic.
|
||||
|
||||
Park for v0.4.0 or v0.5.0. Revisit when we onboard a customer that
|
||||
asks for FS (forward secrecy) on group chat.
|
||||
|
||||
---
|
||||
|
||||
## Phase-2 implementation checklist
|
||||
|
||||
Schema (0026 — done):
|
||||
- [x] `topic.encrypted_key_pubkey` (legacy field, will be unused in
|
||||
Option B's "embed in payload" mode, but keeping it for
|
||||
forward-compat if we ever switch to Option C)
|
||||
- [x] `topic_member_key.(encrypted_key, nonce)`
|
||||
- [x] `topic_message.body_version` (1 = v0.2.0 plaintext, 2 = v0.3.0 ciphertext)
|
||||
|
||||
API (some done — see annotations):
|
||||
- [x] `GET /v1/topics/:name/key` — fetch the calling member's sealed copy
|
||||
- [ ] `GET /v1/topics/:name/pending-seals` — list members without keys
|
||||
- [ ] `POST /v1/topics/:name/seal` — submit a re-sealed copy
|
||||
|
||||
Broker:
|
||||
- [x] `createTopic` generates topic key + seals for creator
|
||||
- [ ] `joinTopic` becomes a "pending" insert — no key seal
|
||||
- [ ] (optional) WS notification to online topic members when a new
|
||||
joiner arrives, so re-seal latency is sub-second instead of
|
||||
polling-bound
|
||||
|
||||
Client (CLI + web):
|
||||
- [ ] On topic open, fetch sealed key, decrypt + cache in memory
|
||||
- [ ] On send, encrypt body with topic key, set `body_version: 2`
|
||||
- [ ] On render, decrypt v2 messages with cached key; v1 stays
|
||||
base64 plaintext (legacy)
|
||||
- [ ] Background re-seal loop — poll for pending joiners, seal,
|
||||
POST
|
||||
|
||||
UX:
|
||||
- [ ] "waiting for a peer to share the topic key" state when GET key
|
||||
returns 404
|
||||
- [ ] "you are the only online member — joiners can't read messages
|
||||
until someone else logs in" warning when sole online holder
|
||||
goes offline
|
||||
|
||||
The phase-2 commit ships only the schema + creator-seal + GET /key.
|
||||
The pending-seals endpoint, seal POST, and client encryption land in
|
||||
phase 3 once this spec gets a code review. Mention fan-out from
|
||||
phase 1 already works for both v1 and v2 messages, so /v1/notifications
|
||||
keeps working through the cutover.
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
meshStream,
|
||||
meshTopic,
|
||||
meshTopicMember,
|
||||
meshTopicMemberKey,
|
||||
meshTopicMessage,
|
||||
meshVaultEntry,
|
||||
meshTask,
|
||||
@@ -557,12 +558,27 @@ export async function createTopic(args: {
|
||||
description?: string;
|
||||
visibility?: "public" | "private" | "dm";
|
||||
createdByMemberId?: string;
|
||||
}): Promise<{ id: string; created: boolean }> {
|
||||
}): Promise<{ id: string; created: boolean; encryptedKeyPubkey?: string }> {
|
||||
const existing = await db
|
||||
.select({ id: meshTopic.id })
|
||||
.select({
|
||||
id: meshTopic.id,
|
||||
encryptedKeyPubkey: meshTopic.encryptedKeyPubkey,
|
||||
})
|
||||
.from(meshTopic)
|
||||
.where(and(eq(meshTopic.meshId, args.meshId), eq(meshTopic.name, args.name)));
|
||||
if (existing[0]) return { id: existing[0].id, created: false };
|
||||
if (existing[0]) {
|
||||
return {
|
||||
id: existing[0].id,
|
||||
created: false,
|
||||
encryptedKeyPubkey: existing[0].encryptedKeyPubkey ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Generate the topic's per-message symmetric key + an ephemeral
|
||||
// sender keypair used to seal it for each member. The plaintext
|
||||
// topicKey is held in memory only long enough to seal one copy per
|
||||
// member; the broker never persists it.
|
||||
const topicKeyBundle = await generateTopicKeyBundle();
|
||||
|
||||
const [row] = await db
|
||||
.insert(meshTopic)
|
||||
@@ -572,10 +588,119 @@ export async function createTopic(args: {
|
||||
description: args.description ?? null,
|
||||
visibility: args.visibility ?? "public",
|
||||
createdByMemberId: args.createdByMemberId ?? null,
|
||||
encryptedKeyPubkey: topicKeyBundle.senderPubkeyHex,
|
||||
})
|
||||
.returning({ id: meshTopic.id });
|
||||
if (!row) throw new Error("failed to create topic");
|
||||
return { id: row.id, created: true };
|
||||
|
||||
// Seal a copy for the creator immediately. Other members get sealed
|
||||
// copies as they join via joinTopic().
|
||||
if (args.createdByMemberId) {
|
||||
await sealTopicKeyForMember({
|
||||
topicId: row.id,
|
||||
memberId: args.createdByMemberId,
|
||||
bundle: topicKeyBundle,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
created: true,
|
||||
encryptedKeyPubkey: topicKeyBundle.senderPubkeyHex,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a per-topic symmetric key + an ephemeral x25519 sender keypair
|
||||
* used to seal it. Returns the bundle in a form that callers can hand to
|
||||
* sealTopicKeyForMember() repeatedly without ever persisting the key
|
||||
* plaintext.
|
||||
*
|
||||
* crypto_kx is the libsodium primitive matching v0.1's mesh handshake,
|
||||
* but we only need a fresh x25519 pair here — keyPair() suffices.
|
||||
*/
|
||||
async function generateTopicKeyBundle(): Promise<{
|
||||
topicKey: Uint8Array;
|
||||
senderSecret: Uint8Array;
|
||||
senderPubkey: Uint8Array;
|
||||
senderPubkeyHex: string;
|
||||
}> {
|
||||
const sodium = await import("libsodium-wrappers");
|
||||
await sodium.ready;
|
||||
const topicKey = sodium.randombytes_buf(32);
|
||||
const sender = sodium.crypto_box_keypair();
|
||||
return {
|
||||
topicKey,
|
||||
senderSecret: sender.privateKey,
|
||||
senderPubkey: sender.publicKey,
|
||||
senderPubkeyHex: sodium.to_hex(sender.publicKey),
|
||||
};
|
||||
}
|
||||
|
||||
interface TopicKeyBundle {
|
||||
topicKey: Uint8Array;
|
||||
senderSecret: Uint8Array;
|
||||
senderPubkey: Uint8Array;
|
||||
senderPubkeyHex: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seal the topic key for one member using crypto_box. Idempotent on
|
||||
* (topicId, memberId) — calling again rotates the cipher but not the
|
||||
* underlying key (rotation is a separate flow).
|
||||
*
|
||||
* The recipient's peer pubkey is the ed25519 key they registered with
|
||||
* the broker. crypto_box wants x25519, so we convert. Members decrypt
|
||||
* with crypto_box_open + sender pubkey + their own x25519 secret
|
||||
* (derived from their ed25519 secret the same way).
|
||||
*/
|
||||
async function sealTopicKeyForMember(args: {
|
||||
topicId: string;
|
||||
memberId: string;
|
||||
bundle: TopicKeyBundle;
|
||||
}): Promise<void> {
|
||||
const [member] = await db
|
||||
.select({ peerPubkey: memberTable.peerPubkey })
|
||||
.from(memberTable)
|
||||
.where(eq(memberTable.id, args.memberId));
|
||||
if (!member) return;
|
||||
|
||||
const sodium = await import("libsodium-wrappers");
|
||||
await sodium.ready;
|
||||
let recipientX25519: Uint8Array;
|
||||
try {
|
||||
const ed = sodium.from_hex(member.peerPubkey);
|
||||
recipientX25519 = sodium.crypto_sign_ed25519_pk_to_curve25519(ed);
|
||||
} catch {
|
||||
// Recipient pubkey isn't a valid ed25519 key — skip silently. The
|
||||
// member won't be able to read v2 messages on this topic until
|
||||
// their identity is regenerated.
|
||||
return;
|
||||
}
|
||||
const nonce = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES);
|
||||
const sealed = sodium.crypto_box_easy(
|
||||
args.bundle.topicKey,
|
||||
nonce,
|
||||
recipientX25519,
|
||||
args.bundle.senderSecret,
|
||||
);
|
||||
|
||||
await db
|
||||
.insert(meshTopicMemberKey)
|
||||
.values({
|
||||
topicId: args.topicId,
|
||||
memberId: args.memberId,
|
||||
encryptedKey: sodium.to_base64(sealed, sodium.base64_variants.ORIGINAL),
|
||||
nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [meshTopicMemberKey.topicId, meshTopicMemberKey.memberId],
|
||||
set: {
|
||||
encryptedKey: sodium.to_base64(sealed, sodium.base64_variants.ORIGINAL),
|
||||
nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL),
|
||||
rotatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** List topics in a mesh, with member counts. */
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
meshNotification,
|
||||
meshTopic,
|
||||
meshTopicMember,
|
||||
meshTopicMemberKey,
|
||||
meshTopicMessage,
|
||||
messageQueue,
|
||||
presence,
|
||||
@@ -595,6 +596,91 @@ export const v1Router = new Hono<Env>()
|
||||
});
|
||||
})
|
||||
|
||||
// GET /v1/topics/:name/key — fetch the calling member's sealed copy
|
||||
// of the topic's symmetric key. v0.3.0 phase 2.
|
||||
//
|
||||
// The broker stores `crypto_box(topic_key, recipient_x25519,
|
||||
// ephemeral_sender_x25519)` per (topic, member). Clients decrypt with
|
||||
// their ed25519→x25519-converted secret + the topic's ephemeral
|
||||
// sender pubkey on `topic.encrypted_key_pubkey`.
|
||||
//
|
||||
// Returns 404 when no sealed copy exists for this member yet —
|
||||
// expected when the member joined a topic after creation and no
|
||||
// other peer has re-sealed the key for them. UI surfaces a "pending
|
||||
// — waiting for re-seal from another member" state in that case.
|
||||
// Spec for the re-seal flow lives at
|
||||
// `.artifacts/specs/2026-05-02-topic-key-onboarding.md`.
|
||||
.get("/topics/:name/key", async (c) => {
|
||||
const key = c.var.apiKey;
|
||||
requireCapability(key, "read");
|
||||
const name = c.req.param("name");
|
||||
requireTopicScope(key, name);
|
||||
|
||||
if (!key.issuedByMemberId) {
|
||||
return c.json({ error: "api_key_has_no_issuer" }, 400);
|
||||
}
|
||||
|
||||
const [topic] = await db
|
||||
.select({
|
||||
id: meshTopic.id,
|
||||
encryptedKeyPubkey: meshTopic.encryptedKeyPubkey,
|
||||
})
|
||||
.from(meshTopic)
|
||||
.where(
|
||||
and(
|
||||
eq(meshTopic.meshId, key.meshId),
|
||||
eq(meshTopic.name, name),
|
||||
isNull(meshTopic.archivedAt),
|
||||
),
|
||||
);
|
||||
if (!topic) {
|
||||
return c.json({ error: "topic_not_found", topic: name }, 404);
|
||||
}
|
||||
if (!topic.encryptedKeyPubkey) {
|
||||
return c.json(
|
||||
{
|
||||
error: "topic_unencrypted",
|
||||
topic: name,
|
||||
hint: "legacy v0.2.0 topic — messages are base64 plaintext",
|
||||
},
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const [sealed] = await db
|
||||
.select({
|
||||
encryptedKey: meshTopicMemberKey.encryptedKey,
|
||||
nonce: meshTopicMemberKey.nonce,
|
||||
createdAt: meshTopicMemberKey.createdAt,
|
||||
})
|
||||
.from(meshTopicMemberKey)
|
||||
.where(
|
||||
and(
|
||||
eq(meshTopicMemberKey.topicId, topic.id),
|
||||
eq(meshTopicMemberKey.memberId, key.issuedByMemberId),
|
||||
),
|
||||
);
|
||||
if (!sealed) {
|
||||
return c.json(
|
||||
{
|
||||
error: "key_not_sealed_for_member",
|
||||
topic: name,
|
||||
hint: "join the topic, then ask an existing member to re-seal",
|
||||
},
|
||||
404,
|
||||
);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
topic: name,
|
||||
topicId: topic.id,
|
||||
encryptedKey: sealed.encryptedKey,
|
||||
nonce: sealed.nonce,
|
||||
senderPubkey: topic.encryptedKeyPubkey,
|
||||
createdAt: sealed.createdAt.toISOString(),
|
||||
});
|
||||
})
|
||||
|
||||
// GET /v1/notifications — recent @-mentions of the viewer across
|
||||
// all topics in the key's mesh. Reads from mesh.notification, which
|
||||
// is populated at write time by POST /v1/messages and the broker's
|
||||
|
||||
44
packages/db/migrations/0026_topic_keys.sql
Normal file
44
packages/db/migrations/0026_topic_keys.sql
Normal file
@@ -0,0 +1,44 @@
|
||||
-- Per-topic symmetric encryption keys (v0.3.0 phase 2 — schema layer).
|
||||
--
|
||||
-- Each topic gets a freshly-generated 32-byte XSalsa20-Poly1305 symmetric
|
||||
-- key. That key is encrypted once per topic member with libsodium
|
||||
-- crypto_box (recipient pubkey + sender ephemeral keypair) so only the
|
||||
-- intended member can decrypt their copy. Server stores ciphertext only;
|
||||
-- it can no longer read message bodies.
|
||||
--
|
||||
-- Writes are versioned via topic_message.body_version:
|
||||
-- 1 = legacy v0.2.0 base64-of-plaintext (still readable)
|
||||
-- 2 = real ciphertext (sealed to the topic key, server-blind)
|
||||
--
|
||||
-- Old messages stay v1; new clients send v2. Mention fan-out is already
|
||||
-- decoupled from ciphertext via the notification table (migration 0025),
|
||||
-- so /v1/notifications keeps working through the cutover.
|
||||
|
||||
ALTER TABLE "mesh"."topic"
|
||||
ADD COLUMN IF NOT EXISTS "encrypted_key_pubkey" text;
|
||||
COMMENT ON COLUMN "mesh"."topic"."encrypted_key_pubkey" IS
|
||||
'Ephemeral x25519 sender pubkey used to seal per-member copies of the topic symmetric key. Null = legacy v0.2.0 topic with no encryption.';
|
||||
|
||||
ALTER TABLE "mesh"."topic_message"
|
||||
ADD COLUMN IF NOT EXISTS "body_version" integer NOT NULL DEFAULT 1;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "topic_message_by_version"
|
||||
ON "mesh"."topic_message" ("body_version");
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "mesh"."topic_member_key" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"topic_id" text NOT NULL REFERENCES "mesh"."topic"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
"member_id" text NOT NULL REFERENCES "mesh"."member"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
/** crypto_box ciphertext of the 32-byte topic key, sealed for this member. */
|
||||
"encrypted_key" text NOT NULL,
|
||||
/** 24-byte nonce used to seal `encrypted_key`. */
|
||||
"nonce" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"rotated_at" timestamp
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "topic_member_key_unique"
|
||||
ON "mesh"."topic_member_key" ("topic_id", "member_id");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "topic_member_key_by_member"
|
||||
ON "mesh"."topic_member_key" ("member_id");
|
||||
@@ -1360,6 +1360,11 @@ export const meshTopic = meshSchema.table(
|
||||
onDelete: "set null",
|
||||
onUpdate: "cascade",
|
||||
}),
|
||||
/**
|
||||
* Ephemeral x25519 sender pubkey used to seal per-member topic-key
|
||||
* copies via crypto_box. Null on legacy v0.2.0 topics (no encryption).
|
||||
*/
|
||||
encryptedKeyPubkey: text(),
|
||||
createdAt: timestamp().defaultNow().notNull(),
|
||||
archivedAt: timestamp(),
|
||||
},
|
||||
@@ -1395,6 +1400,61 @@ export const meshTopicMember = meshSchema.table(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Per-(topic, member) sealed copy of the topic's symmetric key. v0.3.0
|
||||
* phase 2 — each topic_member gets a crypto_box ciphertext of the 32-byte
|
||||
* topic key, sealed to their peer pubkey using an ephemeral sender
|
||||
* keypair stored on `topic.encryptedKeyPubkey`. The server holds only
|
||||
* ciphertext; it can't read message bodies.
|
||||
*/
|
||||
export const meshTopicMemberKey = meshSchema.table(
|
||||
"topic_member_key",
|
||||
{
|
||||
id: text().primaryKey().notNull().$defaultFn(generateId),
|
||||
topicId: text()
|
||||
.references(() => meshTopic.id, {
|
||||
onDelete: "cascade",
|
||||
onUpdate: "cascade",
|
||||
})
|
||||
.notNull(),
|
||||
memberId: text()
|
||||
.references(() => meshMember.id, {
|
||||
onDelete: "cascade",
|
||||
onUpdate: "cascade",
|
||||
})
|
||||
.notNull(),
|
||||
encryptedKey: text().notNull(),
|
||||
nonce: text().notNull(),
|
||||
createdAt: timestamp().defaultNow().notNull(),
|
||||
rotatedAt: timestamp(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex("topic_member_key_unique").on(t.topicId, t.memberId),
|
||||
index("topic_member_key_by_member").on(t.memberId),
|
||||
],
|
||||
);
|
||||
|
||||
export const meshTopicMemberKeyRelations = relations(
|
||||
meshTopicMemberKey,
|
||||
({ one }) => ({
|
||||
topic: one(meshTopic, {
|
||||
fields: [meshTopicMemberKey.topicId],
|
||||
references: [meshTopic.id],
|
||||
}),
|
||||
member: one(meshMember, {
|
||||
fields: [meshTopicMemberKey.memberId],
|
||||
references: [meshMember.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
export const selectMeshTopicMemberKeySchema =
|
||||
createSelectSchema(meshTopicMemberKey);
|
||||
export const insertMeshTopicMemberKeySchema =
|
||||
createInsertSchema(meshTopicMemberKey);
|
||||
export type SelectMeshTopicMemberKey = typeof meshTopicMemberKey.$inferSelect;
|
||||
export type InsertMeshTopicMemberKey = typeof meshTopicMemberKey.$inferInsert;
|
||||
|
||||
/**
|
||||
* Topic-scoped persistent message history. Direct messages (DMs) stay
|
||||
* ephemeral via message_queue by design — this table only persists
|
||||
@@ -1424,9 +1484,19 @@ export const meshTopicMessage = meshSchema.table(
|
||||
senderSessionPubkey: text(),
|
||||
nonce: text().notNull(),
|
||||
ciphertext: text().notNull(),
|
||||
/**
|
||||
* Body-format version. 1 = legacy base64-of-plaintext (v0.2.0). 2 =
|
||||
* crypto_secretbox under the topic key (v0.3.0). Readers branch on
|
||||
* this; mention fan-out is decoupled via the notification table so
|
||||
* a v2 message still resolves @-mentions correctly.
|
||||
*/
|
||||
bodyVersion: integer().notNull().default(1),
|
||||
createdAt: timestamp().defaultNow().notNull(),
|
||||
},
|
||||
(t) => [index("topic_message_by_topic_time").on(t.topicId, t.createdAt)],
|
||||
(t) => [
|
||||
index("topic_message_by_topic_time").on(t.topicId, t.createdAt),
|
||||
index("topic_message_by_version").on(t.bodyVersion),
|
||||
],
|
||||
);
|
||||
|
||||
export const meshTopicRelations = relations(meshTopic, ({ one, many }) => ({
|
||||
|
||||
Reference in New Issue
Block a user