fix: 加固聊天限流与安全策略串行化
This commit is contained in:
@@ -0,0 +1,56 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { RedisService } from "../redis/redis.service.js";
|
||||||
|
import { ChatRateLimiter } from "./chat-rate-limiter.js";
|
||||||
|
|
||||||
|
type FakeRedisClient = {
|
||||||
|
isReady: boolean;
|
||||||
|
isOpen: boolean;
|
||||||
|
on: ReturnType<typeof vi.fn>;
|
||||||
|
connect: ReturnType<typeof vi.fn>;
|
||||||
|
eval: ReturnType<typeof vi.fn>;
|
||||||
|
destroy: ReturnType<typeof vi.fn>;
|
||||||
|
quit: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function fakeClient(evalResult: Promise<unknown>): FakeRedisClient {
|
||||||
|
return {
|
||||||
|
isReady: true,
|
||||||
|
isOpen: true,
|
||||||
|
on: vi.fn(),
|
||||||
|
connect: vi.fn(() => Promise.resolve()),
|
||||||
|
eval: vi.fn(() => evalResult),
|
||||||
|
destroy: vi.fn(),
|
||||||
|
quit: vi.fn(() => Promise.resolve()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ChatRateLimiter Redis command deadline", () => {
|
||||||
|
const previousTimeout = process.env.REDIS_OPERATION_TIMEOUT_MS;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (previousTimeout === undefined)
|
||||||
|
delete process.env.REDIS_OPERATION_TIMEOUT_MS;
|
||||||
|
else process.env.REDIS_OPERATION_TIMEOUT_MS = previousTimeout;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("destroys a client whose eval never settles and recovers with a new client", async () => {
|
||||||
|
process.env.REDIS_OPERATION_TIMEOUT_MS = "20";
|
||||||
|
const stuck = fakeClient(new Promise(() => undefined));
|
||||||
|
const healthy = fakeClient(Promise.resolve(1));
|
||||||
|
const factory = vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValueOnce(stuck)
|
||||||
|
.mockReturnValueOnce(healthy);
|
||||||
|
const redis = new RedisService(factory as never);
|
||||||
|
const limiter = new ChatRateLimiter(redis);
|
||||||
|
|
||||||
|
await expect(limiter.consume("account", "session")).rejects.toThrow(
|
||||||
|
"Auth Redis operation timed out",
|
||||||
|
);
|
||||||
|
expect(stuck.destroy).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
await expect(limiter.consume("account", "session")).resolves.toBe(true);
|
||||||
|
expect(healthy.eval).toHaveBeenCalledOnce();
|
||||||
|
expect(factory).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,7 +5,6 @@ import { RedisService } from "../redis/redis.service.js";
|
|||||||
export class ChatRateLimiter {
|
export class ChatRateLimiter {
|
||||||
constructor(@Inject(RedisService) private readonly redis: RedisService) {}
|
constructor(@Inject(RedisService) private readonly redis: RedisService) {}
|
||||||
async consume(accountId: string, sessionId: string): Promise<boolean> {
|
async consume(accountId: string, sessionId: string): Promise<boolean> {
|
||||||
await this.redis.ensureConnected();
|
|
||||||
const prefix = process.env.REDIS_KEY_PREFIX ?? "drift:auth:";
|
const prefix = process.env.REDIS_KEY_PREFIX ?? "drift:auth:";
|
||||||
const max = Number(process.env.CHAT_MESSAGE_RATE_LIMIT ?? 30);
|
const max = Number(process.env.CHAT_MESSAGE_RATE_LIMIT ?? 30);
|
||||||
const ttl = Number(process.env.CHAT_MESSAGE_RATE_WINDOW_SECONDS ?? 60);
|
const ttl = Number(process.env.CHAT_MESSAGE_RATE_WINDOW_SECONDS ?? 60);
|
||||||
@@ -13,9 +12,11 @@ export class ChatRateLimiter {
|
|||||||
`${prefix}chat:account:${accountId}`,
|
`${prefix}chat:account:${accountId}`,
|
||||||
`${prefix}chat:session:${sessionId}`,
|
`${prefix}chat:session:${sessionId}`,
|
||||||
];
|
];
|
||||||
const result = await this.redis.client.eval(
|
const result = await this.redis.executeWithDeadline((client) =>
|
||||||
`for _,k in ipairs(KEYS) do if tonumber(redis.call('GET',k) or '0')+1>tonumber(ARGV[1]) then return 0 end end; for _,k in ipairs(KEYS) do local n=redis.call('INCR',k); if n==1 then redis.call('EXPIRE',k,ARGV[2]) end end; return 1`,
|
client.eval(
|
||||||
{ keys, arguments: [String(max), String(ttl)] },
|
`for _,k in ipairs(KEYS) do if tonumber(redis.call('GET',k) or '0')+1>tonumber(ARGV[1]) then return 0 end end; for _,k in ipairs(KEYS) do local n=redis.call('INCR',k); if n==1 then redis.call('EXPIRE',k,ARGV[2]) end end; return 1`,
|
||||||
|
{ keys, arguments: [String(max), String(ttl)] },
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return Number(result) === 1;
|
return Number(result) === 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ export class ChatGateway
|
|||||||
const user = await this.auth.validateAccess(token);
|
const user = await this.auth.validateAccess(token);
|
||||||
client.data.token = token;
|
client.data.token = token;
|
||||||
client.data.user = user;
|
client.data.user = user;
|
||||||
await client.join(`account:${user.sub}`);
|
|
||||||
next();
|
next();
|
||||||
} catch {
|
} catch {
|
||||||
next(new Error("Unauthorized"));
|
next(new Error("Unauthorized"));
|
||||||
@@ -108,11 +107,14 @@ export class ChatGateway
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleConnection(client: ChatSocket): void {
|
async handleConnection(client: ChatSocket): Promise<void> {
|
||||||
// Single-instance MVP: require a Redis adapter before horizontal scaling.
|
// Single-instance MVP: require a Redis adapter before horizontal scaling.
|
||||||
const user = client.data.user;
|
const user = client.data.user;
|
||||||
if (!user || !client.rooms.has(`account:${user.sub}`))
|
if (!user) {
|
||||||
client.disconnect(true);
|
client.disconnect(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await client.join(`account:${user.sub}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeMessage("message:send")
|
@SubscribeMessage("message:send")
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ 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 { OutboxMessageRelay } from "./outbox-message-relay.js";
|
||||||
import { ChatRateLimiter } from "./chat-rate-limiter.js";
|
import { ChatRateLimiter } from "./chat-rate-limiter.js";
|
||||||
|
import { ConversationService } from "./conversation.service.js";
|
||||||
|
import { SafetyLockService } from "../safety/safety-lock.service.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";
|
||||||
@@ -281,6 +283,101 @@ describe("conversation API with real PostgreSQL", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("linearizes a send before a later block using canonical subject locks", async () => {
|
||||||
|
const author = await actor("lock-send-author");
|
||||||
|
const picker = await actor("lock-send-picker");
|
||||||
|
const { bottle, lease } = await leased(author.id, picker);
|
||||||
|
const created = await reply(
|
||||||
|
picker.authorization,
|
||||||
|
bottle.id,
|
||||||
|
lease,
|
||||||
|
randomUUID(),
|
||||||
|
).expect(201);
|
||||||
|
const conversationId = created.body.data.conversationId as string;
|
||||||
|
const locks = app.get(SafetyLockService);
|
||||||
|
const originalLock = locks.lockAccounts.bind(locks);
|
||||||
|
let announceLocked!: () => void;
|
||||||
|
let releaseSend!: () => void;
|
||||||
|
const sendLocked = new Promise<void>(
|
||||||
|
(resolve) => (announceLocked = resolve),
|
||||||
|
);
|
||||||
|
const sendRelease = new Promise<void>((resolve) => (releaseSend = resolve));
|
||||||
|
let pause = true;
|
||||||
|
locks.lockAccounts = async (tx, accountIds) => {
|
||||||
|
await originalLock(tx, accountIds);
|
||||||
|
if (pause) {
|
||||||
|
pause = false;
|
||||||
|
announceLocked();
|
||||||
|
await sendRelease;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const send = app
|
||||||
|
.get(ConversationService)
|
||||||
|
.send(picker.id, conversationId, randomUUID(), "before block");
|
||||||
|
await sendLocked;
|
||||||
|
let blockCommitted = false;
|
||||||
|
const block = prisma
|
||||||
|
.$transaction(async (tx) => {
|
||||||
|
await locks.lockAccounts(tx, [author.id, picker.id]);
|
||||||
|
await tx.block.create({
|
||||||
|
data: { blockerId: author.id, blockedId: picker.id },
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
blockCommitted = true;
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||||
|
expect(blockCommitted).toBe(false);
|
||||||
|
releaseSend();
|
||||||
|
await expect(send).resolves.toMatchObject({ deduplicated: false });
|
||||||
|
await block;
|
||||||
|
locks.lockAccounts = originalLock;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for an earlier block and then rejects the message", async () => {
|
||||||
|
const author = await actor("lock-block-author");
|
||||||
|
const picker = await actor("lock-block-picker");
|
||||||
|
const { bottle, lease } = await leased(author.id, picker);
|
||||||
|
const created = await reply(
|
||||||
|
picker.authorization,
|
||||||
|
bottle.id,
|
||||||
|
lease,
|
||||||
|
randomUUID(),
|
||||||
|
).expect(201);
|
||||||
|
const conversationId = created.body.data.conversationId as string;
|
||||||
|
const locks = app.get(SafetyLockService);
|
||||||
|
let announceLocked!: () => void;
|
||||||
|
let releaseBlock!: () => void;
|
||||||
|
const blockLocked = new Promise<void>(
|
||||||
|
(resolve) => (announceLocked = resolve),
|
||||||
|
);
|
||||||
|
const blockRelease = new Promise<void>(
|
||||||
|
(resolve) => (releaseBlock = resolve),
|
||||||
|
);
|
||||||
|
const block = prisma.$transaction(async (tx) => {
|
||||||
|
await locks.lockAccounts(tx, [picker.id, author.id]);
|
||||||
|
await tx.block.create({
|
||||||
|
data: { blockerId: author.id, blockedId: picker.id },
|
||||||
|
});
|
||||||
|
announceLocked();
|
||||||
|
await blockRelease;
|
||||||
|
});
|
||||||
|
await blockLocked;
|
||||||
|
let sendSettled = false;
|
||||||
|
const send = app
|
||||||
|
.get(ConversationService)
|
||||||
|
.send(picker.id, conversationId, randomUUID(), "after block")
|
||||||
|
.finally(() => {
|
||||||
|
sendSettled = true;
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||||
|
expect(sendSettled).toBe(false);
|
||||||
|
releaseBlock();
|
||||||
|
await block;
|
||||||
|
await expect(send).rejects.toMatchObject({ code: "USER_BLOCKED" });
|
||||||
|
});
|
||||||
|
|
||||||
it("authorizes before idempotency lookup and never exposes account ids", async () => {
|
it("authorizes before idempotency lookup and never exposes account ids", async () => {
|
||||||
const author = await actor("author");
|
const author = await actor("author");
|
||||||
const picker = await actor("picker");
|
const picker = await actor("picker");
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Module } from "@nestjs/common";
|
|||||||
import { AuthModule } from "../auth/auth.module.js";
|
import { AuthModule } from "../auth/auth.module.js";
|
||||||
import { DatabaseModule } from "../database/database.module.js";
|
import { DatabaseModule } from "../database/database.module.js";
|
||||||
import { RedisModule } from "../redis/redis.module.js";
|
import { RedisModule } from "../redis/redis.module.js";
|
||||||
|
import { SafetyModule } from "../safety/safety.module.js";
|
||||||
import { ChatGateway } from "./chat.gateway.js";
|
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";
|
||||||
@@ -10,7 +11,7 @@ import { CHAT_PUBLISHER, OutboxMessageRelay } from "./outbox-message-relay.js";
|
|||||||
import { StateChangingOriginGuard } from "./state-changing-origin.guard.js";
|
import { StateChangingOriginGuard } from "./state-changing-origin.guard.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule, RedisModule, AuthModule],
|
imports: [DatabaseModule, RedisModule, AuthModule, SafetyModule],
|
||||||
controllers: [ConversationController],
|
controllers: [ConversationController],
|
||||||
providers: [
|
providers: [
|
||||||
ConversationService,
|
ConversationService,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Prisma, type PrismaClient } from "@prisma/client";
|
|||||||
import { leaseHmac } from "../auth/auth.config.js";
|
import { leaseHmac } from "../auth/auth.config.js";
|
||||||
import { DomainException } from "../common/domain.exception.js";
|
import { DomainException } from "../common/domain.exception.js";
|
||||||
import { PrismaService } from "../database/prisma.service.js";
|
import { PrismaService } from "../database/prisma.service.js";
|
||||||
|
import { SafetyLockService } from "../safety/safety-lock.service.js";
|
||||||
|
|
||||||
type Db = Prisma.TransactionClient | PrismaClient;
|
type Db = Prisma.TransactionClient | PrismaClient;
|
||||||
type MessageRow = {
|
type MessageRow = {
|
||||||
@@ -42,7 +43,10 @@ const firstMessageInclude = {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ConversationService {
|
export class ConversationService {
|
||||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||||
|
@Inject(SafetyLockService) private readonly safetyLocks: SafetyLockService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async reply(
|
async reply(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
@@ -59,8 +63,7 @@ export class ConversationService {
|
|||||||
include: firstMessageInclude,
|
include: firstMessageInclude,
|
||||||
});
|
});
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await this.assertConversationMember(accountId, existing.id);
|
return this.resolveExistingReply(accountId, existing, clientMsgId);
|
||||||
return this.replyResult(existing, clientMsgId, false);
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return await this.prisma.$transaction(
|
return await this.prisma.$transaction(
|
||||||
@@ -71,8 +74,12 @@ export class ConversationService {
|
|||||||
include: firstMessageInclude,
|
include: firstMessageInclude,
|
||||||
});
|
});
|
||||||
if (winner) {
|
if (winner) {
|
||||||
await this.assertConversationMember(accountId, winner.id, tx);
|
return this.resolveExistingReply(
|
||||||
return this.replyResult(winner, clientMsgId, false);
|
accountId,
|
||||||
|
winner,
|
||||||
|
clientMsgId,
|
||||||
|
tx,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const lease = await tx.bottlePickLease.findFirst({
|
const lease = await tx.bottlePickLease.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -99,6 +106,7 @@ export class ConversationService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const authorId = lease.bottle.authorId;
|
const authorId = lease.bottle.authorId;
|
||||||
|
await this.safetyLocks.lockAccounts(tx, [accountId, authorId]);
|
||||||
await this.assertAllowed(tx, accountId, authorId);
|
await this.assertAllowed(tx, accountId, authorId);
|
||||||
const profiles = await tx.anonymousProfile.findMany({
|
const profiles = await tx.anonymousProfile.findMany({
|
||||||
where: { accountId: { in: [accountId, authorId] } },
|
where: { accountId: { in: [accountId, authorId] } },
|
||||||
@@ -177,8 +185,7 @@ export class ConversationService {
|
|||||||
include: firstMessageInclude,
|
include: firstMessageInclude,
|
||||||
});
|
});
|
||||||
if (raced) {
|
if (raced) {
|
||||||
await this.assertConversationMember(accountId, raced.id);
|
return this.resolveExistingReply(accountId, raced, clientMsgId);
|
||||||
return this.replyResult(raced, clientMsgId, false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,6 +196,26 @@ export class ConversationService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveExistingReply(
|
||||||
|
accountId: string,
|
||||||
|
conversation: ConversationWithFirst,
|
||||||
|
clientMsgId: string,
|
||||||
|
existingTx?: Prisma.TransactionClient,
|
||||||
|
) {
|
||||||
|
const resolve = async (tx: Prisma.TransactionClient) => {
|
||||||
|
const authorized = await this.authorizeConversationMembership(
|
||||||
|
tx,
|
||||||
|
accountId,
|
||||||
|
conversation.id,
|
||||||
|
);
|
||||||
|
const peer = this.activePeer(authorized.members, accountId);
|
||||||
|
await this.safetyLocks.lockAccounts(tx, [accountId, peer.accountId]);
|
||||||
|
await this.assertAllowed(tx, accountId, peer.accountId);
|
||||||
|
return this.replyResult(conversation, clientMsgId, false);
|
||||||
|
};
|
||||||
|
return existingTx ? resolve(existingTx) : this.prisma.$transaction(resolve);
|
||||||
|
}
|
||||||
|
|
||||||
private replyResult(
|
private replyResult(
|
||||||
conversation: ConversationWithFirst,
|
conversation: ConversationWithFirst,
|
||||||
clientMsgId: string,
|
clientMsgId: string,
|
||||||
@@ -214,11 +241,14 @@ export class ConversationService {
|
|||||||
this.moderate(text);
|
this.moderate(text);
|
||||||
try {
|
try {
|
||||||
return await this.prisma.$transaction(async (tx) => {
|
return await this.prisma.$transaction(async (tx) => {
|
||||||
const conversation = await this.authorizeConversation(
|
const conversation = await this.authorizeConversationMembership(
|
||||||
tx,
|
tx,
|
||||||
accountId,
|
accountId,
|
||||||
conversationId,
|
conversationId,
|
||||||
);
|
);
|
||||||
|
const peer = this.activePeer(conversation.members, accountId);
|
||||||
|
await this.safetyLocks.lockAccounts(tx, [accountId, peer.accountId]);
|
||||||
|
await this.assertAllowed(tx, accountId, peer.accountId);
|
||||||
const previous = await tx.message.findUnique({
|
const previous = await tx.message.findUnique({
|
||||||
where: {
|
where: {
|
||||||
conversationId_clientMsgId: { conversationId, clientMsgId },
|
conversationId_clientMsgId: { conversationId, clientMsgId },
|
||||||
@@ -267,11 +297,14 @@ export class ConversationService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.isMessageIdempotencyRace(error)) {
|
if (this.isMessageIdempotencyRace(error)) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const conversation = await this.authorizeConversation(
|
const conversation = await this.authorizeConversationMembership(
|
||||||
tx,
|
tx,
|
||||||
accountId,
|
accountId,
|
||||||
conversationId,
|
conversationId,
|
||||||
);
|
);
|
||||||
|
const peer = this.activePeer(conversation.members, accountId);
|
||||||
|
await this.safetyLocks.lockAccounts(tx, [accountId, peer.accountId]);
|
||||||
|
await this.assertAllowed(tx, accountId, peer.accountId);
|
||||||
const message = await tx.message.findUniqueOrThrow({
|
const message = await tx.message.findUniqueOrThrow({
|
||||||
where: {
|
where: {
|
||||||
conversationId_clientMsgId: { conversationId, clientMsgId },
|
conversationId_clientMsgId: { conversationId, clientMsgId },
|
||||||
@@ -396,7 +429,7 @@ export class ConversationService {
|
|||||||
return { conversationId: id, lastReadSeq: member.lastReadSeq.toString() };
|
return { conversationId: id, lastReadSeq: member.lastReadSeq.toString() };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async authorizeConversation(
|
private async authorizeConversationMembership(
|
||||||
tx: Db,
|
tx: Db,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
@@ -414,12 +447,18 @@ export class ConversationService {
|
|||||||
) {
|
) {
|
||||||
throw this.forbidden();
|
throw this.forbidden();
|
||||||
}
|
}
|
||||||
const peer = conversation.members.find(
|
return conversation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private activePeer<T extends { accountId: string; leftAt: Date | null }>(
|
||||||
|
members: T[],
|
||||||
|
accountId: string,
|
||||||
|
): T {
|
||||||
|
const peer = members.find(
|
||||||
(member) => member.accountId !== accountId && !member.leftAt,
|
(member) => member.accountId !== accountId && !member.leftAt,
|
||||||
);
|
);
|
||||||
if (!peer) throw this.forbidden();
|
if (!peer) throw this.forbidden();
|
||||||
await this.assertAllowed(tx, accountId, peer.accountId);
|
return peer;
|
||||||
return conversation;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createMessageOutbox(
|
private async createMessageOutbox(
|
||||||
@@ -524,17 +563,6 @@ export class ConversationService {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async assertConversationMember(
|
|
||||||
accountId: string,
|
|
||||||
id: string,
|
|
||||||
tx: Db = this.prisma,
|
|
||||||
) {
|
|
||||||
const found = await tx.conversationMember.findUnique({
|
|
||||||
where: { conversationId_accountId: { conversationId: id, accountId } },
|
|
||||||
});
|
|
||||||
if (!found || found.leftAt) throw this.hidden();
|
|
||||||
}
|
|
||||||
|
|
||||||
async assertMember(accountId: string, id: string) {
|
async assertMember(accountId: string, id: string) {
|
||||||
const found = await this.prisma.conversationMember.findUnique({
|
const found = await this.prisma.conversationMember.findUnique({
|
||||||
where: { conversationId_accountId: { conversationId: id, accountId } },
|
where: { conversationId_accountId: { conversationId: id, accountId } },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
Logger,
|
||||||
type OnModuleDestroy,
|
type OnModuleDestroy,
|
||||||
type OnModuleInit,
|
type OnModuleInit,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
@@ -26,13 +27,22 @@ type ClaimedEvent = {
|
|||||||
|
|
||||||
const NEVER = new Date("9999-12-31T23:59:59.999Z");
|
const NEVER = new Date("9999-12-31T23:59:59.999Z");
|
||||||
|
|
||||||
|
function positiveInt(value: string | undefined, fallback: number): number {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OutboxMessageRelay implements OnModuleInit, OnModuleDestroy {
|
export class OutboxMessageRelay implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(OutboxMessageRelay.name);
|
||||||
private stopped = false;
|
private stopped = false;
|
||||||
private loopPromise: Promise<void> | undefined;
|
private loopPromise: Promise<void> | undefined;
|
||||||
private wakeLoop: (() => void) | undefined;
|
private wakeLoop: (() => void) | undefined;
|
||||||
private readonly leaseMs = Number(process.env.OUTBOX_LEASE_MS ?? 30_000);
|
private readonly leaseMs = positiveInt(process.env.OUTBOX_LEASE_MS, 30_000);
|
||||||
private readonly maxAttempts = Number(process.env.OUTBOX_MAX_ATTEMPTS ?? 5);
|
private readonly maxAttempts = positiveInt(
|
||||||
|
process.env.OUTBOX_MAX_ATTEMPTS,
|
||||||
|
5,
|
||||||
|
);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(PrismaService) private readonly prisma: PrismaService,
|
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||||
@@ -112,21 +122,25 @@ export class OutboxMessageRelay implements OnModuleInit, OnModuleDestroy {
|
|||||||
nextRetryAt: exhausted ? NEVER : new Date(Date.now() + delayMs),
|
nextRetryAt: exhausted ? NEVER : new Date(Date.now() + delayMs),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.error(
|
this.logger.error({
|
||||||
JSON.stringify({
|
eventId: event.id,
|
||||||
eventId: event.id,
|
eventType: "MESSAGE_CREATED",
|
||||||
eventType: "MESSAGE_CREATED",
|
attempt: event.attempts,
|
||||||
attempt: event.attempts,
|
errorClass: error instanceof Error ? error.name : "UnknownError",
|
||||||
errorClass: error instanceof Error ? error.name : "UnknownError",
|
});
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loop(): Promise<void> {
|
private async loop(): Promise<void> {
|
||||||
while (!this.stopped) {
|
while (!this.stopped) {
|
||||||
const handled = await this.runOnce().catch(() => false);
|
const handled = await this.runOnce().catch((error: unknown) => {
|
||||||
|
this.logger.error({
|
||||||
|
event: "Message outbox relay iteration failed",
|
||||||
|
errorClass: error instanceof Error ? error.name : "UnknownError",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
});
|
||||||
if (!handled && !this.stopped) await this.waitForWork();
|
if (!handled && !this.stopped) await this.waitForWork();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,27 @@ export class RedisService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs an already-connected Redis command under the same hard deadline as
|
||||||
|
* connect/quit. On timeout the exact client generation is destroyed, which
|
||||||
|
* rejects queued commands and lets the next call create a fresh client.
|
||||||
|
* Callers must create the command from the provided client: retaining
|
||||||
|
* `redis.client` across calls would bypass generation-safe replacement.
|
||||||
|
*/
|
||||||
|
async executeWithDeadline<T>(
|
||||||
|
operation: (client: RedisClient) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
await this.ensureConnected();
|
||||||
|
const client = this.client;
|
||||||
|
const generation = this.generation;
|
||||||
|
try {
|
||||||
|
return await this.withDeadline(client, generation, operation(client));
|
||||||
|
} catch (error) {
|
||||||
|
this.destroyClient(client, generation);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
/* Auth Redis connects lazily so unrelated modules remain isolated. */
|
/* Auth Redis connects lazily so unrelated modules remain isolated. */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { type Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transaction-scoped serialization protocol for policy subjects.
|
||||||
|
*
|
||||||
|
* Every transaction that reads or writes block, sanction, or account-status
|
||||||
|
* policy MUST call this method first for every affected account. IDs are
|
||||||
|
* de-duplicated and sorted so overlapping multi-account operations cannot
|
||||||
|
* deadlock. The lock is released by PostgreSQL only when the transaction ends;
|
||||||
|
* callers must therefore pass the transaction client, never PrismaService.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SafetyLockService {
|
||||||
|
async lockAccounts(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
accountIds: readonly string[],
|
||||||
|
): Promise<void> {
|
||||||
|
const canonicalIds = Array.from(new Set(accountIds)).sort();
|
||||||
|
for (const accountId of canonicalIds) {
|
||||||
|
await tx.$executeRaw`
|
||||||
|
SELECT pg_advisory_xact_lock(
|
||||||
|
hashtextextended(${`safety:${accountId}`}, 0)
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { SafetyLockService } from "./safety-lock.service.js";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [SafetyLockService],
|
||||||
|
exports: [SafetyLockService],
|
||||||
|
})
|
||||||
|
export class SafetyModule {}
|
||||||
Reference in New Issue
Block a user