feat(api+broker+web): write-time mention fan-out via notification table
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

Phase 1 of v0.3.0 — replaces the regex-on-decoded-ciphertext scan
in /v1/notifications and the dashboard MentionsSection with reads
from a new mesh.notification table populated at write time.

Schema 0025: mesh.notification (id, mesh_id, topic_id, message_id,
recipient_member_id, sender_member_id, kind, created_at, read_at)
with a unique (message_id, recipient) so a re-fanned message yields
one row per recipient. Backfills existing v0.2.0 messages by
regex-matching the (still-base64-plaintext) bodies — guarded with
a base64 + length check so binary ciphertext doesn't crash the
migration.

Writers (POST /v1/messages + broker appendTopicMessage) now
extract @-mentions from either an explicit `mentions: string[]`
on the request OR a regex over the base64 plaintext (transitional
fallback). Targets are intersected with the mesh roster + capped
at 32 per message. Web chat panel sends the explicit array now so
it keeps working after phase 2 lands.

Readers switch to JOIN-on-notification:
  /v1/notifications      — table-backed, supports ?unread=1
  POST /v1/notifications/read  — new, mark by ids or all-up-to
  MentionsSection (RSC) — same JOIN, returns readAt for each row

GET /v1/notifications also gains a read_at field per row so a
future bell UI can show unread vs read.

Once per-topic encryption (phase 2) lands, the regex fallback
becomes a no-op for v2 messages — clients MUST send `mentions`,
which they already do.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-02 20:23:50 +01:00
parent 81f8066f99
commit 1a238d4178
6 changed files with 474 additions and 48 deletions

View File

