Skip to content

Commit 779da9d

Browse files
authored
Merge pull request #316 from Johnalex-hub/feat/183-fanout-service
wip(183): scaffold fanout service
2 parents feb52ad + 28a46c1 commit 779da9d

1 file changed

Lines changed: 92 additions & 0 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// #183 — fan-out service: resolve members -> devices -> envelopes
2+
//
3+
// Given an unpersisted message and a sender-provided map of
4+
// { recipientDeviceId -> ciphertext }, validates that the client encrypted
5+
// to exactly the conversation's current active recipient devices (including
6+
// the sender's *other* devices, for multi-device self-sync — but excluding
7+
// the device that is doing the sending). If the client's device set is
8+
// stale, returns device_set_mismatch with the authoritative device list
9+
// instead of guessing or dropping ciphertext. On success, the message and
10+
// its envelopes are persisted atomically.
11+
12+
import { and, eq, inArray, isNull } from 'drizzle-orm';
13+
import { db } from '../db/index.js';
14+
import { conversationMembers, messages, messageEnvelopes, userDevices } from '../db/schema.js';
15+
import type { Message, NewMessage } from '../db/schema.js';
16+
17+
export interface FanoutSuccess {
18+
ok: true;
19+
message: Message;
20+
}
21+
22+
export interface FanoutDeviceSetMismatch {
23+
ok: false;
24+
error: 'device_set_mismatch';
25+
expectedDeviceIds: string[];
26+
}
27+
28+
export type FanoutResult = FanoutSuccess | FanoutDeviceSetMismatch;
29+
30+
/**
31+
* Persists `newMessage` and its per-device envelopes in a single transaction,
32+
* after verifying `envelopeCiphertexts` covers exactly the conversation's
33+
* current active recipient devices.
34+
*
35+
* @param newMessage - Message row to insert (id may be omitted; defaultRandom).
36+
* @param senderDeviceId - The device sending this message; excluded from the
37+
* authoritative recipient set (it doesn't need its own envelope).
38+
* @param envelopeCiphertexts - Sender-provided map of recipientDeviceId -> ciphertext.
39+
*/
40+
export async function fanoutMessage(
41+
newMessage: NewMessage,
42+
senderDeviceId: string | null,
43+
envelopeCiphertexts: Record<string, string>,
44+
): Promise<FanoutResult> {
45+
const members = await db.query.conversationMembers.findMany({
46+
where: eq(conversationMembers.conversationId, newMessage.conversationId),
47+
columns: { userId: true },
48+
});
49+
const memberIds = members.map((m) => m.userId);
50+
51+
const activeDevices = await db.query.userDevices.findMany({
52+
where: and(inArray(userDevices.userId, memberIds), isNull(userDevices.revokedAt)),
53+
columns: { id: true, userId: true },
54+
});
55+
56+
const expectedDevices = activeDevices.filter((d) => d.id !== senderDeviceId);
57+
const deviceToUser = new Map(expectedDevices.map((d) => [d.id, d.userId]));
58+
const expectedDeviceIds = new Set(deviceToUser.keys());
59+
60+
const providedDeviceIds = Object.keys(envelopeCiphertexts);
61+
const setsMatch =
62+
providedDeviceIds.length === expectedDeviceIds.size &&
63+
providedDeviceIds.every((id) => expectedDeviceIds.has(id));
64+
65+
if (!setsMatch) {
66+
return {
67+
ok: false,
68+
error: 'device_set_mismatch',
69+
expectedDeviceIds: [...expectedDeviceIds],
70+
};
71+
}
72+
73+
const message = await db.transaction(async (tx) => {
74+
const [inserted] = await tx.insert(messages).values(newMessage).returning();
75+
const persisted = inserted!;
76+
77+
const envelopeRows = providedDeviceIds.map((deviceId) => ({
78+
messageId: persisted.id,
79+
recipientDeviceId: deviceId,
80+
recipientUserId: deviceToUser.get(deviceId)!,
81+
ciphertext: envelopeCiphertexts[deviceId]!,
82+
}));
83+
84+
if (envelopeRows.length > 0) {
85+
await tx.insert(messageEnvelopes).values(envelopeRows);
86+
}
87+
88+
return persisted;
89+
});
90+
91+
return { ok: true, message };
92+
}

0 commit comments

Comments
 (0)