fix: 完成消息 Outbox 投递与会话状态保护

This commit is contained in:
root
2026-09-16 13:41:59 +08:00
parent 13b8b9ded8
commit 8a12c04104
9 changed files with 621 additions and 46 deletions
+11 -8
View File
@@ -23,6 +23,7 @@ import {
type MessageResult,
} from "./conversation.service.js";
import { SendMessageDto, SocketReadDto } from "./dto.js";
import type { ChatPublisher } from "./outbox-message-relay.js";
interface SocketData {
token?: string;
@@ -72,7 +73,9 @@ function allowOrigin(
credentials: true,
},
})
export class ChatGateway implements OnGatewayInit, OnGatewayConnection {
export class ChatGateway
implements OnGatewayInit, OnGatewayConnection, ChatPublisher
{
@WebSocketServer() server!: ChatServer;
constructor(
@@ -153,13 +156,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection {
};
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;
} catch (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 {
if (error instanceof DomainException) {
return {
@@ -2,6 +2,7 @@ import {
Body,
Controller,
Get,
HttpStatus,
Inject,
Param,
ParseUUIDPipe,
@@ -9,10 +10,14 @@ import {
Query,
UseGuards,
} from "@nestjs/common";
import { ErrorCode } from "@drift/contracts";
import { AuthGuard } from "../auth/auth.guard.js";
import { CurrentUser } from "../auth/current-user.decorator.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 { StateChangingOriginGuard } from "./state-changing-origin.guard.js";
import {
ConversationsQueryDto,
MessagesQueryDto,
@@ -27,8 +32,11 @@ export class ConversationController {
constructor(
@Inject(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,
@Param("id", new ParseUUIDPipe()) id: string,
@Body() dto: ReplyDto,
@@ -66,20 +74,50 @@ export class ConversationController {
query.limit,
);
}
@Post("conversations/:id/messages/prepare") prepare(
@UseGuards(StateChangingOriginGuard)
@Post("conversations/:id/messages/prepare")
prepare(
@CurrentUser() user: AccessClaims,
@Param("id", new ParseUUIDPipe()) id: string,
@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,
@Param("id", new ParseUUIDPipe()) id: string,
@Body() dto: ReadConversationDto,
) {
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(
"design:paramtypes",
@@ -12,6 +12,8 @@ import { AppModule } from "../app.module.js";
import { TokenService } from "../auth/token.service.js";
import { leaseHmac, encryptLeaseToken } from "../auth/auth.config.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.WEB_ORIGIN = "http://localhost:3000";
@@ -44,6 +46,7 @@ describe("conversation API with real PostgreSQL", () => {
await app.listen(0, "127.0.0.1");
});
beforeEach(async () => {
await prisma.outboxEvent.deleteMany();
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "accounts" CASCADE`);
});
afterAll(async () => {
@@ -127,6 +130,7 @@ describe("conversation API with real PostgreSQL", () => {
request(app.getHttpServer())
.post(`/api/v1/bottles/${bottleId}/reply`)
.set("Authorization", authorization)
.set("Origin", process.env.WEB_ORIGIN!)
.send({ leaseId: lease.id, leaseToken: lease.token, clientMsgId, text });
function socket(token: string, origin = process.env.WEB_ORIGIN): Socket {
@@ -294,6 +298,7 @@ describe("conversation API with real PostgreSQL", () => {
await request(app.getHttpServer())
.post(`/api/v1/conversations/${conversationId}/messages/prepare`)
.set("Authorization", stranger.authorization)
.set("Origin", process.env.WEB_ORIGIN!)
.send({ conversationId, clientMsgId, text: "steal" })
.expect(403);
@@ -328,6 +333,188 @@ describe("conversation API with real PostgreSQL", () => {
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 () => {
const owner = await actor("owner");
const peer = await actor("peer");
@@ -395,14 +582,82 @@ describe("conversation API with real PostgreSQL", () => {
await request(app.getHttpServer())
.post(`/api/v1/conversations/${id}/read`)
.set("Authorization", picker.authorization)
.set("Origin", process.env.WEB_ORIGIN!)
.send({ seq: "1" })
.expect(201);
const monotonic = await request(app.getHttpServer())
.post(`/api/v1/conversations/${id}/read`)
.set("Authorization", picker.authorization)
.set("Origin", process.env.WEB_ORIGIN!)
.send({ seq: "0" })
.expect(201);
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({
where: {
conversationId_accountId: { conversationId: id, accountId: picker.id },
@@ -412,6 +667,7 @@ describe("conversation API with real PostgreSQL", () => {
await request(app.getHttpServer())
.post(`/api/v1/conversations/${id}/read`)
.set("Authorization", picker.authorization)
.set("Origin", process.env.WEB_ORIGIN!)
.send({ seq: "1" })
.expect(403);
});
@@ -427,6 +683,7 @@ describe("conversation API with real PostgreSQL", () => {
randomUUID(),
).expect(201);
const conversationId = created.body.data.conversationId as string;
expect(await app.get(OutboxMessageRelay).runOnce()).toBe(true);
const unauthenticated = socket("invalid-token");
const denied = new Promise<string>((resolve) =>
@@ -459,6 +716,7 @@ describe("conversation API with real PostgreSQL", () => {
text: "socket hello",
});
expect(first).toMatchObject({ ok: true, data: { deduplicated: false } });
expect(await app.get(OutboxMessageRelay).runOnce()).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 50));
expect([authorDeliveries, pickerDeliveries]).toEqual([1, 1]);
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 { ConversationController } from "./conversation.controller.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({
imports: [DatabaseModule, RedisModule, AuthModule],
controllers: [ConversationController],
providers: [ConversationService, ChatRateLimiter, ChatGateway],
exports: [ConversationService],
providers: [
ConversationService,
ChatRateLimiter,
ChatGateway,
{ provide: CHAT_PUBLISHER, useExisting: ChatGateway },
OutboxMessageRelay,
StateChangingOriginGuard,
],
exports: [ConversationService, OutboxMessageRelay],
})
export class ConversationModule {}
@@ -145,10 +145,7 @@ export class ConversationService {
include: firstMessageInclude,
});
const message = conversation.messages[0]!;
await this.createMessageOutbox(tx, message.id, conversation.id, [
accountId,
authorId,
]);
await this.createMessageOutbox(tx, message.id, conversation.id);
const consumed = await tx.bottle.updateMany({
where: {
id: bottleId,
@@ -260,12 +257,7 @@ export class ConversationService {
const memberIds = conversation.members.map(
(member) => member.accountId,
);
await this.createMessageOutbox(
tx,
message.id,
conversationId,
memberIds,
);
await this.createMessageOutbox(tx, message.id, conversationId);
return {
message: this.serializeMessage(message),
deduplicated: false,
@@ -372,28 +364,36 @@ export class ConversationService {
}
async read(accountId: string, id: string, seqText: string) {
const member = await this.assertMember(accountId, id);
const seq = this.parseSeq(seqText);
const conversation = await this.prisma.conversation.findUnique({
where: { id },
select: { nextSeq: true },
});
if (!conversation) throw this.forbidden();
if (seq >= conversation.nextSeq) throw this.validation();
if (seq <= member.lastReadSeq) {
return { conversationId: id, lastReadSeq: member.lastReadSeq.toString() };
}
const updated = await this.prisma.conversationMember.updateMany({
where: {
const rows = await this.prisma.$queryRaw<Array<{ lastReadSeq: bigint }>>`
UPDATE "conversation_members" AS member
SET "last_read_seq"=GREATEST(member."last_read_seq", ${seq})
FROM "conversations" AS conversation
WHERE member."conversation_id"=${id}::uuid
AND member."account_id"=${accountId}::uuid
AND member."left_at" IS NULL
AND conversation."id"=member."conversation_id"
AND ${seq} < conversation."next_seq"
RETURNING member."last_read_seq" AS "lastReadSeq"
`;
if (rows[0]) {
return {
conversationId: id,
accountId,
leftAt: null,
lastReadSeq: { lt: seq },
},
data: { lastReadSeq: seq },
});
if (updated.count !== 1) throw this.forbidden();
return { conversationId: id, lastReadSeq: seq.toString() };
lastReadSeq: rows[0].lastReadSeq.toString(),
};
}
const [member, conversation] = await Promise.all([
this.prisma.conversationMember.findUnique({
where: { conversationId_accountId: { conversationId: id, accountId } },
}),
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(
@@ -426,7 +426,6 @@ export class ConversationService {
tx: Prisma.TransactionClient,
messageId: string,
conversationId: string,
memberIds: string[],
) {
await tx.outboxEvent.create({
data: {
@@ -434,7 +433,7 @@ export class ConversationService {
aggregateId: messageId,
eventType: "MESSAGE_CREATED",
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,
);
}
}
+36
View File
@@ -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 () => {
const { bottle, event } = await fixture("ordinary");
const worker = new ModerationWorker(prisma, () => {
+3
View File
@@ -382,6 +382,9 @@ export class ModerationWorker {
return this.prisma.$transaction(async (tx) => {
await tx.outboxEvent.updateMany({
where: {
eventType: {
in: ["BOTTLE_MODERATION_REQUESTED", "PROFILE_MODERATION_REQUESTED"],
},
status: "PROCESSING",
lockedAt: { lt: staleBefore },
attempts: { gte: this.maxAttempts },