fix: 完成消息 Outbox 投递与会话状态保护
This commit is contained in:
@@ -23,6 +23,7 @@ import {
|
|||||||
type MessageResult,
|
type MessageResult,
|
||||||
} from "./conversation.service.js";
|
} from "./conversation.service.js";
|
||||||
import { SendMessageDto, SocketReadDto } from "./dto.js";
|
import { SendMessageDto, SocketReadDto } from "./dto.js";
|
||||||
|
import type { ChatPublisher } from "./outbox-message-relay.js";
|
||||||
|
|
||||||
interface SocketData {
|
interface SocketData {
|
||||||
token?: string;
|
token?: string;
|
||||||
@@ -72,7 +73,9 @@ function allowOrigin(
|
|||||||
credentials: true,
|
credentials: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
export class ChatGateway implements OnGatewayInit, OnGatewayConnection {
|
export class ChatGateway
|
||||||
|
implements OnGatewayInit, OnGatewayConnection, ChatPublisher
|
||||||
|
{
|
||||||
@WebSocketServer() server!: ChatServer;
|
@WebSocketServer() server!: ChatServer;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -153,13 +156,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection {
|
|||||||
};
|
};
|
||||||
ack?.(response);
|
ack?.(response);
|
||||||
client.emit("message:ack", response);
|
client.emit("message:ack", response);
|
||||||
if (!result.deduplicated) {
|
|
||||||
for (const member of result.memberIds) {
|
|
||||||
this.server
|
|
||||||
.to(`account:${member}`)
|
|
||||||
.emit("message:new", result.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const response = this.error(error);
|
const response = this.error(error);
|
||||||
@@ -193,6 +189,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
publishMessage(memberIds: string[], message: MessageResult["message"]): void {
|
||||||
|
if (!this.server) throw new Error("chat gateway is not ready");
|
||||||
|
for (const member of memberIds) {
|
||||||
|
this.server.to(`account:${member}`).emit("message:new", message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private error(error: unknown): SocketResponse {
|
private error(error: unknown): SocketResponse {
|
||||||
if (error instanceof DomainException) {
|
if (error instanceof DomainException) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
HttpStatus,
|
||||||
Inject,
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
@@ -9,10 +10,14 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
import { ErrorCode } from "@drift/contracts";
|
||||||
import { AuthGuard } from "../auth/auth.guard.js";
|
import { AuthGuard } from "../auth/auth.guard.js";
|
||||||
import { CurrentUser } from "../auth/current-user.decorator.js";
|
import { CurrentUser } from "../auth/current-user.decorator.js";
|
||||||
import type { AccessClaims } from "../auth/token.service.js";
|
import type { AccessClaims } from "../auth/token.service.js";
|
||||||
|
import { DomainException } from "../common/domain.exception.js";
|
||||||
|
import { ChatRateLimiter } from "./chat-rate-limiter.js";
|
||||||
import { ConversationService } from "./conversation.service.js";
|
import { ConversationService } from "./conversation.service.js";
|
||||||
|
import { StateChangingOriginGuard } from "./state-changing-origin.guard.js";
|
||||||
import {
|
import {
|
||||||
ConversationsQueryDto,
|
ConversationsQueryDto,
|
||||||
MessagesQueryDto,
|
MessagesQueryDto,
|
||||||
@@ -27,8 +32,11 @@ export class ConversationController {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(ConversationService)
|
@Inject(ConversationService)
|
||||||
private readonly conversations: ConversationService,
|
private readonly conversations: ConversationService,
|
||||||
|
@Inject(ChatRateLimiter) private readonly limiter: ChatRateLimiter,
|
||||||
) {}
|
) {}
|
||||||
@Post("bottles/:id/reply") reply(
|
@UseGuards(StateChangingOriginGuard)
|
||||||
|
@Post("bottles/:id/reply")
|
||||||
|
reply(
|
||||||
@CurrentUser() user: AccessClaims,
|
@CurrentUser() user: AccessClaims,
|
||||||
@Param("id", new ParseUUIDPipe()) id: string,
|
@Param("id", new ParseUUIDPipe()) id: string,
|
||||||
@Body() dto: ReplyDto,
|
@Body() dto: ReplyDto,
|
||||||
@@ -66,20 +74,50 @@ export class ConversationController {
|
|||||||
query.limit,
|
query.limit,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@Post("conversations/:id/messages/prepare") prepare(
|
@UseGuards(StateChangingOriginGuard)
|
||||||
|
@Post("conversations/:id/messages/prepare")
|
||||||
|
prepare(
|
||||||
@CurrentUser() user: AccessClaims,
|
@CurrentUser() user: AccessClaims,
|
||||||
@Param("id", new ParseUUIDPipe()) id: string,
|
@Param("id", new ParseUUIDPipe()) id: string,
|
||||||
@Body() dto: SendMessageDto,
|
@Body() dto: SendMessageDto,
|
||||||
) {
|
) {
|
||||||
return this.conversations.send(user.sub, id, dto.clientMsgId, dto.text);
|
return this.consumeAndRun(user, () =>
|
||||||
|
this.conversations.send(user.sub, id, dto.clientMsgId, dto.text),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@Post("conversations/:id/read") read(
|
@UseGuards(StateChangingOriginGuard)
|
||||||
|
@Post("conversations/:id/read")
|
||||||
|
read(
|
||||||
@CurrentUser() user: AccessClaims,
|
@CurrentUser() user: AccessClaims,
|
||||||
@Param("id", new ParseUUIDPipe()) id: string,
|
@Param("id", new ParseUUIDPipe()) id: string,
|
||||||
@Body() dto: ReadConversationDto,
|
@Body() dto: ReadConversationDto,
|
||||||
) {
|
) {
|
||||||
return this.conversations.read(user.sub, id, dto.seq);
|
return this.conversations.read(user.sub, id, dto.seq);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async consumeAndRun<T>(
|
||||||
|
user: AccessClaims,
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
let allowed: boolean;
|
||||||
|
try {
|
||||||
|
allowed = await this.limiter.consume(user.sub, user.session_id);
|
||||||
|
} catch {
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.SERVICE_UNAVAILABLE,
|
||||||
|
"Service unavailable",
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!allowed) {
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.RATE_LIMITED,
|
||||||
|
"Too many requests",
|
||||||
|
HttpStatus.TOO_MANY_REQUESTS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return operation();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reflect.defineMetadata(
|
Reflect.defineMetadata(
|
||||||
"design:paramtypes",
|
"design:paramtypes",
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { AppModule } from "../app.module.js";
|
|||||||
import { TokenService } from "../auth/token.service.js";
|
import { TokenService } from "../auth/token.service.js";
|
||||||
import { leaseHmac, encryptLeaseToken } from "../auth/auth.config.js";
|
import { leaseHmac, encryptLeaseToken } from "../auth/auth.config.js";
|
||||||
import { configureApp } from "../main.js";
|
import { configureApp } from "../main.js";
|
||||||
|
import { OutboxMessageRelay } from "./outbox-message-relay.js";
|
||||||
|
import { ChatRateLimiter } from "./chat-rate-limiter.js";
|
||||||
|
|
||||||
process.env.NODE_ENV = "test";
|
process.env.NODE_ENV = "test";
|
||||||
process.env.WEB_ORIGIN = "http://localhost:3000";
|
process.env.WEB_ORIGIN = "http://localhost:3000";
|
||||||
@@ -44,6 +46,7 @@ describe("conversation API with real PostgreSQL", () => {
|
|||||||
await app.listen(0, "127.0.0.1");
|
await app.listen(0, "127.0.0.1");
|
||||||
});
|
});
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
await prisma.outboxEvent.deleteMany();
|
||||||
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "accounts" CASCADE`);
|
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "accounts" CASCADE`);
|
||||||
});
|
});
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -127,6 +130,7 @@ describe("conversation API with real PostgreSQL", () => {
|
|||||||
request(app.getHttpServer())
|
request(app.getHttpServer())
|
||||||
.post(`/api/v1/bottles/${bottleId}/reply`)
|
.post(`/api/v1/bottles/${bottleId}/reply`)
|
||||||
.set("Authorization", authorization)
|
.set("Authorization", authorization)
|
||||||
|
.set("Origin", process.env.WEB_ORIGIN!)
|
||||||
.send({ leaseId: lease.id, leaseToken: lease.token, clientMsgId, text });
|
.send({ leaseId: lease.id, leaseToken: lease.token, clientMsgId, text });
|
||||||
|
|
||||||
function socket(token: string, origin = process.env.WEB_ORIGIN): Socket {
|
function socket(token: string, origin = process.env.WEB_ORIGIN): Socket {
|
||||||
@@ -294,6 +298,7 @@ describe("conversation API with real PostgreSQL", () => {
|
|||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.post(`/api/v1/conversations/${conversationId}/messages/prepare`)
|
.post(`/api/v1/conversations/${conversationId}/messages/prepare`)
|
||||||
.set("Authorization", stranger.authorization)
|
.set("Authorization", stranger.authorization)
|
||||||
|
.set("Origin", process.env.WEB_ORIGIN!)
|
||||||
.send({ conversationId, clientMsgId, text: "steal" })
|
.send({ conversationId, clientMsgId, text: "steal" })
|
||||||
.expect(403);
|
.expect(403);
|
||||||
|
|
||||||
@@ -328,6 +333,188 @@ describe("conversation API with real PostgreSQL", () => {
|
|||||||
expect(JSON.stringify(event.payload)).not.toContain("private message text");
|
expect(JSON.stringify(event.payload)).not.toContain("private message text");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("enforces Origin before REST mutation rate limiting and maps limiter outcomes", async () => {
|
||||||
|
const author = await actor("rest-origin-author");
|
||||||
|
const picker = await actor("rest-origin-picker");
|
||||||
|
const { bottle, lease } = await leased(author.id, picker);
|
||||||
|
const created = await reply(
|
||||||
|
picker.authorization,
|
||||||
|
bottle.id,
|
||||||
|
lease,
|
||||||
|
randomUUID(),
|
||||||
|
).expect(201);
|
||||||
|
const id = created.body.data.conversationId as string;
|
||||||
|
const limiter = app.get(ChatRateLimiter);
|
||||||
|
const originalConsume = limiter.consume.bind(limiter);
|
||||||
|
let calls = 0;
|
||||||
|
limiter.consume = () => {
|
||||||
|
calls += 1;
|
||||||
|
return Promise.resolve(true);
|
||||||
|
};
|
||||||
|
const body = {
|
||||||
|
conversationId: id,
|
||||||
|
clientMsgId: randomUUID(),
|
||||||
|
text: "rest",
|
||||||
|
};
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/api/v1/conversations/${id}/messages/prepare`)
|
||||||
|
.set("Authorization", picker.authorization)
|
||||||
|
.send(body)
|
||||||
|
.expect(403);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/api/v1/conversations/${id}/messages/prepare`)
|
||||||
|
.set("Authorization", picker.authorization)
|
||||||
|
.set("Origin", "https://evil.example")
|
||||||
|
.send(body)
|
||||||
|
.expect(403);
|
||||||
|
expect(calls).toBe(0);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/api/v1/conversations/${id}/messages/prepare`)
|
||||||
|
.set("Authorization", picker.authorization)
|
||||||
|
.set("Origin", process.env.WEB_ORIGIN!)
|
||||||
|
.send(body)
|
||||||
|
.expect(201);
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
|
||||||
|
limiter.consume = () => Promise.resolve(false);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/api/v1/conversations/${id}/messages/prepare`)
|
||||||
|
.set("Authorization", picker.authorization)
|
||||||
|
.set("Origin", process.env.WEB_ORIGIN!)
|
||||||
|
.send({ ...body, clientMsgId: randomUUID() })
|
||||||
|
.expect(429);
|
||||||
|
limiter.consume = () => Promise.reject(new Error("redis secret"));
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/api/v1/conversations/${id}/messages/prepare`)
|
||||||
|
.set("Authorization", picker.authorization)
|
||||||
|
.set("Origin", process.env.WEB_ORIGIN!)
|
||||||
|
.send({ ...body, clientMsgId: randomUUID() })
|
||||||
|
.expect(503);
|
||||||
|
limiter.consume = originalConsume;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("relays first replies and bidirectional live messages through the durable outbox", async () => {
|
||||||
|
const author = await actor("relay-author");
|
||||||
|
const picker = await actor("relay-picker");
|
||||||
|
const { bottle, lease } = await leased(author.id, picker);
|
||||||
|
const authorSocket = socket(author.token);
|
||||||
|
const pickerSocket = socket(picker.token);
|
||||||
|
await Promise.all([connected(authorSocket), connected(pickerSocket)]);
|
||||||
|
const authorMessages: Array<{ clientMsgId: string }> = [];
|
||||||
|
const pickerMessages: Array<{ clientMsgId: string }> = [];
|
||||||
|
authorSocket.on("message:new", (message) => authorMessages.push(message));
|
||||||
|
pickerSocket.on("message:new", (message) => pickerMessages.push(message));
|
||||||
|
|
||||||
|
const firstId = randomUUID();
|
||||||
|
const created = await reply(
|
||||||
|
picker.authorization,
|
||||||
|
bottle.id,
|
||||||
|
lease,
|
||||||
|
firstId,
|
||||||
|
).expect(201);
|
||||||
|
const conversationId = created.body.data.conversationId as string;
|
||||||
|
expect(await app.get(OutboxMessageRelay).runOnce()).toBe(true);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||||
|
expect(authorMessages.map((message) => message.clientMsgId)).toEqual([
|
||||||
|
firstId,
|
||||||
|
]);
|
||||||
|
expect(pickerMessages.map((message) => message.clientMsgId)).toEqual([
|
||||||
|
firstId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pickerToAuthor = randomUUID();
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await ack<{ ok: boolean }>(pickerSocket, "message:send", {
|
||||||
|
conversationId,
|
||||||
|
clientMsgId: pickerToAuthor,
|
||||||
|
text: "picker to author",
|
||||||
|
})
|
||||||
|
).ok,
|
||||||
|
).toBe(true);
|
||||||
|
expect(await app.get(OutboxMessageRelay).runOnce()).toBe(true);
|
||||||
|
const authorToPicker = randomUUID();
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await ack<{ ok: boolean }>(authorSocket, "message:send", {
|
||||||
|
conversationId,
|
||||||
|
clientMsgId: authorToPicker,
|
||||||
|
text: "author to picker",
|
||||||
|
})
|
||||||
|
).ok,
|
||||||
|
).toBe(true);
|
||||||
|
expect(await app.get(OutboxMessageRelay).runOnce()).toBe(true);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||||
|
const expected = [firstId, pickerToAuthor, authorToPicker];
|
||||||
|
expect(authorMessages.map((message) => message.clientMsgId)).toEqual(
|
||||||
|
expected,
|
||||||
|
);
|
||||||
|
expect(pickerMessages.map((message) => message.clientMsgId)).toEqual(
|
||||||
|
expected,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await prisma.outboxEvent.count({
|
||||||
|
where: { eventType: "MESSAGE_CREATED", status: "PUBLISHED" },
|
||||||
|
}),
|
||||||
|
).toBe(3);
|
||||||
|
authorSocket.close();
|
||||||
|
pickerSocket.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes relayed messages from canonical membership instead of outbox payload", async () => {
|
||||||
|
const author = await actor("relay-route-author");
|
||||||
|
const picker = await actor("relay-route-picker");
|
||||||
|
const stranger = await actor("relay-route-stranger");
|
||||||
|
const { bottle, lease } = await leased(author.id, picker);
|
||||||
|
const authorSocket = socket(author.token);
|
||||||
|
const pickerSocket = socket(picker.token);
|
||||||
|
const strangerSocket = socket(stranger.token);
|
||||||
|
await Promise.all([
|
||||||
|
connected(authorSocket),
|
||||||
|
connected(pickerSocket),
|
||||||
|
connected(strangerSocket),
|
||||||
|
]);
|
||||||
|
const authorMessages: string[] = [];
|
||||||
|
const pickerMessages: string[] = [];
|
||||||
|
const strangerMessages: string[] = [];
|
||||||
|
authorSocket.on("message:new", (message) =>
|
||||||
|
authorMessages.push(message.id),
|
||||||
|
);
|
||||||
|
pickerSocket.on("message:new", (message) =>
|
||||||
|
pickerMessages.push(message.id),
|
||||||
|
);
|
||||||
|
strangerSocket.on("message:new", (message) =>
|
||||||
|
strangerMessages.push(message.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const created = await reply(
|
||||||
|
picker.authorization,
|
||||||
|
bottle.id,
|
||||||
|
lease,
|
||||||
|
randomUUID(),
|
||||||
|
).expect(201);
|
||||||
|
const messageId = created.body.data.message.id as string;
|
||||||
|
await prisma.outboxEvent.update({
|
||||||
|
where: { dedupeKey: `message-created:${messageId}` },
|
||||||
|
data: {
|
||||||
|
payload: {
|
||||||
|
messageId,
|
||||||
|
conversationId: created.body.data.conversationId,
|
||||||
|
memberIds: [stranger.id],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await app.get(OutboxMessageRelay).runOnce()).toBe(true);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||||
|
expect(authorMessages).toEqual([messageId]);
|
||||||
|
expect(pickerMessages).toEqual([messageId]);
|
||||||
|
expect(strangerMessages).toEqual([]);
|
||||||
|
authorSocket.close();
|
||||||
|
pickerSocket.close();
|
||||||
|
strangerSocket.close();
|
||||||
|
});
|
||||||
|
|
||||||
it("uses an exact timestamp/id cursor without skipping tied rows", async () => {
|
it("uses an exact timestamp/id cursor without skipping tied rows", async () => {
|
||||||
const owner = await actor("owner");
|
const owner = await actor("owner");
|
||||||
const peer = await actor("peer");
|
const peer = await actor("peer");
|
||||||
@@ -395,14 +582,82 @@ describe("conversation API with real PostgreSQL", () => {
|
|||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.post(`/api/v1/conversations/${id}/read`)
|
.post(`/api/v1/conversations/${id}/read`)
|
||||||
.set("Authorization", picker.authorization)
|
.set("Authorization", picker.authorization)
|
||||||
|
.set("Origin", process.env.WEB_ORIGIN!)
|
||||||
.send({ seq: "1" })
|
.send({ seq: "1" })
|
||||||
.expect(201);
|
.expect(201);
|
||||||
const monotonic = await request(app.getHttpServer())
|
const monotonic = await request(app.getHttpServer())
|
||||||
.post(`/api/v1/conversations/${id}/read`)
|
.post(`/api/v1/conversations/${id}/read`)
|
||||||
.set("Authorization", picker.authorization)
|
.set("Authorization", picker.authorization)
|
||||||
|
.set("Origin", process.env.WEB_ORIGIN!)
|
||||||
.send({ seq: "0" })
|
.send({ seq: "0" })
|
||||||
.expect(201);
|
.expect(201);
|
||||||
expect(monotonic.body.data.lastReadSeq).toBe("1");
|
expect(monotonic.body.data.lastReadSeq).toBe("1");
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.message.create({
|
||||||
|
data: {
|
||||||
|
conversationId: id,
|
||||||
|
senderId: author.id,
|
||||||
|
senderPublicId: (
|
||||||
|
await prisma.anonymousProfile.findUniqueOrThrow({
|
||||||
|
where: { accountId: author.id },
|
||||||
|
})
|
||||||
|
).publicId,
|
||||||
|
clientMsgId: randomUUID(),
|
||||||
|
seq: 2n,
|
||||||
|
contentText: "second",
|
||||||
|
reviewStatus: "APPROVED",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.message.create({
|
||||||
|
data: {
|
||||||
|
conversationId: id,
|
||||||
|
senderId: author.id,
|
||||||
|
senderPublicId: (
|
||||||
|
await prisma.anonymousProfile.findUniqueOrThrow({
|
||||||
|
where: { accountId: author.id },
|
||||||
|
})
|
||||||
|
).publicId,
|
||||||
|
clientMsgId: randomUUID(),
|
||||||
|
seq: 3n,
|
||||||
|
contentText: "third",
|
||||||
|
reviewStatus: "APPROVED",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.conversation.update({ where: { id }, data: { nextSeq: 4n } }),
|
||||||
|
]);
|
||||||
|
const reads = await Promise.all(
|
||||||
|
["3", "2"].map((seq) =>
|
||||||
|
request(app.getHttpServer())
|
||||||
|
.post(`/api/v1/conversations/${id}/read`)
|
||||||
|
.set("Authorization", picker.authorization)
|
||||||
|
.set("Origin", process.env.WEB_ORIGIN!)
|
||||||
|
.send({ seq }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(reads.map((result) => result.status)).toEqual([201, 201]);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await prisma.conversationMember.findUniqueOrThrow({
|
||||||
|
where: {
|
||||||
|
conversationId_accountId: {
|
||||||
|
conversationId: id,
|
||||||
|
accountId: picker.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).lastReadSeq,
|
||||||
|
).toBe(3n);
|
||||||
|
|
||||||
|
const pickerSocket = socket(picker.token);
|
||||||
|
await connected(pickerSocket);
|
||||||
|
await expect(
|
||||||
|
ack(pickerSocket, "conversation:read", { conversationId: id, seq: "2" }),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
data: { conversationId: id, lastReadSeq: "3" },
|
||||||
|
});
|
||||||
|
pickerSocket.close();
|
||||||
await prisma.conversationMember.update({
|
await prisma.conversationMember.update({
|
||||||
where: {
|
where: {
|
||||||
conversationId_accountId: { conversationId: id, accountId: picker.id },
|
conversationId_accountId: { conversationId: id, accountId: picker.id },
|
||||||
@@ -412,6 +667,7 @@ describe("conversation API with real PostgreSQL", () => {
|
|||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.post(`/api/v1/conversations/${id}/read`)
|
.post(`/api/v1/conversations/${id}/read`)
|
||||||
.set("Authorization", picker.authorization)
|
.set("Authorization", picker.authorization)
|
||||||
|
.set("Origin", process.env.WEB_ORIGIN!)
|
||||||
.send({ seq: "1" })
|
.send({ seq: "1" })
|
||||||
.expect(403);
|
.expect(403);
|
||||||
});
|
});
|
||||||
@@ -427,6 +683,7 @@ describe("conversation API with real PostgreSQL", () => {
|
|||||||
randomUUID(),
|
randomUUID(),
|
||||||
).expect(201);
|
).expect(201);
|
||||||
const conversationId = created.body.data.conversationId as string;
|
const conversationId = created.body.data.conversationId as string;
|
||||||
|
expect(await app.get(OutboxMessageRelay).runOnce()).toBe(true);
|
||||||
|
|
||||||
const unauthenticated = socket("invalid-token");
|
const unauthenticated = socket("invalid-token");
|
||||||
const denied = new Promise<string>((resolve) =>
|
const denied = new Promise<string>((resolve) =>
|
||||||
@@ -459,6 +716,7 @@ describe("conversation API with real PostgreSQL", () => {
|
|||||||
text: "socket hello",
|
text: "socket hello",
|
||||||
});
|
});
|
||||||
expect(first).toMatchObject({ ok: true, data: { deduplicated: false } });
|
expect(first).toMatchObject({ ok: true, data: { deduplicated: false } });
|
||||||
|
expect(await app.get(OutboxMessageRelay).runOnce()).toBe(true);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
expect([authorDeliveries, pickerDeliveries]).toEqual([1, 1]);
|
expect([authorDeliveries, pickerDeliveries]).toEqual([1, 1]);
|
||||||
const duplicate = await ack<{ data: { deduplicated: boolean } }>(
|
const duplicate = await ack<{ data: { deduplicated: boolean } }>(
|
||||||
|
|||||||
@@ -6,11 +6,20 @@ import { ChatGateway } from "./chat.gateway.js";
|
|||||||
import { ChatRateLimiter } from "./chat-rate-limiter.js";
|
import { ChatRateLimiter } from "./chat-rate-limiter.js";
|
||||||
import { ConversationController } from "./conversation.controller.js";
|
import { ConversationController } from "./conversation.controller.js";
|
||||||
import { ConversationService } from "./conversation.service.js";
|
import { ConversationService } from "./conversation.service.js";
|
||||||
|
import { CHAT_PUBLISHER, OutboxMessageRelay } from "./outbox-message-relay.js";
|
||||||
|
import { StateChangingOriginGuard } from "./state-changing-origin.guard.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule, RedisModule, AuthModule],
|
imports: [DatabaseModule, RedisModule, AuthModule],
|
||||||
controllers: [ConversationController],
|
controllers: [ConversationController],
|
||||||
providers: [ConversationService, ChatRateLimiter, ChatGateway],
|
providers: [
|
||||||
exports: [ConversationService],
|
ConversationService,
|
||||||
|
ChatRateLimiter,
|
||||||
|
ChatGateway,
|
||||||
|
{ provide: CHAT_PUBLISHER, useExisting: ChatGateway },
|
||||||
|
OutboxMessageRelay,
|
||||||
|
StateChangingOriginGuard,
|
||||||
|
],
|
||||||
|
exports: [ConversationService, OutboxMessageRelay],
|
||||||
})
|
})
|
||||||
export class ConversationModule {}
|
export class ConversationModule {}
|
||||||
|
|||||||
@@ -145,10 +145,7 @@ export class ConversationService {
|
|||||||
include: firstMessageInclude,
|
include: firstMessageInclude,
|
||||||
});
|
});
|
||||||
const message = conversation.messages[0]!;
|
const message = conversation.messages[0]!;
|
||||||
await this.createMessageOutbox(tx, message.id, conversation.id, [
|
await this.createMessageOutbox(tx, message.id, conversation.id);
|
||||||
accountId,
|
|
||||||
authorId,
|
|
||||||
]);
|
|
||||||
const consumed = await tx.bottle.updateMany({
|
const consumed = await tx.bottle.updateMany({
|
||||||
where: {
|
where: {
|
||||||
id: bottleId,
|
id: bottleId,
|
||||||
@@ -260,12 +257,7 @@ export class ConversationService {
|
|||||||
const memberIds = conversation.members.map(
|
const memberIds = conversation.members.map(
|
||||||
(member) => member.accountId,
|
(member) => member.accountId,
|
||||||
);
|
);
|
||||||
await this.createMessageOutbox(
|
await this.createMessageOutbox(tx, message.id, conversationId);
|
||||||
tx,
|
|
||||||
message.id,
|
|
||||||
conversationId,
|
|
||||||
memberIds,
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
message: this.serializeMessage(message),
|
message: this.serializeMessage(message),
|
||||||
deduplicated: false,
|
deduplicated: false,
|
||||||
@@ -372,28 +364,36 @@ export class ConversationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async read(accountId: string, id: string, seqText: string) {
|
async read(accountId: string, id: string, seqText: string) {
|
||||||
const member = await this.assertMember(accountId, id);
|
|
||||||
const seq = this.parseSeq(seqText);
|
const seq = this.parseSeq(seqText);
|
||||||
const conversation = await this.prisma.conversation.findUnique({
|
const rows = await this.prisma.$queryRaw<Array<{ lastReadSeq: bigint }>>`
|
||||||
where: { id },
|
UPDATE "conversation_members" AS member
|
||||||
select: { nextSeq: true },
|
SET "last_read_seq"=GREATEST(member."last_read_seq", ${seq})
|
||||||
});
|
FROM "conversations" AS conversation
|
||||||
if (!conversation) throw this.forbidden();
|
WHERE member."conversation_id"=${id}::uuid
|
||||||
if (seq >= conversation.nextSeq) throw this.validation();
|
AND member."account_id"=${accountId}::uuid
|
||||||
if (seq <= member.lastReadSeq) {
|
AND member."left_at" IS NULL
|
||||||
return { conversationId: id, lastReadSeq: member.lastReadSeq.toString() };
|
AND conversation."id"=member."conversation_id"
|
||||||
}
|
AND ${seq} < conversation."next_seq"
|
||||||
const updated = await this.prisma.conversationMember.updateMany({
|
RETURNING member."last_read_seq" AS "lastReadSeq"
|
||||||
where: {
|
`;
|
||||||
|
if (rows[0]) {
|
||||||
|
return {
|
||||||
conversationId: id,
|
conversationId: id,
|
||||||
accountId,
|
lastReadSeq: rows[0].lastReadSeq.toString(),
|
||||||
leftAt: null,
|
};
|
||||||
lastReadSeq: { lt: seq },
|
}
|
||||||
},
|
const [member, conversation] = await Promise.all([
|
||||||
data: { lastReadSeq: seq },
|
this.prisma.conversationMember.findUnique({
|
||||||
});
|
where: { conversationId_accountId: { conversationId: id, accountId } },
|
||||||
if (updated.count !== 1) throw this.forbidden();
|
}),
|
||||||
return { conversationId: id, lastReadSeq: seq.toString() };
|
this.prisma.conversation.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { nextSeq: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (!member || member.leftAt || !conversation) throw this.forbidden();
|
||||||
|
if (seq >= conversation.nextSeq) throw this.validation();
|
||||||
|
return { conversationId: id, lastReadSeq: member.lastReadSeq.toString() };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async authorizeConversation(
|
private async authorizeConversation(
|
||||||
@@ -426,7 +426,6 @@ export class ConversationService {
|
|||||||
tx: Prisma.TransactionClient,
|
tx: Prisma.TransactionClient,
|
||||||
messageId: string,
|
messageId: string,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
memberIds: string[],
|
|
||||||
) {
|
) {
|
||||||
await tx.outboxEvent.create({
|
await tx.outboxEvent.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -434,7 +433,7 @@ export class ConversationService {
|
|||||||
aggregateId: messageId,
|
aggregateId: messageId,
|
||||||
eventType: "MESSAGE_CREATED",
|
eventType: "MESSAGE_CREATED",
|
||||||
dedupeKey: `message-created:${messageId}`,
|
dedupeKey: `message-created:${messageId}`,
|
||||||
payload: { messageId, conversationId, memberIds },
|
payload: { messageId, conversationId },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import {
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
type OnModuleDestroy,
|
||||||
|
type OnModuleInit,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { PrismaService } from "../database/prisma.service.js";
|
||||||
|
import { ConversationService } from "./conversation.service.js";
|
||||||
|
|
||||||
|
export const CHAT_PUBLISHER = Symbol("CHAT_PUBLISHER");
|
||||||
|
|
||||||
|
export interface ChatPublisher {
|
||||||
|
publishMessage(memberIds: string[], message: MessagePayload): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessagePayload = ReturnType<ConversationService["serializeMessage"]>;
|
||||||
|
type ClaimedEvent = {
|
||||||
|
id: string;
|
||||||
|
aggregateId: string;
|
||||||
|
payload: Prisma.JsonValue;
|
||||||
|
attempts: number;
|
||||||
|
lockToken: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NEVER = new Date("9999-12-31T23:59:59.999Z");
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OutboxMessageRelay implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private stopped = false;
|
||||||
|
private loopPromise: Promise<void> | undefined;
|
||||||
|
private wakeLoop: (() => void) | undefined;
|
||||||
|
private readonly leaseMs = Number(process.env.OUTBOX_LEASE_MS ?? 30_000);
|
||||||
|
private readonly maxAttempts = Number(process.env.OUTBOX_MAX_ATTEMPTS ?? 5);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||||
|
@Inject(ConversationService)
|
||||||
|
private readonly conversations: ConversationService,
|
||||||
|
@Inject(CHAT_PUBLISHER) private readonly publisher: ChatPublisher,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onModuleInit(): void {
|
||||||
|
if (process.env.NODE_ENV !== "test") this.loopPromise = this.loop();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy(): Promise<void> {
|
||||||
|
this.stopped = true;
|
||||||
|
this.wakeLoop?.();
|
||||||
|
await this.loopPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async runOnce(): Promise<boolean> {
|
||||||
|
const event = await this.claim();
|
||||||
|
if (!event) return false;
|
||||||
|
try {
|
||||||
|
const payload = this.payload(event);
|
||||||
|
const message = await this.prisma.message.findUnique({
|
||||||
|
where: { id: payload.messageId },
|
||||||
|
include: {
|
||||||
|
conversation: {
|
||||||
|
select: {
|
||||||
|
members: {
|
||||||
|
where: { leftAt: null },
|
||||||
|
select: { accountId: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!message ||
|
||||||
|
message.id !== event.aggregateId ||
|
||||||
|
message.conversationId !== payload.conversationId
|
||||||
|
) {
|
||||||
|
throw new Error("invalid message event");
|
||||||
|
}
|
||||||
|
this.publisher.publishMessage(
|
||||||
|
message.conversation.members.map((member) => member.accountId),
|
||||||
|
this.conversations.serializeMessage(message),
|
||||||
|
);
|
||||||
|
const completed = await this.prisma.outboxEvent.updateMany({
|
||||||
|
where: {
|
||||||
|
id: event.id,
|
||||||
|
eventType: "MESSAGE_CREATED",
|
||||||
|
status: "PROCESSING",
|
||||||
|
lockToken: event.lockToken,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: "PUBLISHED",
|
||||||
|
publishedAt: new Date(),
|
||||||
|
lockedAt: null,
|
||||||
|
lockToken: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (completed.count !== 1) throw new Error("outbox lease lost");
|
||||||
|
} catch (error) {
|
||||||
|
const exhausted = event.attempts >= this.maxAttempts;
|
||||||
|
const delayMs = Math.min(60_000, 1000 * 2 ** (event.attempts - 1));
|
||||||
|
await this.prisma.outboxEvent.updateMany({
|
||||||
|
where: {
|
||||||
|
id: event.id,
|
||||||
|
eventType: "MESSAGE_CREATED",
|
||||||
|
status: "PROCESSING",
|
||||||
|
lockToken: event.lockToken,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: "FAILED",
|
||||||
|
lockedAt: null,
|
||||||
|
lockToken: null,
|
||||||
|
nextRetryAt: exhausted ? NEVER : new Date(Date.now() + delayMs),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.error(
|
||||||
|
JSON.stringify({
|
||||||
|
eventId: event.id,
|
||||||
|
eventType: "MESSAGE_CREATED",
|
||||||
|
attempt: event.attempts,
|
||||||
|
errorClass: error instanceof Error ? error.name : "UnknownError",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loop(): Promise<void> {
|
||||||
|
while (!this.stopped) {
|
||||||
|
const handled = await this.runOnce().catch(() => false);
|
||||||
|
if (!handled && !this.stopped) await this.waitForWork();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitForWork(): Promise<void> {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const timer = setTimeout(resolve, 100);
|
||||||
|
this.wakeLoop = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
this.wakeLoop = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private payload(event: ClaimedEvent): {
|
||||||
|
messageId: string;
|
||||||
|
conversationId: string;
|
||||||
|
} {
|
||||||
|
const value = event.payload;
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
||||||
|
throw new Error("invalid message payload");
|
||||||
|
const payload = value as Record<string, unknown>;
|
||||||
|
if (
|
||||||
|
typeof payload.messageId !== "string" ||
|
||||||
|
typeof payload.conversationId !== "string"
|
||||||
|
) {
|
||||||
|
throw new Error("invalid message payload");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
messageId: payload.messageId,
|
||||||
|
conversationId: payload.conversationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async claim(): Promise<ClaimedEvent | null> {
|
||||||
|
const staleBefore = new Date(Date.now() - this.leaseMs);
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.outboxEvent.updateMany({
|
||||||
|
where: {
|
||||||
|
eventType: "MESSAGE_CREATED",
|
||||||
|
status: "PROCESSING",
|
||||||
|
lockedAt: { lt: staleBefore },
|
||||||
|
attempts: { gte: this.maxAttempts },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: "FAILED",
|
||||||
|
lockedAt: null,
|
||||||
|
lockToken: null,
|
||||||
|
nextRetryAt: NEVER,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const rows = await tx.$queryRaw<Array<Omit<ClaimedEvent, "lockToken">>>`
|
||||||
|
SELECT "id", "aggregate_id" AS "aggregateId", "payload", "attempts"
|
||||||
|
FROM "outbox_events"
|
||||||
|
WHERE "event_type" = 'MESSAGE_CREATED'
|
||||||
|
AND "attempts" < ${this.maxAttempts}
|
||||||
|
AND (("status" IN ('PENDING', 'FAILED') AND "next_retry_at" <= now())
|
||||||
|
OR ("status" = 'PROCESSING' AND "locked_at" < ${staleBefore}))
|
||||||
|
ORDER BY "created_at"
|
||||||
|
FOR UPDATE SKIP LOCKED LIMIT 1`;
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row) return null;
|
||||||
|
const lockToken = randomUUID();
|
||||||
|
await tx.outboxEvent.update({
|
||||||
|
where: { id: row.id },
|
||||||
|
data: {
|
||||||
|
status: "PROCESSING",
|
||||||
|
lockedAt: new Date(),
|
||||||
|
lockToken,
|
||||||
|
attempts: { increment: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { ...row, attempts: row.attempts + 1, lockToken };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
HttpStatus,
|
||||||
|
Injectable,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ErrorCode } from "@drift/contracts";
|
||||||
|
import type { Request } from "express";
|
||||||
|
import { webOrigin } from "../auth/auth.config.js";
|
||||||
|
import { DomainException } from "../common/domain.exception.js";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StateChangingOriginGuard implements CanActivate {
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
if (request.headers.origin === webOrigin()) return true;
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.CONVERSATION_FORBIDDEN,
|
||||||
|
"Forbidden",
|
||||||
|
HttpStatus.FORBIDDEN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,42 @@ describe("moderation worker with real PostgreSQL", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["PENDING", null, 0],
|
||||||
|
["PROCESSING", new Date(Date.now() - 60_000), 5],
|
||||||
|
] as const)(
|
||||||
|
"leaves %s MESSAGE_CREATED for the API relay",
|
||||||
|
async (status, lockedAt, attempts) => {
|
||||||
|
await prisma.account.create({
|
||||||
|
data: {
|
||||||
|
phoneCiphertext: Buffer.from("cipher"),
|
||||||
|
phoneHmac: randomUUID(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const event = await prisma.outboxEvent.create({
|
||||||
|
data: {
|
||||||
|
aggregateType: "MESSAGE",
|
||||||
|
aggregateId: randomUUID(),
|
||||||
|
eventType: "MESSAGE_CREATED",
|
||||||
|
dedupeKey: randomUUID(),
|
||||||
|
payload: {
|
||||||
|
messageId: randomUUID(),
|
||||||
|
conversationId: randomUUID(),
|
||||||
|
},
|
||||||
|
status,
|
||||||
|
lockedAt,
|
||||||
|
lockToken: lockedAt ? randomUUID() : null,
|
||||||
|
attempts,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await new ModerationWorker(prisma).runOnce()).toBe(false);
|
||||||
|
expect(
|
||||||
|
await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }),
|
||||||
|
).toMatchObject({ status, attempts, lockedAt });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("is fail-closed and exponentially reschedules failures", async () => {
|
it("is fail-closed and exponentially reschedules failures", async () => {
|
||||||
const { bottle, event } = await fixture("ordinary");
|
const { bottle, event } = await fixture("ordinary");
|
||||||
const worker = new ModerationWorker(prisma, () => {
|
const worker = new ModerationWorker(prisma, () => {
|
||||||
|
|||||||
@@ -382,6 +382,9 @@ export class ModerationWorker {
|
|||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
await tx.outboxEvent.updateMany({
|
await tx.outboxEvent.updateMany({
|
||||||
where: {
|
where: {
|
||||||
|
eventType: {
|
||||||
|
in: ["BOTTLE_MODERATION_REQUESTED", "PROFILE_MODERATION_REQUESTED"],
|
||||||
|
},
|
||||||
status: "PROCESSING",
|
status: "PROCESSING",
|
||||||
lockedAt: { lt: staleBefore },
|
lockedAt: { lt: staleBefore },
|
||||||
attempts: { gte: this.maxAttempts },
|
attempts: { gte: this.maxAttempts },
|
||||||
|
|||||||
Reference in New Issue
Block a user