@@ -38,7 +38,9 @@ import {
meshFileKey,
meshContext,
meshMember as memberTable,
meshMember,
meshMemory,
meshNotification,
meshState,
meshService,
meshSkill,
@@ -694,6 +696,13 @@ export async function appendTopicMessage(args: {
senderSessionPubkey?: string;
nonce: string;
ciphertext: string;
/**
* Optional client-extracted mention list (lowercased display names
* without the leading @). Required once per-topic encryption lands —
* the server can't read v0.3.0 ciphertext. Falls back to a regex on
* the v0.2.0 base64 plaintext when omitted.
*/
mentions?: string[];
}): Promise<string> {
const [row] = await db
.insert(meshTopicMessage)
@@ -706,9 +715,94 @@ export async function appendTopicMessage(args: {
})
.returning({ id: meshTopicMessage.id });
if (!row) throw new Error("failed to append topic message");
void fanOutMentions({
messageId: row.id,
topicId: args.topicId,
senderMemberId: args.senderMemberId,
ciphertext: args.ciphertext,
explicit: args.mentions,
}).catch(() => {
// Notifications are advisory; don't fail the message write.
});
return row.id;
}
/**
* Extract `@<displayName>` tokens from a base64-of-UTF8 plaintext body.
* Capped at 16 tokens. Returns lowercased names without the @ prefix.
*/
function extractMentionTokens(b64: string): string[] {
let text: string;
try {
text = Buffer.from(b64, "base64").toString("utf-8");
} catch {
return [];
}
const found = new Set<string>();
const re = /(^|[^A-Za-z0-9_-])@([A-Za-z0-9_-]{1,64})(?=$|[^A-Za-z0-9_-])/g;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
found.add(m[2]!.toLowerCase());
if (found.size >= 16) break;
}
return [...found];
}
async function fanOutMentions(args: {
messageId: string;
topicId: string;
senderMemberId: string;
ciphertext: string;
explicit?: string[];
}): Promise<void> {
let tokens = args.explicit?.map((s) => s.toLowerCase().replace(/^@/, ""));
if (!tokens || tokens.length === 0) {
tokens = extractMentionTokens(args.ciphertext);
}
if (tokens.length === 0) return;
const [topic] = await db
.select({ meshId: meshTopic.meshId })
.from(meshTopic)
.where(eq(meshTopic.id, args.topicId));
if (!topic) return;
const recipients = await db
.select({
id: meshMember.id,
displayName: meshMember.displayName,
})
.from(meshMember)
.where(
and(eq(meshMember.meshId, topic.meshId), isNull(meshMember.revokedAt)),
);
const tokenSet = new Set(tokens);
const targets = recipients
.filter(
(r) =>
tokenSet.has(r.displayName.toLowerCase()) &&
r.id !== args.senderMemberId,
)
.slice(0, 32);
if (targets.length === 0) return;
await db
.insert(meshNotification)
.values(
targets.map((t) => ({
meshId: topic.meshId,
topicId: args.topicId,
messageId: args.messageId,
recipientMemberId: t.id,
senderMemberId: args.senderMemberId,
kind: "mention",
})),
)
.onConflictDoNothing();
}
/**
* Fetch topic history for a member. Pagination via `before` cursor (id of
* an earlier message); pass null for the latest page.

View File

@@ -9,11 +9,12 @@ import { db } from "@turbostarter/db/server";
import {
mesh,
meshMember,
meshNotification,
meshTopic,
meshTopicMember,
meshTopicMessage,
} from "@turbostarter/db/schema/mesh";
import { and, count, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
import { aliasedTable, and, count, desc, eq, gt, inArray, isNull, or, sql } from "drizzle-orm";
import { appConfig } from "~/config/app";
import { pathsConfig } from "~/config/paths";
@@ -127,45 +128,42 @@ export default async function UniversePage() {
}));
// Recent @-mentions of the viewer across every mesh they belong to.
// Build a (memberId, regex) pair per mesh and OR them together so we
// catch users with different display names in different meshes. The
// ciphertext is base64 plaintext in v0.2.0; per-topic encryption in
// v0.3.0 will move this scan to a notification table populated at
// write time. 7-day window keeps the query bounded.
// Reads from mesh.notification, populated at write time by the
// POST /v1/messages handler + broker topic-send. Survives v0.3.0
// per-topic encryption (the previous regex-on-decoded-ciphertext
// approach would not). 7-day window keeps the result bounded.
const mentionWindow = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const mentionConditions = myMembers.map((m) => {
const escaped = m.displayName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = `(^|\\s|[^A-Za-z0-9_-])@${escaped}($|[^A-Za-z0-9_-])`;
return and(
eq(meshTopic.meshId, m.meshId),
sql`${meshTopicMessage.senderMemberId} <> ${m.id}`,
sql`convert_from(decode(${meshTopicMessage.ciphertext}, 'base64'), 'UTF8') ~* ${pattern}`,
);
});
const mentionRows = mentionConditions.length
const senderMember = aliasedTable(meshMember, "sender_member");
const mentionRows = myMemberIds.length
? await db
.select({
id: meshTopicMessage.id,
notificationId: meshNotification.id,
topicId: meshTopicMessage.topicId,
topicName: meshTopic.name,
meshId: meshTopic.meshId,
meshName: mesh.name,
senderName: meshMember.displayName,
senderName: senderMember.displayName,
ciphertext: meshTopicMessage.ciphertext,
readAt: meshNotification.readAt,
createdAt: meshTopicMessage.createdAt,
})
.from(meshTopicMessage)
.innerJoin(meshTopic, eq(meshTopic.id, meshTopicMessage.topicId))
.from(meshNotification)
.innerJoin(
meshTopicMessage,
eq(meshTopicMessage.id, meshNotification.messageId),
)
.innerJoin(meshTopic, eq(meshTopic.id, meshNotification.topicId))
.innerJoin(mesh, eq(mesh.id, meshTopic.meshId))
.innerJoin(
meshMember,
eq(meshMember.id, meshTopicMessage.senderMemberId),
senderMember,
eq(senderMember.id, meshNotification.senderMemberId),
)
.where(
and(
inArray(meshNotification.recipientMemberId, myMemberIds),
isNull(meshTopic.archivedAt),
gt(meshTopicMessage.createdAt, mentionWindow),
or(...mentionConditions),
),
)
.orderBy(desc(meshTopicMessage.createdAt))

View File

@@ -414,6 +414,21 @@ export function TopicChatPanel({
[draft, mentionState],
);
// Extract @-mention tokens from the draft body so the server can
// populate mesh.notification rows without having to read the
// ciphertext (forward-compat with v0.3.0 per-topic encryption).
// Capped at 16 to bound notification fan-out.
const extractMentions = (text: string): string[] => {
const found = new Set<string>();
const re = /(^|[^A-Za-z0-9_-])@([A-Za-z0-9_-]{1,64})(?=$|[^A-Za-z0-9_-])/g;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
found.add(m[2]!.toLowerCase());
if (found.size >= 16) break;
}
return [...found];
};
const send = async () => {
const text = draft.trim();
if (!text) return;
@@ -421,10 +436,16 @@ export function TopicChatPanel({
setError(null);
try {
const { ciphertext, nonce } = encodeOutgoing(text);
const mentions = extractMentions(text);
const res = await fetch("/api/v1/messages", {
method: "POST",
headers,
body: JSON.stringify({ topic: topicName, ciphertext, nonce }),
body: JSON.stringify({
topic: topicName,
ciphertext,
nonce,
...(mentions.length > 0 ? { mentions } : {}),
}),
});
if (!res.ok) {
const body = await res.text().catch(() => "");