Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa0fa78312 | |||
| ce8e1db279 | |||
| 02189ebb2e | |||
| 8a12c04104 | |||
| 13b8b9ded8 | |||
| 11fcdb5307 | |||
| c94d7b1af0 | |||
| 0c5cbd75d9 | |||
| 69fc8d51ce | |||
| 969a10d664 |
@@ -9,6 +9,8 @@ PHONE_ENCRYPTION_KEY=<base64-encoded-exactly-32-byte-key>
|
|||||||
PHONE_HMAC_KEY=<independent-at-least-32-byte-secret>
|
PHONE_HMAC_KEY=<independent-at-least-32-byte-secret>
|
||||||
VERIFICATION_CODE_HMAC_KEY=<independent-at-least-32-byte-secret>
|
VERIFICATION_CODE_HMAC_KEY=<independent-at-least-32-byte-secret>
|
||||||
REFRESH_TOKEN_HMAC_KEY=<independent-at-least-32-byte-secret>
|
REFRESH_TOKEN_HMAC_KEY=<independent-at-least-32-byte-secret>
|
||||||
|
LEASE_TOKEN_HMAC_KEY=<independent-at-least-32-byte-secret>
|
||||||
|
LEASE_TOKEN_ENCRYPTION_KEY=<base64-encoded-exactly-32-byte-key>
|
||||||
JWT_SECRET=<at-least-32-byte-secret>
|
JWT_SECRET=<at-least-32-byte-secret>
|
||||||
JWT_ISSUER=drift-api
|
JWT_ISSUER=drift-api
|
||||||
JWT_AUDIENCE=drift-web
|
JWT_AUDIENCE=drift-web
|
||||||
@@ -19,4 +21,10 @@ REFRESH_ATTEMPT_IP_WINDOW_SECONDS=60
|
|||||||
REFRESH_RATE_LIMIT=60
|
REFRESH_RATE_LIMIT=60
|
||||||
SMS_CODE_TTL_SECONDS=300
|
SMS_CODE_TTL_SECONDS=300
|
||||||
REDIS_KEY_PREFIX=drift:auth:
|
REDIS_KEY_PREFIX=drift:auth:
|
||||||
|
# Overall pick retry budget and attempt cap; keep bounded to protect API latency/DB load.
|
||||||
|
MATCH_PICK_RETRY_BUDGET_MS=5000
|
||||||
|
MATCH_PICK_MAX_ATTEMPTS=64
|
||||||
|
# Each pick/winner-read transaction is capped and further reduced to its remaining budget.
|
||||||
|
MATCH_PICK_TRANSACTION_MAX_WAIT_MS=1000
|
||||||
|
MATCH_PICK_TRANSACTION_TIMEOUT_MS=2000
|
||||||
TRUST_PROXY=false
|
TRUST_PROXY=false
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"build": "tsc -p tsconfig.build.json",
|
"build": "tsc -p tsconfig.build.json",
|
||||||
"start": "node dist/main.js",
|
"start": "node dist/main.js",
|
||||||
"test": "vitest run --config vitest.config.ts --no-file-parallelism",
|
"test": "vitest run --config vitest.config.ts --no-file-parallelism",
|
||||||
"test:e2e": "vitest run --config vitest.config.ts src/health/health.e2e-spec.ts src/auth/auth.e2e-spec.ts src/profile/profile.e2e-spec.ts src/bottle/bottle.e2e-spec.ts --no-file-parallelism",
|
"test:e2e": "vitest run --config vitest.config.ts src/health/health.e2e-spec.ts src/auth/auth.e2e-spec.ts src/profile/profile.e2e-spec.ts src/bottle/bottle.e2e-spec.ts src/match/match.e2e-spec.ts src/conversation/conversation.e2e-spec.ts src/safety/safety-admin.e2e-spec.ts --no-file-parallelism",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -15,6 +15,8 @@
|
|||||||
"@nestjs/common": "^11.1.6",
|
"@nestjs/common": "^11.1.6",
|
||||||
"@nestjs/core": "^11.1.6",
|
"@nestjs/core": "^11.1.6",
|
||||||
"@nestjs/platform-express": "^11.1.6",
|
"@nestjs/platform-express": "^11.1.6",
|
||||||
|
"@nestjs/platform-socket.io": "^11.1.6",
|
||||||
|
"@nestjs/websockets": "^11.1.6",
|
||||||
"@prisma/client": "6.19.0",
|
"@prisma/client": "6.19.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.2",
|
"class-validator": "^0.14.2",
|
||||||
@@ -22,12 +24,14 @@
|
|||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"redis": "^5.8.2",
|
"redis": "^5.8.2",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.2"
|
"rxjs": "^7.8.2",
|
||||||
|
"socket.io": "^4.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/testing": "^11.1.6",
|
"@nestjs/testing": "^11.1.6",
|
||||||
"@types/express": "^5.0.3",
|
"@types/express": "^5.0.3",
|
||||||
"@types/supertest": "^6.0.3",
|
"@types/supertest": "^6.0.3",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
"supertest": "^7.1.4",
|
"supertest": "^7.1.4",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vitest": "^4.1.11"
|
"vitest": "^4.1.11"
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { AuthGuard } from "../auth/auth.guard.js";
|
||||||
|
import { CurrentUser } from "../auth/current-user.decorator.js";
|
||||||
|
import type { AccessClaims } from "../auth/token.service.js";
|
||||||
|
import { StateChangingOriginGuard } from "../conversation/state-changing-origin.guard.js";
|
||||||
|
import { AdminGuard } from "./admin.guard.js";
|
||||||
|
import { AdminService } from "./admin.service.js";
|
||||||
|
import {
|
||||||
|
AdminQueryDto,
|
||||||
|
ModerateDto,
|
||||||
|
ResolveReportDto,
|
||||||
|
SanctionDto,
|
||||||
|
} from "./dto.js";
|
||||||
|
@Controller("admin")
|
||||||
|
@UseGuards(AuthGuard, AdminGuard)
|
||||||
|
export class AdminController {
|
||||||
|
constructor(@Inject(AdminService) private readonly admin: AdminService) {}
|
||||||
|
@Get("reports") reports(
|
||||||
|
@CurrentUser() u: AccessClaims,
|
||||||
|
@Query() q: AdminQueryDto,
|
||||||
|
) {
|
||||||
|
return this.admin.reports(u.sub, q.status, q.limit);
|
||||||
|
}
|
||||||
|
@UseGuards(StateChangingOriginGuard) @Post("reports/:id/resolve") resolve(
|
||||||
|
@CurrentUser() u: AccessClaims,
|
||||||
|
@Param("id", new ParseUUIDPipe()) id: string,
|
||||||
|
@Body() d: ResolveReportDto,
|
||||||
|
) {
|
||||||
|
return this.admin.resolve(u.sub, id, d);
|
||||||
|
}
|
||||||
|
@Get("moderation") moderation(
|
||||||
|
@CurrentUser() u: AccessClaims,
|
||||||
|
@Query() q: AdminQueryDto,
|
||||||
|
) {
|
||||||
|
return this.admin.moderation(u.sub, q.limit);
|
||||||
|
}
|
||||||
|
@UseGuards(StateChangingOriginGuard) @Post("moderation/:id/resolve") moderate(
|
||||||
|
@CurrentUser() u: AccessClaims,
|
||||||
|
@Param("id", new ParseUUIDPipe()) id: string,
|
||||||
|
@Body() d: ModerateDto,
|
||||||
|
) {
|
||||||
|
return this.admin.moderate(u.sub, id, d.decision, d.reason);
|
||||||
|
}
|
||||||
|
@UseGuards(StateChangingOriginGuard)
|
||||||
|
@Post("accounts/:publicId/sanctions")
|
||||||
|
sanction(
|
||||||
|
@CurrentUser() u: AccessClaims,
|
||||||
|
@Param("publicId", new ParseUUIDPipe()) id: string,
|
||||||
|
@Body() d: SanctionDto,
|
||||||
|
) {
|
||||||
|
return this.admin.sanctions(u.sub, id, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Reflect.defineMetadata(
|
||||||
|
"design:paramtypes",
|
||||||
|
[Object, AdminQueryDto],
|
||||||
|
AdminController.prototype,
|
||||||
|
"reports",
|
||||||
|
);
|
||||||
|
Reflect.defineMetadata(
|
||||||
|
"design:paramtypes",
|
||||||
|
[Object, String, ResolveReportDto],
|
||||||
|
AdminController.prototype,
|
||||||
|
"resolve",
|
||||||
|
);
|
||||||
|
Reflect.defineMetadata(
|
||||||
|
"design:paramtypes",
|
||||||
|
[Object, AdminQueryDto],
|
||||||
|
AdminController.prototype,
|
||||||
|
"moderation",
|
||||||
|
);
|
||||||
|
Reflect.defineMetadata(
|
||||||
|
"design:paramtypes",
|
||||||
|
[Object, String, ModerateDto],
|
||||||
|
AdminController.prototype,
|
||||||
|
"moderate",
|
||||||
|
);
|
||||||
|
Reflect.defineMetadata(
|
||||||
|
"design:paramtypes",
|
||||||
|
[Object, String, SanctionDto],
|
||||||
|
AdminController.prototype,
|
||||||
|
"sanction",
|
||||||
|
);
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
HttpStatus,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ErrorCode } from "@drift/contracts";
|
||||||
|
import type { Request } from "express";
|
||||||
|
import { PrismaService } from "../database/prisma.service.js";
|
||||||
|
import { DomainException } from "../common/domain.exception.js";
|
||||||
|
import type { AuthenticatedRequest } from "../auth/current-user.decorator.js";
|
||||||
|
@Injectable()
|
||||||
|
export class AdminGuard implements CanActivate {
|
||||||
|
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||||
|
async canActivate(ctx: ExecutionContext) {
|
||||||
|
const req = ctx.switchToHttp().getRequest<Request & AuthenticatedRequest>();
|
||||||
|
if (!req.user)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.AUTH_UNAUTHORIZED,
|
||||||
|
"Unauthorized",
|
||||||
|
HttpStatus.UNAUTHORIZED,
|
||||||
|
);
|
||||||
|
const account = await this.prisma.account.findUnique({
|
||||||
|
where: { id: req.user.sub },
|
||||||
|
select: { role: true },
|
||||||
|
});
|
||||||
|
if (account?.role !== "ADMIN")
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.CONVERSATION_FORBIDDEN,
|
||||||
|
"Forbidden",
|
||||||
|
HttpStatus.FORBIDDEN,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { AuthModule } from "../auth/auth.module.js";
|
||||||
|
import { DatabaseModule } from "../database/database.module.js";
|
||||||
|
import { StateChangingOriginGuard } from "../conversation/state-changing-origin.guard.js";
|
||||||
|
import { SafetyModule } from "../safety/safety.module.js";
|
||||||
|
import { AdminController } from "./admin.controller.js";
|
||||||
|
import { AdminGuard } from "./admin.guard.js";
|
||||||
|
import { AdminService } from "./admin.service.js";
|
||||||
|
import { NotificationModule } from "../notification/notification.module.js";
|
||||||
|
@Module({
|
||||||
|
imports: [DatabaseModule, AuthModule, SafetyModule, NotificationModule],
|
||||||
|
controllers: [AdminController],
|
||||||
|
providers: [AdminService, AdminGuard, StateChangingOriginGuard],
|
||||||
|
})
|
||||||
|
export class AdminModule {}
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { HttpStatus, Inject, Injectable } from "@nestjs/common";
|
||||||
|
import { ErrorCode } from "@drift/contracts";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { DomainException } from "../common/domain.exception.js";
|
||||||
|
import { PrismaService } from "../database/prisma.service.js";
|
||||||
|
import { SafetyLockService } from "../safety/safety-lock.service.js";
|
||||||
|
import type { ResolveReportDto, SanctionDto } from "./dto.js";
|
||||||
|
import { NotificationService } from "../notification/notification.service.js";
|
||||||
|
@Injectable()
|
||||||
|
export class AdminService {
|
||||||
|
constructor(
|
||||||
|
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||||
|
@Inject(SafetyLockService) private readonly locks: SafetyLockService,
|
||||||
|
@Inject(NotificationService)
|
||||||
|
private readonly notifications: NotificationService,
|
||||||
|
) {}
|
||||||
|
async reports(
|
||||||
|
actorId: string,
|
||||||
|
status: "PENDING" | "REVIEWING" | "RESOLVED" | "DISMISSED" | undefined,
|
||||||
|
limit: number,
|
||||||
|
) {
|
||||||
|
const rows = await this.prisma.report.findMany({
|
||||||
|
where: status ? { status } : {},
|
||||||
|
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
await this.prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
actorId,
|
||||||
|
action: "REPORTS_QUERIED",
|
||||||
|
entityType: "REPORT",
|
||||||
|
metadata: { status: status ?? null, limit, resultCount: rows.length },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
items: rows.map((x) => ({
|
||||||
|
id: x.id,
|
||||||
|
status: x.status,
|
||||||
|
reason: x.reason,
|
||||||
|
details: x.details,
|
||||||
|
targetSnapshot: x.targetSnapshot,
|
||||||
|
resolution: x.resolution,
|
||||||
|
createdAt: x.createdAt,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async resolve(actorId: string, id: string, dto: ResolveReportDto) {
|
||||||
|
if (dto.decision === "DISMISSED" && dto.sanction)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.VALIDATION_ERROR,
|
||||||
|
"Invalid sanction",
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
return this.prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
const report = await tx.report.findUnique({ where: { id } });
|
||||||
|
if (!report)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.NOT_FOUND,
|
||||||
|
"Not found",
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
);
|
||||||
|
const accountId =
|
||||||
|
report.reportedAccountId ??
|
||||||
|
(report.messageId
|
||||||
|
? (
|
||||||
|
await tx.message.findUnique({
|
||||||
|
where: { id: report.messageId },
|
||||||
|
select: { senderId: true },
|
||||||
|
})
|
||||||
|
)?.senderId
|
||||||
|
: report.bottleId
|
||||||
|
? (
|
||||||
|
await tx.bottle.findUnique({
|
||||||
|
where: { id: report.bottleId },
|
||||||
|
select: { authorId: true },
|
||||||
|
})
|
||||||
|
)?.authorId
|
||||||
|
: report.conversationId
|
||||||
|
? (
|
||||||
|
await tx.anonymousProfile.findUnique({
|
||||||
|
where: {
|
||||||
|
publicId:
|
||||||
|
(
|
||||||
|
report.targetSnapshot as {
|
||||||
|
subjectPublicId?: string;
|
||||||
|
}
|
||||||
|
).subjectPublicId ??
|
||||||
|
"00000000-0000-0000-0000-000000000000",
|
||||||
|
},
|
||||||
|
select: { accountId: true },
|
||||||
|
})
|
||||||
|
)?.accountId
|
||||||
|
: undefined);
|
||||||
|
await this.locks.lockAccounts(tx, [
|
||||||
|
actorId,
|
||||||
|
...(accountId ? [accountId] : []),
|
||||||
|
]);
|
||||||
|
const changed = await tx.report.updateMany({
|
||||||
|
where: { id, status: { in: ["PENDING", "REVIEWING"] } },
|
||||||
|
data: {
|
||||||
|
status: dto.decision === "UPHELD" ? "RESOLVED" : "DISMISSED",
|
||||||
|
resolution: dto.resolution,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (changed.count !== 1)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.IDEMPOTENCY_CONFLICT,
|
||||||
|
"Report already resolved",
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
);
|
||||||
|
const task = await tx.moderationTask.update({
|
||||||
|
where: {
|
||||||
|
targetType_targetId: { targetType: "REPORT", targetId: id },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: "COMPLETED",
|
||||||
|
decision: dto.decision,
|
||||||
|
result: { decision: dto.decision },
|
||||||
|
assignedToId: actorId,
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (dto.decision === "UPHELD" && dto.sanction && accountId)
|
||||||
|
await this.issue(tx, actorId, accountId, dto.sanction, task.id);
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: {
|
||||||
|
actorId,
|
||||||
|
action: "REPORT_RESOLVED",
|
||||||
|
entityType: "REPORT",
|
||||||
|
entityId: id,
|
||||||
|
metadata: {
|
||||||
|
decision: dto.decision,
|
||||||
|
sanctionType: dto.sanction?.type,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.notifications.createInApp(
|
||||||
|
tx,
|
||||||
|
report.reporterId,
|
||||||
|
`report:${id}:reporter`,
|
||||||
|
"REPORT_RESOLVED",
|
||||||
|
{ reportId: id, decision: dto.decision },
|
||||||
|
);
|
||||||
|
if (dto.decision === "UPHELD" && dto.sanction && accountId)
|
||||||
|
await this.notifications.createInApp(
|
||||||
|
tx,
|
||||||
|
accountId,
|
||||||
|
`report:${id}:target`,
|
||||||
|
"ACCOUNT_ACTION",
|
||||||
|
{ type: "ACCOUNT_ACTION" },
|
||||||
|
);
|
||||||
|
return tx.report.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true, status: true, resolution: true },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async sanctions(actorId: string, publicId: string, dto: SanctionDto) {
|
||||||
|
const profile = await this.prisma.anonymousProfile.findUnique({
|
||||||
|
where: { publicId },
|
||||||
|
});
|
||||||
|
if (!profile)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.NOT_FOUND,
|
||||||
|
"Not found",
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
);
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
await this.locks.lockAccounts(tx, [actorId, profile.accountId]);
|
||||||
|
const sanction = await this.issue(tx, actorId, profile.accountId, dto);
|
||||||
|
await this.notifications.createInApp(
|
||||||
|
tx,
|
||||||
|
profile.accountId,
|
||||||
|
`sanction:${sanction.id}:account-action`,
|
||||||
|
"ACCOUNT_ACTION",
|
||||||
|
{ type: "ACCOUNT_ACTION" },
|
||||||
|
);
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: {
|
||||||
|
actorId,
|
||||||
|
action: "SANCTION_ISSUED",
|
||||||
|
entityType: "ACCOUNT",
|
||||||
|
entityId: profile.accountId,
|
||||||
|
metadata: { type: dto.type },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return sanction;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async moderation(actorId: string, limit: number) {
|
||||||
|
const items = await this.prisma.moderationTask.findMany({
|
||||||
|
where: {
|
||||||
|
status: "COMPLETED",
|
||||||
|
decision: "MANUAL_REVIEW",
|
||||||
|
targetType: { in: ["PROFILE", "BOTTLE"] },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
targetType: true,
|
||||||
|
targetId: true,
|
||||||
|
riskLabels: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
await this.prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
actorId,
|
||||||
|
action: "MODERATION_QUERIED",
|
||||||
|
entityType: "MODERATION_TASK",
|
||||||
|
metadata: { limit, resultCount: items.length },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { items };
|
||||||
|
}
|
||||||
|
async moderate(
|
||||||
|
actorId: string,
|
||||||
|
id: string,
|
||||||
|
decision: "APPROVED" | "REJECTED",
|
||||||
|
reason: string,
|
||||||
|
) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
// Only the worker's terminal MANUAL_REVIEW result may be overridden. The
|
||||||
|
// outbox must already be published, so no leased worker can later write
|
||||||
|
// a stale moderation decision over the administrator's decision.
|
||||||
|
const task = await tx.moderationTask.findUnique({ where: { id } });
|
||||||
|
if (
|
||||||
|
!task ||
|
||||||
|
task.status !== "COMPLETED" ||
|
||||||
|
task.decision !== "MANUAL_REVIEW" ||
|
||||||
|
(task.targetType !== "BOTTLE" && task.targetType !== "PROFILE")
|
||||||
|
)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.IDEMPOTENCY_CONFLICT,
|
||||||
|
"Task not awaiting manual review",
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
);
|
||||||
|
const event = await tx.outboxEvent.findFirst({
|
||||||
|
where: {
|
||||||
|
eventType:
|
||||||
|
task.targetType === "BOTTLE"
|
||||||
|
? "BOTTLE_MODERATION_REQUESTED"
|
||||||
|
: "PROFILE_MODERATION_REQUESTED",
|
||||||
|
aggregateId: task.targetId,
|
||||||
|
status: "PUBLISHED",
|
||||||
|
payload: { path: ["taskId"], equals: task.id },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!event)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.IDEMPOTENCY_CONFLICT,
|
||||||
|
"Moderation event not published",
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
);
|
||||||
|
if (task.targetType === "BOTTLE") {
|
||||||
|
const bottle = await tx.bottle.findUnique({
|
||||||
|
where: { id: task.targetId },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!bottle ||
|
||||||
|
bottle.reviewStatus !== "MANUAL_REVIEW" ||
|
||||||
|
bottle.poolStatus !== "CLOSED" ||
|
||||||
|
task.payloadHash !==
|
||||||
|
createHash("sha256").update(bottle.contentText).digest("hex")
|
||||||
|
)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.IDEMPOTENCY_CONFLICT,
|
||||||
|
"Bottle state changed",
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
);
|
||||||
|
const updated = await tx.bottle.updateMany({
|
||||||
|
where: {
|
||||||
|
id: bottle.id,
|
||||||
|
reviewStatus: "MANUAL_REVIEW",
|
||||||
|
poolStatus: "CLOSED",
|
||||||
|
contentText: bottle.contentText,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
reviewStatus: decision,
|
||||||
|
poolStatus: decision === "APPROVED" ? "IN_POOL" : "CLOSED",
|
||||||
|
approvedAt: decision === "APPROVED" ? new Date() : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (updated.count !== 1)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.IDEMPOTENCY_CONFLICT,
|
||||||
|
"Bottle state changed",
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const profile = await tx.anonymousProfile.findUnique({
|
||||||
|
where: { id: task.targetId },
|
||||||
|
});
|
||||||
|
const hash =
|
||||||
|
profile &&
|
||||||
|
createHash("sha256")
|
||||||
|
.update(
|
||||||
|
JSON.stringify([
|
||||||
|
profile.nickname,
|
||||||
|
profile.avatarColor,
|
||||||
|
profile.bio,
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.digest("hex");
|
||||||
|
const version = Number(
|
||||||
|
event.dedupeKey.slice(event.dedupeKey.lastIndexOf(":") + 1),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!profile ||
|
||||||
|
profile.reviewStatus !== "MANUAL_REVIEW" ||
|
||||||
|
profile.version !== version ||
|
||||||
|
hash !== task.payloadHash
|
||||||
|
)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.IDEMPOTENCY_CONFLICT,
|
||||||
|
"Profile state changed",
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
);
|
||||||
|
const updated = await tx.anonymousProfile.updateMany({
|
||||||
|
where: { id: profile.id, reviewStatus: "MANUAL_REVIEW", version },
|
||||||
|
data: { reviewStatus: decision },
|
||||||
|
});
|
||||||
|
if (updated.count !== 1)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.IDEMPOTENCY_CONFLICT,
|
||||||
|
"Profile state changed",
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const changed = await tx.moderationTask.updateMany({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
status: "COMPLETED",
|
||||||
|
decision: "MANUAL_REVIEW",
|
||||||
|
payloadHash: task.payloadHash,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
decision,
|
||||||
|
result: { decision },
|
||||||
|
assignedToId: actorId,
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (changed.count !== 1)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.IDEMPOTENCY_CONFLICT,
|
||||||
|
"Task already resolved",
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
);
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: {
|
||||||
|
actorId,
|
||||||
|
action: "MODERATION_RESOLVED",
|
||||||
|
entityType: task.targetType,
|
||||||
|
entityId: task.targetId,
|
||||||
|
metadata: {
|
||||||
|
decision,
|
||||||
|
reason,
|
||||||
|
previousDecision: "MANUAL_REVIEW",
|
||||||
|
eventId: event.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { id, status: "COMPLETED", decision };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
private async issue(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
actorId: string,
|
||||||
|
accountId: string,
|
||||||
|
dto: SanctionDto,
|
||||||
|
moderationTaskId?: string,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
dto.expiresAt &&
|
||||||
|
(new Date(dto.expiresAt).getTime() <= Date.now() ||
|
||||||
|
new Date(dto.expiresAt).getTime() >
|
||||||
|
Date.now() + 365 * 24 * 60 * 60 * 1000)
|
||||||
|
)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.VALIDATION_ERROR,
|
||||||
|
"Invalid sanction expiry",
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
const sanction = await tx.sanction.create({
|
||||||
|
data: {
|
||||||
|
accountId,
|
||||||
|
issuedById: actorId,
|
||||||
|
moderationTaskId: moderationTaskId ?? null,
|
||||||
|
type: dto.type,
|
||||||
|
reason: dto.reason,
|
||||||
|
expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (dto.type === "SUSPENSION" || dto.type === "BAN") {
|
||||||
|
await tx.account.update({
|
||||||
|
where: { id: accountId },
|
||||||
|
data: { tokenVersion: { increment: 1 } },
|
||||||
|
});
|
||||||
|
await tx.session.updateMany({
|
||||||
|
where: { accountId, revokedAt: null },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return sanction;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Transform, Type } from "class-transformer";
|
||||||
|
import {
|
||||||
|
IsDateString,
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
ValidateNested,
|
||||||
|
} from "class-validator";
|
||||||
|
const trim = ({ value }: { value: unknown }) =>
|
||||||
|
typeof value === "string" ? value.trim() : value;
|
||||||
|
export class SanctionDto {
|
||||||
|
@IsEnum(["WARNING", "MUTE", "SUSPENSION", "BAN"]) type!:
|
||||||
|
"WARNING" | "MUTE" | "SUSPENSION" | "BAN";
|
||||||
|
@Transform(trim) @IsString() @MinLength(1) @MaxLength(500) reason!: string;
|
||||||
|
@IsOptional() @IsDateString() expiresAt?: string;
|
||||||
|
}
|
||||||
|
export class ResolveReportDto {
|
||||||
|
@IsEnum(["UPHELD", "DISMISSED"]) decision!: "UPHELD" | "DISMISSED";
|
||||||
|
@Transform(trim)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(1000)
|
||||||
|
resolution!: string;
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => SanctionDto)
|
||||||
|
sanction?: SanctionDto;
|
||||||
|
}
|
||||||
|
export class AdminQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(["PENDING", "REVIEWING", "RESOLVED", "DISMISSED"])
|
||||||
|
status?: "PENDING" | "REVIEWING" | "RESOLVED" | "DISMISSED";
|
||||||
|
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) limit = 20;
|
||||||
|
}
|
||||||
|
export class ModerateDto {
|
||||||
|
@IsEnum(["APPROVED", "REJECTED"]) decision!: "APPROVED" | "REJECTED";
|
||||||
|
@Transform(trim) @IsString() @MinLength(1) @MaxLength(500) reason!: string;
|
||||||
|
}
|
||||||
@@ -4,8 +4,25 @@ import { HealthModule } from "./health/health.module.js";
|
|||||||
import { AuthModule } from "./auth/auth.module.js";
|
import { AuthModule } from "./auth/auth.module.js";
|
||||||
import { ProfileModule } from "./profile/profile.module.js";
|
import { ProfileModule } from "./profile/profile.module.js";
|
||||||
import { BottleModule } from "./bottle/bottle.module.js";
|
import { BottleModule } from "./bottle/bottle.module.js";
|
||||||
|
import { MatchModule } from "./match/match.module.js";
|
||||||
|
import { ConversationModule } from "./conversation/conversation.module.js";
|
||||||
|
import { SafetyModule } from "./safety/safety.module.js";
|
||||||
|
import { NotificationModule } from "./notification/notification.module.js";
|
||||||
|
import { AdminModule } from "./admin/admin.module.js";
|
||||||
|
|
||||||
@Module({ imports: [HealthModule, AuthModule, ProfileModule, BottleModule] })
|
@Module({
|
||||||
|
imports: [
|
||||||
|
HealthModule,
|
||||||
|
AuthModule,
|
||||||
|
ProfileModule,
|
||||||
|
BottleModule,
|
||||||
|
MatchModule,
|
||||||
|
ConversationModule,
|
||||||
|
SafetyModule,
|
||||||
|
NotificationModule,
|
||||||
|
AdminModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
export class AppModule implements NestModule {
|
export class AppModule implements NestModule {
|
||||||
configure(consumer: MiddlewareConsumer): void {
|
configure(consumer: MiddlewareConsumer): void {
|
||||||
consumer.apply(RequestIdMiddleware).forRoutes("{*path}");
|
consumer.apply(RequestIdMiddleware).forRoutes("{*path}");
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
codeHmac,
|
codeHmac,
|
||||||
|
decryptLeaseToken,
|
||||||
demoSmsCodeEnabled,
|
demoSmsCodeEnabled,
|
||||||
|
encryptLeaseToken,
|
||||||
phoneHmac,
|
phoneHmac,
|
||||||
resetAuthEnvironmentForTests,
|
resetAuthEnvironmentForTests,
|
||||||
validateAuthEnvironment,
|
validateAuthEnvironment,
|
||||||
@@ -13,6 +15,8 @@ const valid = {
|
|||||||
PHONE_HMAC_KEY: "phone-hmac-key-that-is-at-least-32-bytes",
|
PHONE_HMAC_KEY: "phone-hmac-key-that-is-at-least-32-bytes",
|
||||||
VERIFICATION_CODE_HMAC_KEY: "code-hmac-key-that-is-at-least-32-bytes!",
|
VERIFICATION_CODE_HMAC_KEY: "code-hmac-key-that-is-at-least-32-bytes!",
|
||||||
REFRESH_TOKEN_HMAC_KEY: "refresh-key-that-is-at-least-thirty-two-bytes",
|
REFRESH_TOKEN_HMAC_KEY: "refresh-key-that-is-at-least-thirty-two-bytes",
|
||||||
|
LEASE_TOKEN_HMAC_KEY: "lease-hmac-key-that-is-at-least-thirty-two-bytes",
|
||||||
|
LEASE_TOKEN_ENCRYPTION_KEY: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
|
||||||
JWT_SECRET: "jwt-secret-that-is-at-least-thirty-two-bytes",
|
JWT_SECRET: "jwt-secret-that-is-at-least-thirty-two-bytes",
|
||||||
WEB_ORIGIN: "http://localhost:3000",
|
WEB_ORIGIN: "http://localhost:3000",
|
||||||
};
|
};
|
||||||
@@ -46,6 +50,33 @@ describe("auth environment", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects lease keys reused from any authentication secret", () => {
|
||||||
|
process.env.LEASE_TOKEN_HMAC_KEY = process.env.REFRESH_TOKEN_HMAC_KEY;
|
||||||
|
expect(() => validateAuthEnvironment()).toThrow(
|
||||||
|
/secrets must be independent/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("binds canonical lease token ciphertext to its lease id", () => {
|
||||||
|
validateAuthEnvironment();
|
||||||
|
const leaseId = "00000000-0000-4000-8000-000000000001";
|
||||||
|
const token = "x".repeat(43);
|
||||||
|
const ciphertext = encryptLeaseToken(token, leaseId);
|
||||||
|
expect(ciphertext).toHaveLength(71);
|
||||||
|
expect(decryptLeaseToken(ciphertext, leaseId)).toBe(token);
|
||||||
|
expect(() =>
|
||||||
|
decryptLeaseToken(ciphertext, "00000000-0000-4000-8000-000000000002"),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects encrypted lease plaintext with a non-canonical format", () => {
|
||||||
|
validateAuthEnvironment();
|
||||||
|
const leaseId = "00000000-0000-4000-8000-000000000001";
|
||||||
|
expect(() =>
|
||||||
|
decryptLeaseToken(encryptLeaseToken("short", leaseId), leaseId),
|
||||||
|
).toThrow(/Invalid lease token ciphertext/);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses a verification-code key independent from the phone key", () => {
|
it("uses a verification-code key independent from the phone key", () => {
|
||||||
validateAuthEnvironment();
|
validateAuthEnvironment();
|
||||||
const phoneDigest = phoneHmac("+8613800138000");
|
const phoneDigest = phoneHmac("+8613800138000");
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import {
|
|||||||
function required(name: string, min = 32): Buffer {
|
function required(name: string, min = 32): Buffer {
|
||||||
const value = process.env[name];
|
const value = process.env[name];
|
||||||
if (!value) throw new Error(`${name} is required`);
|
if (!value) throw new Error(`${name} is required`);
|
||||||
const decoded =
|
const decoded = name.endsWith("_ENCRYPTION_KEY")
|
||||||
name === "PHONE_ENCRYPTION_KEY"
|
|
||||||
? Buffer.from(value, "base64")
|
? Buffer.from(value, "base64")
|
||||||
: Buffer.from(value);
|
: Buffer.from(value);
|
||||||
if (decoded.length < min)
|
if (decoded.length < min)
|
||||||
@@ -22,6 +21,8 @@ const REQUIRED_KEYS = [
|
|||||||
"PHONE_HMAC_KEY",
|
"PHONE_HMAC_KEY",
|
||||||
"VERIFICATION_CODE_HMAC_KEY",
|
"VERIFICATION_CODE_HMAC_KEY",
|
||||||
"REFRESH_TOKEN_HMAC_KEY",
|
"REFRESH_TOKEN_HMAC_KEY",
|
||||||
|
"LEASE_TOKEN_HMAC_KEY",
|
||||||
|
"LEASE_TOKEN_ENCRYPTION_KEY",
|
||||||
"JWT_SECRET",
|
"JWT_SECRET",
|
||||||
] as const;
|
] as const;
|
||||||
type AuthSecrets = Record<(typeof REQUIRED_KEYS)[number], Buffer>;
|
type AuthSecrets = Record<(typeof REQUIRED_KEYS)[number], Buffer>;
|
||||||
@@ -56,8 +57,13 @@ export function validateAuthEnvironment(): void {
|
|||||||
const loaded = Object.fromEntries(
|
const loaded = Object.fromEntries(
|
||||||
REQUIRED_KEYS.map((name) => [name, required(name)]),
|
REQUIRED_KEYS.map((name) => [name, required(name)]),
|
||||||
) as unknown as AuthSecrets;
|
) as unknown as AuthSecrets;
|
||||||
if (loaded.PHONE_ENCRYPTION_KEY.length !== 32)
|
for (const name of [
|
||||||
throw new Error("PHONE_ENCRYPTION_KEY must decode to exactly 32 bytes");
|
"PHONE_ENCRYPTION_KEY",
|
||||||
|
"LEASE_TOKEN_ENCRYPTION_KEY",
|
||||||
|
] as const) {
|
||||||
|
if (loaded[name].length !== 32)
|
||||||
|
throw new Error(`${name} must decode to exactly 32 bytes`);
|
||||||
|
}
|
||||||
const fingerprints = REQUIRED_KEYS.map((name) =>
|
const fingerprints = REQUIRED_KEYS.map((name) =>
|
||||||
loaded[name].toString("hex"),
|
loaded[name].toString("hex"),
|
||||||
);
|
);
|
||||||
@@ -103,6 +109,43 @@ export const refreshHmac = (token: string): string =>
|
|||||||
createHmac("sha256", authSecrets().REFRESH_TOKEN_HMAC_KEY)
|
createHmac("sha256", authSecrets().REFRESH_TOKEN_HMAC_KEY)
|
||||||
.update(token)
|
.update(token)
|
||||||
.digest("hex");
|
.digest("hex");
|
||||||
|
export const leaseHmac = (token: string): string =>
|
||||||
|
createHmac("sha256", authSecrets().LEASE_TOKEN_HMAC_KEY)
|
||||||
|
.update(token)
|
||||||
|
.digest("hex");
|
||||||
|
export function encryptLeaseToken(
|
||||||
|
token: string,
|
||||||
|
leaseId: string,
|
||||||
|
): Uint8Array<ArrayBuffer> {
|
||||||
|
const nonce = randomBytes(12);
|
||||||
|
const cipher = createCipheriv(
|
||||||
|
"aes-256-gcm",
|
||||||
|
authSecrets().LEASE_TOKEN_ENCRYPTION_KEY,
|
||||||
|
nonce,
|
||||||
|
);
|
||||||
|
cipher.setAAD(Buffer.from(leaseId));
|
||||||
|
const body = Buffer.concat([cipher.update(token, "utf8"), cipher.final()]);
|
||||||
|
const encrypted = Buffer.concat([nonce, cipher.getAuthTag(), body]);
|
||||||
|
return new Uint8Array(encrypted).slice();
|
||||||
|
}
|
||||||
|
export function decryptLeaseToken(value: Uint8Array, leaseId: string): string {
|
||||||
|
// nonce (12) + authentication tag (16) + canonical 32-byte token (43 base64url chars)
|
||||||
|
if (value.length !== 71) throw new Error("Invalid lease token ciphertext");
|
||||||
|
const decipher = createDecipheriv(
|
||||||
|
"aes-256-gcm",
|
||||||
|
authSecrets().LEASE_TOKEN_ENCRYPTION_KEY,
|
||||||
|
Buffer.from(value.subarray(0, 12)),
|
||||||
|
);
|
||||||
|
decipher.setAAD(Buffer.from(leaseId));
|
||||||
|
decipher.setAuthTag(Buffer.from(value.subarray(12, 28)));
|
||||||
|
const token = Buffer.concat([
|
||||||
|
decipher.update(Buffer.from(value.subarray(28))),
|
||||||
|
decipher.final(),
|
||||||
|
]).toString();
|
||||||
|
if (!/^[A-Za-z0-9_-]{43}$/.test(token))
|
||||||
|
throw new Error("Invalid lease token ciphertext");
|
||||||
|
return token;
|
||||||
|
}
|
||||||
export const safeEqual = (a: string, b: string): boolean => {
|
export const safeEqual = (a: string, b: string): boolean => {
|
||||||
const x = Buffer.from(a);
|
const x = Buffer.from(a);
|
||||||
const y = Buffer.from(b);
|
const y = Buffer.from(b);
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ process.env.PHONE_ENCRYPTION_KEY =
|
|||||||
process.env.PHONE_HMAC_KEY = "test-phone-hmac-key-with-at-least-32-bytes";
|
process.env.PHONE_HMAC_KEY = "test-phone-hmac-key-with-at-least-32-bytes";
|
||||||
process.env.VERIFICATION_CODE_HMAC_KEY =
|
process.env.VERIFICATION_CODE_HMAC_KEY =
|
||||||
"test-code-hmac-key-with-at-least-32-bytes";
|
"test-code-hmac-key-with-at-least-32-bytes";
|
||||||
|
process.env.LEASE_TOKEN_HMAC_KEY = "test-lease-hmac-key-with-at-least-32-bytes";
|
||||||
|
process.env.LEASE_TOKEN_ENCRYPTION_KEY =
|
||||||
|
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=";
|
||||||
process.env.JWT_SECRET = "test-jwt-secret-with-at-least-thirty-two-bytes";
|
process.env.JWT_SECRET = "test-jwt-secret-with-at-least-thirty-two-bytes";
|
||||||
process.env.REFRESH_TOKEN_HMAC_KEY =
|
process.env.REFRESH_TOKEN_HMAC_KEY =
|
||||||
"test-refresh-hmac-key-with-at-least-32-bytes";
|
"test-refresh-hmac-key-with-at-least-32-bytes";
|
||||||
@@ -777,6 +780,36 @@ describe("auth real PostgreSQL/Redis", () => {
|
|||||||
.expect(401);
|
.expect(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects login while a suspension is active and permits a fresh login after expiry", async () => {
|
||||||
|
const initial = await login();
|
||||||
|
expect(initial.status).toBe(201);
|
||||||
|
const account = await prisma.account.findFirstOrThrow();
|
||||||
|
const expiresAt = new Date(Date.now() + 60_000);
|
||||||
|
const sanction = await prisma.sanction.create({
|
||||||
|
data: {
|
||||||
|
accountId: account.id,
|
||||||
|
type: "SUSPENSION",
|
||||||
|
reason: "test",
|
||||||
|
startsAt: new Date(Date.now() - 1_000),
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const sent = await send();
|
||||||
|
const blocked = await request(app.getHttpServer())
|
||||||
|
.post("/api/v1/auth/sms/login")
|
||||||
|
.send({ phone, deviceId, code: sent.body.data.debugCode });
|
||||||
|
expect(blocked.status).toBe(401);
|
||||||
|
await prisma.sanction.update({
|
||||||
|
where: { id: sanction.id },
|
||||||
|
data: { expiresAt: new Date(Date.now() - 1_000) },
|
||||||
|
});
|
||||||
|
const recovered = await login();
|
||||||
|
expect(recovered.status).toBe(201);
|
||||||
|
expect(
|
||||||
|
await prisma.account.findUniqueOrThrow({ where: { id: account.id } }),
|
||||||
|
).toMatchObject({ status: "ACTIVE" });
|
||||||
|
});
|
||||||
|
|
||||||
it("recovers Auth Redis after a connection deadline and closes its sockets", async () => {
|
it("recovers Auth Redis after a connection deadline and closes its sockets", async () => {
|
||||||
const sockets = new Set<Socket>();
|
const sockets = new Set<Socket>();
|
||||||
const server = createServer((socket) => {
|
const server = createServer((socket) => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ErrorCode } from "@drift/contracts";
|
|||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
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 type { AccessClaims } from "./token.service.js";
|
||||||
import { TokenService } from "./token.service.js";
|
import { TokenService } from "./token.service.js";
|
||||||
import type { AuthenticatedRequest } from "./current-user.decorator.js";
|
import type { AuthenticatedRequest } from "./current-user.decorator.js";
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -17,11 +18,8 @@ export class AuthGuard implements CanActivate {
|
|||||||
@Inject(PrismaService) private readonly prisma: PrismaService,
|
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||||
@Inject(TokenService) private readonly tokens: TokenService,
|
@Inject(TokenService) private readonly tokens: TokenService,
|
||||||
) {}
|
) {}
|
||||||
async canActivate(ctx: ExecutionContext): Promise<boolean> {
|
async validateAccess(token: string): Promise<AccessClaims> {
|
||||||
const req = ctx.switchToHttp().getRequest<Request & AuthenticatedRequest>();
|
const claims = this.tokens.verifyAccess(token);
|
||||||
const auth = req.headers.authorization;
|
|
||||||
if (!auth?.startsWith("Bearer ")) throw this.denied();
|
|
||||||
const claims = this.tokens.verifyAccess(auth.slice(7));
|
|
||||||
const session = await this.prisma.session.findUnique({
|
const session = await this.prisma.session.findUnique({
|
||||||
where: { id: claims.session_id },
|
where: { id: claims.session_id },
|
||||||
include: { account: true },
|
include: { account: true },
|
||||||
@@ -37,7 +35,26 @@ export class AuthGuard implements CanActivate {
|
|||||||
session.account.tokenVersion !== claims.token_version
|
session.account.tokenVersion !== claims.token_version
|
||||||
)
|
)
|
||||||
throw this.denied();
|
throw this.denied();
|
||||||
req.user = claims;
|
const now = new Date();
|
||||||
|
const sanction = await this.prisma.sanction.findFirst({
|
||||||
|
where: {
|
||||||
|
accountId: claims.sub,
|
||||||
|
type: { in: ["SUSPENSION", "BAN"] },
|
||||||
|
revokedAt: null,
|
||||||
|
startsAt: { lte: now },
|
||||||
|
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (sanction) throw this.denied();
|
||||||
|
return claims;
|
||||||
|
}
|
||||||
|
|
||||||
|
async canActivate(ctx: ExecutionContext): Promise<boolean> {
|
||||||
|
const req = ctx.switchToHttp().getRequest<Request & AuthenticatedRequest>();
|
||||||
|
const auth = req.headers.authorization;
|
||||||
|
if (!auth?.startsWith("Bearer ")) throw this.denied();
|
||||||
|
req.user = await this.validateAccess(auth.slice(7));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
private denied() {
|
private denied() {
|
||||||
|
|||||||
@@ -148,6 +148,18 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
if (!account) account = await this.createAccount(phone, digest);
|
if (!account) account = await this.createAccount(phone, digest);
|
||||||
if (account.status !== "ACTIVE") throw this.invalid();
|
if (account.status !== "ACTIVE") throw this.invalid();
|
||||||
|
const now = new Date();
|
||||||
|
const sanction = await this.prisma.sanction.findFirst({
|
||||||
|
where: {
|
||||||
|
accountId: account.id,
|
||||||
|
type: { in: ["SUSPENSION", "BAN"] },
|
||||||
|
revokedAt: null,
|
||||||
|
startsAt: { lte: now },
|
||||||
|
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (sanction) throw this.invalid();
|
||||||
return this.createSession(account, deviceId);
|
return this.createSession(account, deviceId);
|
||||||
}
|
}
|
||||||
private async createAccount(phone: string, digest: string): Promise<Account> {
|
private async createAccount(phone: string, digest: string): Promise<Account> {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ process.env.VERIFICATION_CODE_HMAC_KEY =
|
|||||||
"token-code-hmac-key-with-at-least-32-bytes!";
|
"token-code-hmac-key-with-at-least-32-bytes!";
|
||||||
process.env.REFRESH_TOKEN_HMAC_KEY =
|
process.env.REFRESH_TOKEN_HMAC_KEY =
|
||||||
"token-refresh-hmac-key-with-at-least-32-bytes";
|
"token-refresh-hmac-key-with-at-least-32-bytes";
|
||||||
|
process.env.LEASE_TOKEN_HMAC_KEY = "test-lease-hmac-key-with-at-least-32-bytes";
|
||||||
|
process.env.LEASE_TOKEN_ENCRYPTION_KEY =
|
||||||
|
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=";
|
||||||
process.env.JWT_SECRET = "token-jwt-secret-with-at-least-thirty-two-bytes";
|
process.env.JWT_SECRET = "token-jwt-secret-with-at-least-thirty-two-bytes";
|
||||||
|
|
||||||
const secret = process.env.JWT_SECRET;
|
const secret = process.env.JWT_SECRET;
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ process.env.PHONE_ENCRYPTION_KEY =
|
|||||||
process.env.PHONE_HMAC_KEY = "test-phone-hmac-key-with-at-least-32-bytes";
|
process.env.PHONE_HMAC_KEY = "test-phone-hmac-key-with-at-least-32-bytes";
|
||||||
process.env.VERIFICATION_CODE_HMAC_KEY =
|
process.env.VERIFICATION_CODE_HMAC_KEY =
|
||||||
"test-code-hmac-key-with-at-least-32-bytes";
|
"test-code-hmac-key-with-at-least-32-bytes";
|
||||||
|
process.env.LEASE_TOKEN_HMAC_KEY = "test-lease-hmac-key-with-at-least-32-bytes";
|
||||||
|
process.env.LEASE_TOKEN_ENCRYPTION_KEY =
|
||||||
|
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=";
|
||||||
process.env.JWT_SECRET = "test-jwt-secret-with-at-least-thirty-two-bytes";
|
process.env.JWT_SECRET = "test-jwt-secret-with-at-least-thirty-two-bytes";
|
||||||
process.env.REFRESH_TOKEN_HMAC_KEY =
|
process.env.REFRESH_TOKEN_HMAC_KEY =
|
||||||
"test-refresh-hmac-key-with-at-least-32-bytes";
|
"test-refresh-hmac-key-with-at-least-32-bytes";
|
||||||
@@ -252,8 +255,8 @@ describe("bottles with real PostgreSQL", () => {
|
|||||||
expiresAt: new Date(Date.now() + 60_000),
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const denied = await create(randomUUID()).expect(403);
|
const denied = await create(randomUUID()).expect(401);
|
||||||
expect(denied.body.code).toBe("ACCOUNT_SANCTIONED");
|
expect(denied.body.code).toBe("AUTH_UNAUTHORIZED");
|
||||||
expect(await prisma.bottle.count()).toBe(0);
|
expect(await prisma.bottle.count()).toBe(0);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { AuthModule } from "../auth/auth.module.js";
|
|||||||
import { DatabaseModule } from "../database/database.module.js";
|
import { DatabaseModule } from "../database/database.module.js";
|
||||||
import { BottleController } from "./bottle.controller.js";
|
import { BottleController } from "./bottle.controller.js";
|
||||||
import { BottleService } from "./bottle.service.js";
|
import { BottleService } from "./bottle.service.js";
|
||||||
|
import { SafetyModule } from "../safety/safety.module.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, DatabaseModule],
|
imports: [AuthModule, DatabaseModule, SafetyModule],
|
||||||
controllers: [BottleController],
|
controllers: [BottleController],
|
||||||
providers: [BottleService],
|
providers: [BottleService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,20 +5,19 @@ import { Prisma } from "@prisma/client";
|
|||||||
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 { utc8UsageDate } from "./usage-date.js";
|
import { utc8UsageDate } from "./usage-date.js";
|
||||||
|
import { SafetyLockService } from "../safety/safety-lock.service.js";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BottleService {
|
export class BottleService {
|
||||||
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||||
|
@Inject(SafetyLockService) private readonly locks: SafetyLockService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async create(authorId: string, requestId: string, contentText: string) {
|
async create(authorId: string, requestId: string, contentText: string) {
|
||||||
const existing = await this.prisma.bottle.findUnique({
|
|
||||||
where: {
|
|
||||||
authorId_clientRequestId: { authorId, clientRequestId: requestId },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (existing) return this.resolveIdempotent(existing, contentText);
|
|
||||||
try {
|
try {
|
||||||
return await this.prisma.$transaction(async (tx) => {
|
return await this.prisma.$transaction(async (tx) => {
|
||||||
|
await this.locks.lockAccounts(tx, [authorId]);
|
||||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`${authorId}:${requestId}`}, 0))`;
|
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`${authorId}:${requestId}`}, 0))`;
|
||||||
const duplicate = await tx.bottle.findUnique({
|
const duplicate = await tx.bottle.findUnique({
|
||||||
where: {
|
where: {
|
||||||
@@ -27,6 +26,10 @@ export class BottleService {
|
|||||||
});
|
});
|
||||||
if (duplicate) return this.resolveIdempotent(duplicate, contentText);
|
if (duplicate) return this.resolveIdempotent(duplicate, contentText);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const account = await tx.account.findUnique({
|
||||||
|
where: { id: authorId },
|
||||||
|
select: { status: true },
|
||||||
|
});
|
||||||
const sanctioned = await tx.sanction.findFirst({
|
const sanctioned = await tx.sanction.findFirst({
|
||||||
where: {
|
where: {
|
||||||
accountId: authorId,
|
accountId: authorId,
|
||||||
@@ -37,7 +40,7 @@ export class BottleService {
|
|||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (sanctioned)
|
if (account?.status !== "ACTIVE" || sanctioned)
|
||||||
throw new DomainException(
|
throw new DomainException(
|
||||||
ErrorCode.ACCOUNT_SANCTIONED,
|
ErrorCode.ACCOUNT_SANCTIONED,
|
||||||
"Account sanctioned",
|
"Account sanctioned",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Inject, Injectable } from "@nestjs/common";
|
||||||
|
import { RedisService } from "../redis/redis.service.js";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ChatRateLimiter {
|
||||||
|
constructor(@Inject(RedisService) private readonly redis: RedisService) {}
|
||||||
|
async consume(accountId: string, sessionId: string): Promise<boolean> {
|
||||||
|
const prefix = process.env.REDIS_KEY_PREFIX ?? "drift:auth:";
|
||||||
|
const max = Number(process.env.CHAT_MESSAGE_RATE_LIMIT ?? 30);
|
||||||
|
const ttl = Number(process.env.CHAT_MESSAGE_RATE_WINDOW_SECONDS ?? 60);
|
||||||
|
const keys = [
|
||||||
|
`${prefix}chat:account:${accountId}`,
|
||||||
|
`${prefix}chat:session:${sessionId}`,
|
||||||
|
];
|
||||||
|
const result = await this.redis.executeWithDeadline((client) =>
|
||||||
|
client.eval(
|
||||||
|
`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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { HttpStatus, Inject } from "@nestjs/common";
|
||||||
|
import { plainToInstance } from "class-transformer";
|
||||||
|
import { validate } from "class-validator";
|
||||||
|
import { ErrorCode } from "@drift/contracts";
|
||||||
|
import {
|
||||||
|
ConnectedSocket,
|
||||||
|
MessageBody,
|
||||||
|
type OnGatewayConnection,
|
||||||
|
type OnGatewayInit,
|
||||||
|
SubscribeMessage,
|
||||||
|
WebSocketGateway,
|
||||||
|
WebSocketServer,
|
||||||
|
} from "@nestjs/websockets";
|
||||||
|
import type { IncomingMessage } from "node:http";
|
||||||
|
import type { Server, Socket } from "socket.io";
|
||||||
|
import { webOrigin } from "../auth/auth.config.js";
|
||||||
|
import { AuthGuard } from "../auth/auth.guard.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,
|
||||||
|
type MessageResult,
|
||||||
|
} from "./conversation.service.js";
|
||||||
|
import { SendMessageDto, SocketReadDto } from "./dto.js";
|
||||||
|
import type { ChatPublisher } from "./outbox-message-relay.js";
|
||||||
|
|
||||||
|
interface SocketData {
|
||||||
|
token?: string;
|
||||||
|
user?: AccessClaims;
|
||||||
|
}
|
||||||
|
interface ClientToServerEvents {
|
||||||
|
"message:send": (dto: SendMessageDto, ack?: Ack) => void;
|
||||||
|
"conversation:read": (dto: SocketReadDto, ack?: Ack) => void;
|
||||||
|
}
|
||||||
|
interface ServerToClientEvents {
|
||||||
|
"message:ack": (value: SocketResponse) => void;
|
||||||
|
"message:new": (value: MessageResult["message"]) => void;
|
||||||
|
}
|
||||||
|
type ChatServer = Server<
|
||||||
|
ClientToServerEvents,
|
||||||
|
ServerToClientEvents,
|
||||||
|
Record<string, never>,
|
||||||
|
SocketData
|
||||||
|
>;
|
||||||
|
type ChatSocket = Socket<
|
||||||
|
ClientToServerEvents,
|
||||||
|
ServerToClientEvents,
|
||||||
|
Record<string, never>,
|
||||||
|
SocketData
|
||||||
|
>;
|
||||||
|
type SocketResponse =
|
||||||
|
| { ok: true; data: unknown }
|
||||||
|
| { ok: false; error: { code: ErrorCode; message: string } };
|
||||||
|
type Ack = (value: SocketResponse) => void;
|
||||||
|
type HandshakeAuth = { token?: unknown; authorization?: unknown };
|
||||||
|
|
||||||
|
function allowOrigin(
|
||||||
|
request: IncomingMessage,
|
||||||
|
callback: (error: string | null | undefined, success: boolean) => void,
|
||||||
|
): void {
|
||||||
|
const origin = request.headers.origin;
|
||||||
|
callback(null, origin === undefined || origin === webOrigin());
|
||||||
|
}
|
||||||
|
|
||||||
|
@WebSocketGateway({
|
||||||
|
namespace: "/chat",
|
||||||
|
allowRequest: allowOrigin,
|
||||||
|
cors: {
|
||||||
|
origin(origin, callback) {
|
||||||
|
callback(null, origin === undefined || origin === webOrigin());
|
||||||
|
},
|
||||||
|
credentials: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class ChatGateway
|
||||||
|
implements OnGatewayInit, OnGatewayConnection, ChatPublisher
|
||||||
|
{
|
||||||
|
@WebSocketServer() server!: ChatServer;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(AuthGuard) private readonly auth: AuthGuard,
|
||||||
|
@Inject(ConversationService)
|
||||||
|
private readonly conversations: ConversationService,
|
||||||
|
@Inject(ChatRateLimiter) private readonly limiter: ChatRateLimiter,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
afterInit(server: ChatServer): void {
|
||||||
|
server.use((client, next) => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const auth = client.handshake.auth as unknown as HandshakeAuth;
|
||||||
|
const raw = auth.token ?? auth.authorization;
|
||||||
|
const token =
|
||||||
|
typeof raw === "string" && raw.startsWith("Bearer ")
|
||||||
|
? raw.slice(7)
|
||||||
|
: raw;
|
||||||
|
if (typeof token !== "string") throw new Error("Unauthorized");
|
||||||
|
const user = await this.auth.validateAccess(token);
|
||||||
|
client.data.token = token;
|
||||||
|
client.data.user = user;
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
next(new Error("Unauthorized"));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleConnection(client: ChatSocket): Promise<void> {
|
||||||
|
// Single-instance MVP: require a Redis adapter before horizontal scaling.
|
||||||
|
const user = client.data.user;
|
||||||
|
if (!user) {
|
||||||
|
client.disconnect(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await client.join(`account:${user.sub}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage("message:send")
|
||||||
|
async send(
|
||||||
|
@ConnectedSocket() client: ChatSocket,
|
||||||
|
@MessageBody() dto: SendMessageDto,
|
||||||
|
ack?: Ack,
|
||||||
|
): Promise<SocketResponse> {
|
||||||
|
try {
|
||||||
|
dto = await this.validatePayload(SendMessageDto, dto);
|
||||||
|
const user = await this.auth.validateAccess(client.data.token ?? "");
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const result = await this.conversations.send(
|
||||||
|
user.sub,
|
||||||
|
dto.conversationId,
|
||||||
|
dto.clientMsgId,
|
||||||
|
dto.text,
|
||||||
|
);
|
||||||
|
const response: SocketResponse = {
|
||||||
|
ok: true,
|
||||||
|
data: {
|
||||||
|
message: result.message,
|
||||||
|
deduplicated: result.deduplicated,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ack?.(response);
|
||||||
|
client.emit("message:ack", response);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
const response = this.error(error);
|
||||||
|
ack?.(response);
|
||||||
|
client.emit("message:ack", response);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage("conversation:read")
|
||||||
|
async read(
|
||||||
|
@ConnectedSocket() client: ChatSocket,
|
||||||
|
@MessageBody() body: SocketReadDto,
|
||||||
|
ack?: Ack,
|
||||||
|
): Promise<SocketResponse> {
|
||||||
|
try {
|
||||||
|
body = await this.validatePayload(SocketReadDto, body);
|
||||||
|
const user = await this.auth.validateAccess(client.data.token ?? "");
|
||||||
|
const data = await this.conversations.read(
|
||||||
|
user.sub,
|
||||||
|
body.conversationId,
|
||||||
|
body.seq,
|
||||||
|
);
|
||||||
|
const response: SocketResponse = { ok: true, data };
|
||||||
|
ack?.(response);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
const response = this.error(error);
|
||||||
|
ack?.(response);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
ok: false,
|
||||||
|
error: { code: error.code, message: error.message },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: ErrorCode.INTERNAL_ERROR,
|
||||||
|
message: "Internal server error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async validatePayload<T extends object>(
|
||||||
|
type: new () => T,
|
||||||
|
value: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
const dto = plainToInstance(type, value);
|
||||||
|
const errors = await validate(dto, {
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
});
|
||||||
|
if (errors.length) {
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.VALIDATION_ERROR,
|
||||||
|
"Validation failed",
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Reflect.defineMetadata(
|
||||||
|
"design:paramtypes",
|
||||||
|
[Object, SendMessageDto, Function],
|
||||||
|
ChatGateway.prototype,
|
||||||
|
"send",
|
||||||
|
);
|
||||||
|
Reflect.defineMetadata(
|
||||||
|
"design:paramtypes",
|
||||||
|
[Object, SocketReadDto, Function],
|
||||||
|
ChatGateway.prototype,
|
||||||
|
"read",
|
||||||
|
);
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpStatus,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
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,
|
||||||
|
ReadConversationDto,
|
||||||
|
ReplyDto,
|
||||||
|
SendMessageDto,
|
||||||
|
} from "./dto.js";
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class ConversationController {
|
||||||
|
constructor(
|
||||||
|
@Inject(ConversationService)
|
||||||
|
private readonly conversations: ConversationService,
|
||||||
|
@Inject(ChatRateLimiter) private readonly limiter: ChatRateLimiter,
|
||||||
|
) {}
|
||||||
|
@UseGuards(StateChangingOriginGuard)
|
||||||
|
@Post("bottles/:id/reply")
|
||||||
|
reply(
|
||||||
|
@CurrentUser() user: AccessClaims,
|
||||||
|
@Param("id", new ParseUUIDPipe()) id: string,
|
||||||
|
@Body() dto: ReplyDto,
|
||||||
|
) {
|
||||||
|
return this.conversations.reply(
|
||||||
|
user.sub,
|
||||||
|
id,
|
||||||
|
dto.leaseId,
|
||||||
|
dto.leaseToken,
|
||||||
|
dto.clientMsgId,
|
||||||
|
dto.text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@Get("conversations") list(
|
||||||
|
@CurrentUser() user: AccessClaims,
|
||||||
|
@Query() query: ConversationsQueryDto,
|
||||||
|
) {
|
||||||
|
return this.conversations.list(user.sub, query.cursor, query.limit);
|
||||||
|
}
|
||||||
|
@Get("conversations/:id") detail(
|
||||||
|
@CurrentUser() user: AccessClaims,
|
||||||
|
@Param("id", new ParseUUIDPipe()) id: string,
|
||||||
|
) {
|
||||||
|
return this.conversations.detail(user.sub, id);
|
||||||
|
}
|
||||||
|
@Get("conversations/:id/messages") messages(
|
||||||
|
@CurrentUser() user: AccessClaims,
|
||||||
|
@Param("id", new ParseUUIDPipe()) id: string,
|
||||||
|
@Query() query: MessagesQueryDto,
|
||||||
|
) {
|
||||||
|
return this.conversations.messages(
|
||||||
|
user.sub,
|
||||||
|
id,
|
||||||
|
query.afterSeq,
|
||||||
|
query.limit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@UseGuards(StateChangingOriginGuard)
|
||||||
|
@Post("conversations/:id/messages/prepare")
|
||||||
|
prepare(
|
||||||
|
@CurrentUser() user: AccessClaims,
|
||||||
|
@Param("id", new ParseUUIDPipe()) id: string,
|
||||||
|
@Body() dto: SendMessageDto,
|
||||||
|
) {
|
||||||
|
return this.consumeAndRun(user, () =>
|
||||||
|
this.conversations.send(user.sub, id, dto.clientMsgId, dto.text),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@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",
|
||||||
|
[Object, String, ReplyDto],
|
||||||
|
ConversationController.prototype,
|
||||||
|
"reply",
|
||||||
|
);
|
||||||
|
Reflect.defineMetadata(
|
||||||
|
"design:paramtypes",
|
||||||
|
[Object, ConversationsQueryDto],
|
||||||
|
ConversationController.prototype,
|
||||||
|
"list",
|
||||||
|
);
|
||||||
|
Reflect.defineMetadata(
|
||||||
|
"design:paramtypes",
|
||||||
|
[Object, String],
|
||||||
|
ConversationController.prototype,
|
||||||
|
"detail",
|
||||||
|
);
|
||||||
|
Reflect.defineMetadata(
|
||||||
|
"design:paramtypes",
|
||||||
|
[Object, String, MessagesQueryDto],
|
||||||
|
ConversationController.prototype,
|
||||||
|
"messages",
|
||||||
|
);
|
||||||
|
Reflect.defineMetadata(
|
||||||
|
"design:paramtypes",
|
||||||
|
[Object, String, SendMessageDto],
|
||||||
|
ConversationController.prototype,
|
||||||
|
"prepare",
|
||||||
|
);
|
||||||
|
Reflect.defineMetadata(
|
||||||
|
"design:paramtypes",
|
||||||
|
[Object, String, ReadConversationDto],
|
||||||
|
ConversationController.prototype,
|
||||||
|
"read",
|
||||||
|
);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { AuthModule } from "../auth/auth.module.js";
|
||||||
|
import { DatabaseModule } from "../database/database.module.js";
|
||||||
|
import { RedisModule } from "../redis/redis.module.js";
|
||||||
|
import { SafetyModule } from "../safety/safety.module.js";
|
||||||
|
import { NotificationModule } from "../notification/notification.module.js";
|
||||||
|
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,
|
||||||
|
SafetyModule,
|
||||||
|
NotificationModule,
|
||||||
|
],
|
||||||
|
controllers: [ConversationController],
|
||||||
|
providers: [
|
||||||
|
ConversationService,
|
||||||
|
ChatRateLimiter,
|
||||||
|
ChatGateway,
|
||||||
|
{ provide: CHAT_PUBLISHER, useExisting: ChatGateway },
|
||||||
|
OutboxMessageRelay,
|
||||||
|
StateChangingOriginGuard,
|
||||||
|
],
|
||||||
|
exports: [ConversationService, OutboxMessageRelay],
|
||||||
|
})
|
||||||
|
export class ConversationModule {}
|
||||||
@@ -0,0 +1,685 @@
|
|||||||
|
import { HttpStatus, Inject, Injectable } from "@nestjs/common";
|
||||||
|
import { ErrorCode } from "@drift/contracts";
|
||||||
|
import { Prisma, type PrismaClient } from "@prisma/client";
|
||||||
|
import { leaseHmac } from "../auth/auth.config.js";
|
||||||
|
import { DomainException } from "../common/domain.exception.js";
|
||||||
|
import { PrismaService } from "../database/prisma.service.js";
|
||||||
|
import { SafetyLockService } from "../safety/safety-lock.service.js";
|
||||||
|
import { NotificationService } from "../notification/notification.service.js";
|
||||||
|
|
||||||
|
type Db = Prisma.TransactionClient | PrismaClient;
|
||||||
|
type MessageRow = {
|
||||||
|
id: string;
|
||||||
|
conversationId: string;
|
||||||
|
senderId: string;
|
||||||
|
senderPublicId: string;
|
||||||
|
clientMsgId: string;
|
||||||
|
seq: bigint;
|
||||||
|
contentText: string;
|
||||||
|
status: string;
|
||||||
|
reviewStatus: string;
|
||||||
|
sentAt: Date;
|
||||||
|
};
|
||||||
|
type ConversationWithFirst = {
|
||||||
|
id: string;
|
||||||
|
messages: MessageRow[];
|
||||||
|
};
|
||||||
|
type Cursor = { t: string; id: string };
|
||||||
|
|
||||||
|
export interface MessageResult {
|
||||||
|
message: ReturnType<ConversationService["serializeMessage"]>;
|
||||||
|
deduplicated: boolean;
|
||||||
|
memberIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_SEQ = 9_223_372_036_854_775_807n;
|
||||||
|
const UUID =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
const firstMessageInclude = {
|
||||||
|
messages: {
|
||||||
|
orderBy: { seq: "asc" as const },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ConversationService {
|
||||||
|
constructor(
|
||||||
|
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||||
|
@Inject(SafetyLockService) private readonly safetyLocks: SafetyLockService,
|
||||||
|
@Inject(NotificationService)
|
||||||
|
private readonly notifications: NotificationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async reply(
|
||||||
|
accountId: string,
|
||||||
|
bottleId: string,
|
||||||
|
leaseId: string,
|
||||||
|
leaseToken: string,
|
||||||
|
clientMsgId: string,
|
||||||
|
text: string,
|
||||||
|
) {
|
||||||
|
this.moderate(text);
|
||||||
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
|
const existing = await this.prisma.conversation.findUnique({
|
||||||
|
where: { sourceBottleId: bottleId },
|
||||||
|
include: firstMessageInclude,
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
return this.resolveExistingReply(accountId, existing, clientMsgId);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await this.prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
await tx.$queryRaw`SELECT "id" FROM "bottles" WHERE "id"=${bottleId}::uuid FOR UPDATE`;
|
||||||
|
const winner = await tx.conversation.findUnique({
|
||||||
|
where: { sourceBottleId: bottleId },
|
||||||
|
include: firstMessageInclude,
|
||||||
|
});
|
||||||
|
if (winner) {
|
||||||
|
return this.resolveExistingReply(
|
||||||
|
accountId,
|
||||||
|
winner,
|
||||||
|
clientMsgId,
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const lease = await tx.bottlePickLease.findFirst({
|
||||||
|
where: {
|
||||||
|
id: leaseId,
|
||||||
|
bottleId,
|
||||||
|
pickerId: accountId,
|
||||||
|
leaseTokenHash: leaseHmac(leaseToken),
|
||||||
|
},
|
||||||
|
include: { bottle: true },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!lease ||
|
||||||
|
lease.status !== "ACTIVE" ||
|
||||||
|
lease.bottle.activeLeaseId !== lease.id ||
|
||||||
|
lease.bottle.poolStatus !== "LEASED"
|
||||||
|
) {
|
||||||
|
throw this.hidden();
|
||||||
|
}
|
||||||
|
if (lease.expiresAt <= new Date()) {
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.BOTTLE_LEASE_EXPIRED,
|
||||||
|
"Bottle lease expired",
|
||||||
|
HttpStatus.GONE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const authorId = lease.bottle.authorId;
|
||||||
|
await this.safetyLocks.lockAccounts(tx, [accountId, authorId]);
|
||||||
|
await this.assertAllowed(tx, accountId, authorId);
|
||||||
|
const profiles = await tx.anonymousProfile.findMany({
|
||||||
|
where: { accountId: { in: [accountId, authorId] } },
|
||||||
|
select: { accountId: true, nickname: true, publicId: true },
|
||||||
|
});
|
||||||
|
if (profiles.length !== 2) throw this.forbidden();
|
||||||
|
const now = new Date();
|
||||||
|
const conversation = await tx.conversation.create({
|
||||||
|
data: {
|
||||||
|
sourceBottleId: bottleId,
|
||||||
|
nextSeq: 2n,
|
||||||
|
lastMessageAt: now,
|
||||||
|
members: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
accountId,
|
||||||
|
peerAliasSnapshot: profiles.find(
|
||||||
|
(profile) => profile.accountId === authorId,
|
||||||
|
)!.nickname,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountId: authorId,
|
||||||
|
peerAliasSnapshot: profiles.find(
|
||||||
|
(profile) => profile.accountId === accountId,
|
||||||
|
)!.nickname,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
create: {
|
||||||
|
senderId: accountId,
|
||||||
|
senderPublicId: profiles.find(
|
||||||
|
(profile) => profile.accountId === accountId,
|
||||||
|
)!.publicId,
|
||||||
|
clientMsgId,
|
||||||
|
seq: 1n,
|
||||||
|
contentText: text,
|
||||||
|
reviewStatus: "APPROVED",
|
||||||
|
status: "SENT",
|
||||||
|
sentAt: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: firstMessageInclude,
|
||||||
|
});
|
||||||
|
const message = conversation.messages[0]!;
|
||||||
|
await this.createMessageOutbox(tx, message.id, conversation.id);
|
||||||
|
await this.notifications.createInApp(
|
||||||
|
tx,
|
||||||
|
authorId,
|
||||||
|
`first-reply:${conversation.id}`,
|
||||||
|
"FIRST_REPLY",
|
||||||
|
{ conversationId: conversation.id },
|
||||||
|
);
|
||||||
|
const consumed = await tx.bottle.updateMany({
|
||||||
|
where: {
|
||||||
|
id: bottleId,
|
||||||
|
poolStatus: "LEASED",
|
||||||
|
activeLeaseId: leaseId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
poolStatus: "CONSUMED",
|
||||||
|
activeLeaseId: null,
|
||||||
|
consumedAt: now,
|
||||||
|
version: { increment: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (consumed.count !== 1) throw this.hidden();
|
||||||
|
await tx.bottlePickLease.update({
|
||||||
|
where: { id: leaseId },
|
||||||
|
data: { status: "CONSUMED", endedAt: now },
|
||||||
|
});
|
||||||
|
return this.replyResult(conversation, clientMsgId, true);
|
||||||
|
},
|
||||||
|
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DomainException) throw error;
|
||||||
|
if (!this.isReplyRetryable(error)) throw error;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10 * (attempt + 1)));
|
||||||
|
const raced = await this.prisma.conversation.findUnique({
|
||||||
|
where: { sourceBottleId: bottleId },
|
||||||
|
include: firstMessageInclude,
|
||||||
|
});
|
||||||
|
if (raced) {
|
||||||
|
return this.resolveExistingReply(accountId, raced, clientMsgId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.SERVICE_UNAVAILABLE,
|
||||||
|
"Conversation temporarily unavailable",
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
conversation: ConversationWithFirst,
|
||||||
|
clientMsgId: string,
|
||||||
|
created: boolean,
|
||||||
|
) {
|
||||||
|
const message = conversation.messages[0]!;
|
||||||
|
const won = message.clientMsgId === clientMsgId;
|
||||||
|
return {
|
||||||
|
conversationId: conversation.id,
|
||||||
|
message: this.serializeMessage(message),
|
||||||
|
created,
|
||||||
|
deduplicated: !created && won,
|
||||||
|
won,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(
|
||||||
|
accountId: string,
|
||||||
|
conversationId: string,
|
||||||
|
clientMsgId: string,
|
||||||
|
text: string,
|
||||||
|
): Promise<MessageResult> {
|
||||||
|
this.moderate(text);
|
||||||
|
try {
|
||||||
|
return await this.prisma.$transaction(async (tx) => {
|
||||||
|
const conversation = await this.authorizeConversationMembership(
|
||||||
|
tx,
|
||||||
|
accountId,
|
||||||
|
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({
|
||||||
|
where: {
|
||||||
|
conversationId_clientMsgId: { conversationId, clientMsgId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (previous) {
|
||||||
|
return {
|
||||||
|
message: this.serializeMessage(previous),
|
||||||
|
deduplicated: true,
|
||||||
|
memberIds: conversation.members.map((member) => member.accountId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const rows = await tx.$queryRaw<Array<{ seq: bigint }>>`
|
||||||
|
UPDATE "conversations"
|
||||||
|
SET "next_seq"="next_seq"+1, "last_message_at"=now(), "updated_at"=now()
|
||||||
|
WHERE "id"=${conversationId}::uuid
|
||||||
|
RETURNING "next_seq"-1 AS seq
|
||||||
|
`;
|
||||||
|
const sender = await tx.anonymousProfile.findUnique({
|
||||||
|
where: { accountId },
|
||||||
|
select: { publicId: true },
|
||||||
|
});
|
||||||
|
if (!sender) throw this.forbidden();
|
||||||
|
const message = await tx.message.create({
|
||||||
|
data: {
|
||||||
|
conversationId,
|
||||||
|
senderId: accountId,
|
||||||
|
senderPublicId: sender.publicId,
|
||||||
|
clientMsgId,
|
||||||
|
seq: rows[0]!.seq,
|
||||||
|
contentText: text,
|
||||||
|
status: "SENT",
|
||||||
|
reviewStatus: "APPROVED",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const memberIds = conversation.members.map(
|
||||||
|
(member) => member.accountId,
|
||||||
|
);
|
||||||
|
await this.createMessageOutbox(tx, message.id, conversationId);
|
||||||
|
return {
|
||||||
|
message: this.serializeMessage(message),
|
||||||
|
deduplicated: false,
|
||||||
|
memberIds,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (this.isMessageIdempotencyRace(error)) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const conversation = await this.authorizeConversationMembership(
|
||||||
|
tx,
|
||||||
|
accountId,
|
||||||
|
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({
|
||||||
|
where: {
|
||||||
|
conversationId_clientMsgId: { conversationId, clientMsgId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
message: this.serializeMessage(message),
|
||||||
|
deduplicated: true,
|
||||||
|
memberIds: conversation.members.map((member) => member.accountId),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(accountId: string, cursorText: string | undefined, limit: number) {
|
||||||
|
const cursor = cursorText ? this.decodeCursor(cursorText) : undefined;
|
||||||
|
const cursorDate = cursor ? new Date(cursor.t) : undefined;
|
||||||
|
const rows = await this.prisma.conversation.findMany({
|
||||||
|
where: {
|
||||||
|
members: { some: { accountId, leftAt: null } },
|
||||||
|
...(cursor && cursorDate
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ lastMessageAt: { lt: cursorDate } },
|
||||||
|
{ lastMessageAt: cursorDate, id: { lt: cursor.id } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
orderBy: [{ lastMessageAt: "desc" }, { id: "desc" }],
|
||||||
|
take: limit + 1,
|
||||||
|
include: {
|
||||||
|
members: { where: { accountId } },
|
||||||
|
messages: {
|
||||||
|
orderBy: { seq: "desc" },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const hasMore = rows.length > limit;
|
||||||
|
const page = rows.slice(0, limit);
|
||||||
|
return {
|
||||||
|
items: page.map((conversation) => {
|
||||||
|
const last = conversation.messages[0];
|
||||||
|
const read = conversation.members[0]!.lastReadSeq;
|
||||||
|
return {
|
||||||
|
id: conversation.id,
|
||||||
|
status: conversation.status,
|
||||||
|
lastMessageAt: conversation.lastMessageAt,
|
||||||
|
lastMessage: last ? this.serializeMessage(last) : null,
|
||||||
|
unread: last && last.seq > read ? (last.seq - read).toString() : "0",
|
||||||
|
lastReadSeq: read.toString(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
nextCursor: hasMore ? this.encodeCursor(page.at(-1)!) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async detail(accountId: string, id: string) {
|
||||||
|
const row = await this.prisma.conversation.findFirst({
|
||||||
|
where: { id, members: { some: { accountId, leftAt: null } } },
|
||||||
|
include: { members: { where: { accountId } } },
|
||||||
|
});
|
||||||
|
if (!row) throw this.forbidden();
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
status: row.status,
|
||||||
|
lastMessageAt: row.lastMessageAt,
|
||||||
|
nextSeq: row.nextSeq.toString(),
|
||||||
|
lastReadSeq: row.members[0]!.lastReadSeq.toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async messages(
|
||||||
|
accountId: string,
|
||||||
|
id: string,
|
||||||
|
afterSeq: string,
|
||||||
|
limit: number,
|
||||||
|
) {
|
||||||
|
await this.assertMember(accountId, id);
|
||||||
|
const after = this.parseSeq(afterSeq);
|
||||||
|
const items = await this.prisma.message.findMany({
|
||||||
|
where: { conversationId: id, seq: { gt: after } },
|
||||||
|
orderBy: { seq: "asc" },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
return { items: items.map((message) => this.serializeMessage(message)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async read(accountId: string, id: string, seqText: string) {
|
||||||
|
const seq = this.parseSeq(seqText);
|
||||||
|
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,
|
||||||
|
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 authorizeConversationMembership(
|
||||||
|
tx: Db,
|
||||||
|
accountId: string,
|
||||||
|
conversationId: string,
|
||||||
|
) {
|
||||||
|
const conversation = await tx.conversation.findUnique({
|
||||||
|
where: { id: conversationId },
|
||||||
|
include: { members: true },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!conversation ||
|
||||||
|
conversation.status !== "ACTIVE" ||
|
||||||
|
!conversation.members.some(
|
||||||
|
(member) => member.accountId === accountId && !member.leftAt,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw this.forbidden();
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
if (!peer) throw this.forbidden();
|
||||||
|
return peer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createMessageOutbox(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
messageId: string,
|
||||||
|
conversationId: string,
|
||||||
|
) {
|
||||||
|
await tx.outboxEvent.create({
|
||||||
|
data: {
|
||||||
|
aggregateType: "MESSAGE",
|
||||||
|
aggregateId: messageId,
|
||||||
|
eventType: "MESSAGE_CREATED",
|
||||||
|
dedupeKey: `message-created:${messageId}`,
|
||||||
|
payload: { messageId, conversationId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private isReplyRetryable(error: unknown): boolean {
|
||||||
|
if (!(error instanceof Prisma.PrismaClientKnownRequestError)) return false;
|
||||||
|
if (error.code === "P2034") return true;
|
||||||
|
// Prisma wraps PostgreSQL serialization failures from $queryRaw as P2010.
|
||||||
|
if (error.code === "P2010") {
|
||||||
|
return error.meta?.code === "40001";
|
||||||
|
}
|
||||||
|
if (error.code !== "P2002") return false;
|
||||||
|
const fields = this.prismaUniqueTarget(error.meta?.target);
|
||||||
|
return (
|
||||||
|
fields.includes("source_bottle_id") ||
|
||||||
|
fields.includes("client_msg_id") ||
|
||||||
|
fields.includes("conversations_source_bottle_id_key") ||
|
||||||
|
fields.includes("messages_conversation_id_client_msg_id_key")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isMessageIdempotencyRace(error: unknown): boolean {
|
||||||
|
if (!(error instanceof Prisma.PrismaClientKnownRequestError)) return false;
|
||||||
|
if (error.code !== "P2002") return false;
|
||||||
|
const fields = this.prismaUniqueTarget(error.meta?.target);
|
||||||
|
return (
|
||||||
|
fields.includes("client_msg_id") ||
|
||||||
|
fields.includes("messages_conversation_id_client_msg_id_key")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private prismaUniqueTarget(target: unknown): string[] {
|
||||||
|
if (typeof target === "string") return [target];
|
||||||
|
if (
|
||||||
|
Array.isArray(target) &&
|
||||||
|
target.every((item) => typeof item === "string")
|
||||||
|
) {
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private encodeCursor(row: { id: string; lastMessageAt: Date }): string {
|
||||||
|
return Buffer.from(
|
||||||
|
JSON.stringify({ t: row.lastMessageAt.toISOString(), id: row.id }),
|
||||||
|
).toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodeCursor(text: string): Cursor {
|
||||||
|
if (text.length > 256 || !/^[A-Za-z0-9_-]+$/.test(text)) {
|
||||||
|
throw this.validation();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const decoded = Buffer.from(text, "base64url").toString("utf8");
|
||||||
|
if (Buffer.from(decoded).toString("base64url") !== text) {
|
||||||
|
throw new Error("non-canonical");
|
||||||
|
}
|
||||||
|
const value: unknown = JSON.parse(decoded);
|
||||||
|
if (
|
||||||
|
!value ||
|
||||||
|
typeof value !== "object" ||
|
||||||
|
Array.isArray(value) ||
|
||||||
|
Object.keys(value).length !== 2 ||
|
||||||
|
typeof (value as Cursor).t !== "string" ||
|
||||||
|
typeof (value as Cursor).id !== "string" ||
|
||||||
|
!UUID.test((value as Cursor).id)
|
||||||
|
) {
|
||||||
|
throw new Error("invalid cursor");
|
||||||
|
}
|
||||||
|
const date = new Date((value as Cursor).t);
|
||||||
|
if (
|
||||||
|
Number.isNaN(date.valueOf()) ||
|
||||||
|
date.toISOString() !== (value as Cursor).t ||
|
||||||
|
JSON.stringify(value) !== decoded
|
||||||
|
) {
|
||||||
|
throw new Error("non-canonical cursor");
|
||||||
|
}
|
||||||
|
return value as Cursor;
|
||||||
|
} catch {
|
||||||
|
throw this.validation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseSeq(text: string): bigint {
|
||||||
|
if (!/^(0|[1-9]\d{0,18})$/.test(text)) throw this.validation();
|
||||||
|
const value = BigInt(text);
|
||||||
|
if (value > MAX_SEQ) throw this.validation();
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async assertMember(accountId: string, id: string) {
|
||||||
|
const found = await this.prisma.conversationMember.findUnique({
|
||||||
|
where: { conversationId_accountId: { conversationId: id, accountId } },
|
||||||
|
});
|
||||||
|
if (!found || found.leftAt) throw this.forbidden();
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertAllowed(tx: Db, a: string, b: string) {
|
||||||
|
const now = new Date();
|
||||||
|
const [accounts, block, sanction] = await Promise.all([
|
||||||
|
tx.account.count({ where: { id: { in: [a, b] }, status: "ACTIVE" } }),
|
||||||
|
tx.block.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ blockerId: a, blockedId: b },
|
||||||
|
{ blockerId: b, blockedId: a },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
}),
|
||||||
|
tx.sanction.findFirst({
|
||||||
|
where: {
|
||||||
|
accountId: { in: [a, b] },
|
||||||
|
type: { in: ["MUTE", "SUSPENSION", "BAN"] },
|
||||||
|
revokedAt: null,
|
||||||
|
startsAt: { lte: now },
|
||||||
|
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (block) {
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.USER_BLOCKED,
|
||||||
|
"User blocked",
|
||||||
|
HttpStatus.FORBIDDEN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (accounts !== 2 || sanction) {
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.ACCOUNT_SANCTIONED,
|
||||||
|
"Account sanctioned",
|
||||||
|
HttpStatus.FORBIDDEN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private moderate(text: string) {
|
||||||
|
const reject = (process.env.MODERATION_REJECT_WORDS ?? "reject-word")
|
||||||
|
.split(",")
|
||||||
|
.filter(Boolean);
|
||||||
|
const review = (process.env.MODERATION_REVIEW_WORDS ?? "review-word")
|
||||||
|
.split(",")
|
||||||
|
.filter(Boolean);
|
||||||
|
if ([...reject, ...review].some((word) => text.includes(word))) {
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.CONTENT_REJECTED,
|
||||||
|
"Content rejected",
|
||||||
|
HttpStatus.FORBIDDEN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
serializeMessage(message: MessageRow) {
|
||||||
|
const publicId = message.senderPublicId;
|
||||||
|
if (!publicId) {
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.INTERNAL_ERROR,
|
||||||
|
"Message identity unavailable",
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: message.id,
|
||||||
|
conversationId: message.conversationId,
|
||||||
|
sender: { publicId },
|
||||||
|
clientMsgId: message.clientMsgId,
|
||||||
|
seq: message.seq.toString(),
|
||||||
|
text: message.contentText,
|
||||||
|
status: message.status,
|
||||||
|
reviewStatus: message.reviewStatus,
|
||||||
|
sentAt: message.sentAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private hidden() {
|
||||||
|
return new DomainException(
|
||||||
|
ErrorCode.NOT_FOUND,
|
||||||
|
"Not Found",
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private forbidden() {
|
||||||
|
return new DomainException(
|
||||||
|
ErrorCode.CONVERSATION_FORBIDDEN,
|
||||||
|
"Conversation forbidden",
|
||||||
|
HttpStatus.FORBIDDEN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private validation() {
|
||||||
|
return new DomainException(
|
||||||
|
ErrorCode.VALIDATION_ERROR,
|
||||||
|
"Validation failed",
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Transform, Type } from "class-transformer";
|
||||||
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Matches,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
const trim = ({ value }: { value: unknown }) =>
|
||||||
|
typeof value === "string" ? value.trim() : value;
|
||||||
|
|
||||||
|
export class ReplyDto {
|
||||||
|
@IsUUID() leaseId!: string;
|
||||||
|
@IsString() @Matches(/^[A-Za-z0-9_-]{43}$/) leaseToken!: string;
|
||||||
|
@IsUUID() clientMsgId!: string;
|
||||||
|
@Transform(trim) @IsString() @MinLength(1) @MaxLength(1000) text!: string;
|
||||||
|
}
|
||||||
|
export class SendMessageDto {
|
||||||
|
@IsUUID() conversationId!: string;
|
||||||
|
@IsUUID() clientMsgId!: string;
|
||||||
|
@Transform(trim) @IsString() @MinLength(1) @MaxLength(1000) text!: string;
|
||||||
|
}
|
||||||
|
export class MessagesQueryDto {
|
||||||
|
@IsOptional() @Matches(/^\d+$/) afterSeq = "0";
|
||||||
|
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) limit = 50;
|
||||||
|
}
|
||||||
|
export class ConversationsQueryDto {
|
||||||
|
@IsOptional() @IsString() @Matches(/^[A-Za-z0-9_-]+$/) cursor?: string;
|
||||||
|
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) limit = 20;
|
||||||
|
}
|
||||||
|
export class ReadConversationDto {
|
||||||
|
@Matches(/^\d+$/) seq!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SocketReadDto extends ReadConversationDto {
|
||||||
|
@IsUUID() conversationId!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import {
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
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");
|
||||||
|
|
||||||
|
function positiveInt(value: string | undefined, fallback: number): number {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OutboxMessageRelay implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(OutboxMessageRelay.name);
|
||||||
|
private stopped = false;
|
||||||
|
private loopPromise: Promise<void> | undefined;
|
||||||
|
private wakeLoop: (() => void) | undefined;
|
||||||
|
private readonly leaseMs = positiveInt(process.env.OUTBOX_LEASE_MS, 30_000);
|
||||||
|
private readonly maxAttempts = positiveInt(
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.logger.error({
|
||||||
|
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((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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,21 @@ import { PrismaService } from "../database/prisma.service.js";
|
|||||||
import { bootstrap, configureApp } from "../main.js";
|
import { bootstrap, configureApp } from "../main.js";
|
||||||
import { HealthService } from "./health.service.js";
|
import { HealthService } from "./health.service.js";
|
||||||
|
|
||||||
|
process.env.NODE_ENV = "test";
|
||||||
|
process.env.WEB_ORIGIN = "http://localhost:3000";
|
||||||
|
process.env.PHONE_ENCRYPTION_KEY =
|
||||||
|
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||||
|
process.env.PHONE_HMAC_KEY = "health-phone-hmac-key-with-at-least-32-bytes";
|
||||||
|
process.env.VERIFICATION_CODE_HMAC_KEY =
|
||||||
|
"health-code-hmac-key-with-at-least-32-bytes";
|
||||||
|
process.env.REFRESH_TOKEN_HMAC_KEY =
|
||||||
|
"health-refresh-hmac-key-with-at-least-32-bytes";
|
||||||
|
process.env.LEASE_TOKEN_HMAC_KEY =
|
||||||
|
"health-lease-hmac-key-with-at-least-32-bytes";
|
||||||
|
process.env.LEASE_TOKEN_ENCRYPTION_KEY =
|
||||||
|
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=";
|
||||||
|
process.env.JWT_SECRET = "health-jwt-secret-with-at-least-thirty-two-bytes";
|
||||||
|
|
||||||
type RedisClientDouble = EventEmitter & {
|
type RedisClientDouble = EventEmitter & {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
connect: ReturnType<typeof vi.fn>;
|
connect: ReturnType<typeof vi.fn>;
|
||||||
@@ -375,7 +390,9 @@ describe("API infrastructure", () => {
|
|||||||
const previousUrl = process.env.REDIS_URL;
|
const previousUrl = process.env.REDIS_URL;
|
||||||
const previousTimeout = process.env.REDIS_PROBE_TIMEOUT_MS;
|
const previousTimeout = process.env.REDIS_PROBE_TIMEOUT_MS;
|
||||||
process.env.REDIS_URL = "redis://127.0.0.1:1";
|
process.env.REDIS_URL = "redis://127.0.0.1:1";
|
||||||
process.env.REDIS_PROBE_TIMEOUT_MS = "100";
|
// A real reconnect plus PING can exceed 100 ms under full-suite load.
|
||||||
|
// Keep the strict deadline in the dedicated never-answering-connection test.
|
||||||
|
process.env.REDIS_PROBE_TIMEOUT_MS = "1000";
|
||||||
const warning = vi
|
const warning = vi
|
||||||
.spyOn(Logger.prototype, "warn")
|
.spyOn(Logger.prototype, "warn")
|
||||||
.mockImplementation(() => undefined);
|
.mockImplementation(() => undefined);
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
export type Candidate = {
|
||||||
|
id: string;
|
||||||
|
version: number;
|
||||||
|
authorId: string;
|
||||||
|
contentText: string;
|
||||||
|
publicId: string;
|
||||||
|
nickname: string;
|
||||||
|
avatarColor: string;
|
||||||
|
bio: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CandidateRepository {
|
||||||
|
async findBatch(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
pickerId: string,
|
||||||
|
limit = 32,
|
||||||
|
pivot: string = randomUUID(),
|
||||||
|
) {
|
||||||
|
const after = await tx.$queryRaw<Candidate[]>`
|
||||||
|
SELECT b."id", b."version", b."author_id" AS "authorId",
|
||||||
|
b."content_text" AS "contentText", p."public_id" AS "publicId",
|
||||||
|
p."nickname", p."avatar_color" AS "avatarColor", p."bio"
|
||||||
|
FROM "bottles" b
|
||||||
|
JOIN "anonymous_profiles" p ON p."account_id" = b."author_id"
|
||||||
|
WHERE b."id" >= ${pivot}::uuid
|
||||||
|
AND b."author_id" <> ${pickerId}::uuid
|
||||||
|
AND b."review_status" = 'APPROVED'
|
||||||
|
AND b."pool_status" = 'IN_POOL'
|
||||||
|
AND b."active_lease_id" IS NULL
|
||||||
|
AND p."review_status" = 'APPROVED'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "bottle_pick_history" h
|
||||||
|
WHERE h."bottle_id" = b."id" AND h."picker_id" = ${pickerId}::uuid
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "blocks" x
|
||||||
|
WHERE (x."blocker_id" = ${pickerId}::uuid AND x."blocked_id" = b."author_id")
|
||||||
|
OR (x."blocker_id" = b."author_id" AND x."blocked_id" = ${pickerId}::uuid)
|
||||||
|
)
|
||||||
|
ORDER BY b."id"
|
||||||
|
LIMIT ${limit}`;
|
||||||
|
const remaining = limit - after.length;
|
||||||
|
if (remaining <= 0) return after;
|
||||||
|
const before = await tx.$queryRaw<Candidate[]>`
|
||||||
|
SELECT b."id", b."version", b."author_id" AS "authorId",
|
||||||
|
b."content_text" AS "contentText", p."public_id" AS "publicId",
|
||||||
|
p."nickname", p."avatar_color" AS "avatarColor", p."bio"
|
||||||
|
FROM "bottles" b
|
||||||
|
JOIN "anonymous_profiles" p ON p."account_id" = b."author_id"
|
||||||
|
WHERE b."id" < ${pivot}::uuid
|
||||||
|
AND b."author_id" <> ${pickerId}::uuid
|
||||||
|
AND b."review_status" = 'APPROVED'
|
||||||
|
AND b."pool_status" = 'IN_POOL'
|
||||||
|
AND b."active_lease_id" IS NULL
|
||||||
|
AND p."review_status" = 'APPROVED'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "bottle_pick_history" h
|
||||||
|
WHERE h."bottle_id" = b."id" AND h."picker_id" = ${pickerId}::uuid
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM "blocks" x
|
||||||
|
WHERE (x."blocker_id" = ${pickerId}::uuid AND x."blocked_id" = b."author_id")
|
||||||
|
OR (x."blocker_id" = b."author_id" AND x."blocked_id" = ${pickerId}::uuid)
|
||||||
|
)
|
||||||
|
ORDER BY b."id"
|
||||||
|
LIMIT ${remaining}`;
|
||||||
|
return [...after, ...before];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { IsString, IsUUID, Matches } from "class-validator";
|
||||||
|
|
||||||
|
export class ReturnBottleDto {
|
||||||
|
@IsUUID() leaseId!: string;
|
||||||
|
@IsString() @Matches(/^[A-Za-z0-9_-]{43}$/) token!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
CandidateBatchContended,
|
||||||
|
classifyPickError,
|
||||||
|
readMatchRetryConfig,
|
||||||
|
transactionLimits,
|
||||||
|
} from "./match-retry-policy.js";
|
||||||
|
|
||||||
|
const prismaError = (code: string, target?: string[]) =>
|
||||||
|
new Prisma.PrismaClientKnownRequestError("test", {
|
||||||
|
code,
|
||||||
|
clientVersion: "test",
|
||||||
|
...(target ? { meta: { target } } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("match pick retry policy", () => {
|
||||||
|
it("uses bounded defaults and caps each transaction by the overall deadline", () => {
|
||||||
|
const config = readMatchRetryConfig({});
|
||||||
|
expect(config).toEqual({
|
||||||
|
budgetMs: 30_000,
|
||||||
|
maxAttempts: 64,
|
||||||
|
transactionMaxWaitMs: 10_000,
|
||||||
|
transactionTimeoutMs: 10_000,
|
||||||
|
});
|
||||||
|
expect(transactionLimits(config, 14_500, 14_750)).toEqual({
|
||||||
|
maxWait: 125,
|
||||||
|
timeout: 125,
|
||||||
|
});
|
||||||
|
expect(transactionLimits(config, 15_000, 15_000)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["MATCH_PICK_RETRY_BUDGET_MS", "0"],
|
||||||
|
["MATCH_PICK_RETRY_BUDGET_MS", "30001"],
|
||||||
|
["MATCH_PICK_MAX_ATTEMPTS", "101"],
|
||||||
|
["MATCH_PICK_TRANSACTION_MAX_WAIT_MS", "10001"],
|
||||||
|
["MATCH_PICK_TRANSACTION_TIMEOUT_MS", "nope"],
|
||||||
|
])("rejects invalid or excessive %s", (name, value) => {
|
||||||
|
expect(() => readMatchRetryConfig({ [name]: value })).toThrow(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies exact pick races without hiding unrelated unique violations", () => {
|
||||||
|
expect(classifyPickError(prismaError("P2034"))).toBe("RETRY");
|
||||||
|
expect(classifyPickError(new CandidateBatchContended())).toBe("RETRY");
|
||||||
|
expect(
|
||||||
|
classifyPickError(prismaError("P2002", ["picker_id", "request_id"])),
|
||||||
|
).toBe("READ_WINNER");
|
||||||
|
expect(
|
||||||
|
classifyPickError(
|
||||||
|
prismaError("P2002", ["bottle_pick_requests_picker_id_request_id_key"]),
|
||||||
|
),
|
||||||
|
).toBe("READ_WINNER");
|
||||||
|
expect(
|
||||||
|
classifyPickError(prismaError("P2002", ["bottle_id", "picker_id"])),
|
||||||
|
).toBe("RETRY");
|
||||||
|
expect(
|
||||||
|
classifyPickError(
|
||||||
|
prismaError("P2002", ["bottle_pick_leases_one_active_per_bottle"]),
|
||||||
|
),
|
||||||
|
).toBe("RETRY");
|
||||||
|
expect(classifyPickError(prismaError("P2002", ["bottle_id"]))).toBe(
|
||||||
|
"RETRY",
|
||||||
|
);
|
||||||
|
expect(classifyPickError(prismaError("P2002", ["active_lease_id"]))).toBe(
|
||||||
|
"RETRY",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
classifyPickError(prismaError("P2002", ["unrelated_bug_column"])),
|
||||||
|
).toBe("THROW");
|
||||||
|
expect(
|
||||||
|
classifyPickError(prismaError("P2002", ["customer_lease_notes_key"])),
|
||||||
|
).toBe("THROW");
|
||||||
|
expect(classifyPickError(prismaError("P2028"))).toBe("SERVICE_UNAVAILABLE");
|
||||||
|
expect(classifyPickError(new Error("programming bug"))).toBe("THROW");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
export type MatchRetryConfig = {
|
||||||
|
budgetMs: number;
|
||||||
|
maxAttempts: number;
|
||||||
|
transactionMaxWaitMs: number;
|
||||||
|
transactionTimeoutMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CandidateBatchContended extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("match pick candidate batch contended");
|
||||||
|
this.name = "CandidateBatchContended";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundedInt = (
|
||||||
|
environment: NodeJS.ProcessEnv,
|
||||||
|
name: string,
|
||||||
|
fallback: number,
|
||||||
|
maximum: number,
|
||||||
|
): number => {
|
||||||
|
const raw = environment[name];
|
||||||
|
const value = raw === undefined ? fallback : Number(raw);
|
||||||
|
if (!Number.isInteger(value) || value <= 0 || value > maximum)
|
||||||
|
throw new Error(
|
||||||
|
`${name} must be a positive integer no greater than ${maximum}`,
|
||||||
|
);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function readMatchRetryConfig(
|
||||||
|
environment: NodeJS.ProcessEnv,
|
||||||
|
): MatchRetryConfig {
|
||||||
|
return {
|
||||||
|
budgetMs: boundedInt(
|
||||||
|
environment,
|
||||||
|
"MATCH_PICK_RETRY_BUDGET_MS",
|
||||||
|
30_000,
|
||||||
|
30_000,
|
||||||
|
),
|
||||||
|
maxAttempts: boundedInt(environment, "MATCH_PICK_MAX_ATTEMPTS", 64, 100),
|
||||||
|
transactionMaxWaitMs: boundedInt(
|
||||||
|
environment,
|
||||||
|
"MATCH_PICK_TRANSACTION_MAX_WAIT_MS",
|
||||||
|
10_000,
|
||||||
|
10_000,
|
||||||
|
),
|
||||||
|
transactionTimeoutMs: boundedInt(
|
||||||
|
environment,
|
||||||
|
"MATCH_PICK_TRANSACTION_TIMEOUT_MS",
|
||||||
|
10_000,
|
||||||
|
10_000,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transactionLimits(
|
||||||
|
config: MatchRetryConfig,
|
||||||
|
nowMs: number,
|
||||||
|
deadlineMs: number,
|
||||||
|
): { maxWait: number; timeout: number } | null {
|
||||||
|
const remaining = Math.floor(deadlineMs - nowMs);
|
||||||
|
if (remaining <= 1) return null;
|
||||||
|
// Prisma's queue wait and transaction execution timeout are sequential. Split
|
||||||
|
// the final sliver so their sum can never exceed the overall deadline.
|
||||||
|
const maxWait = Math.min(
|
||||||
|
config.transactionMaxWaitMs,
|
||||||
|
Math.floor(remaining / 2),
|
||||||
|
);
|
||||||
|
const timeout = Math.min(config.transactionTimeoutMs, remaining - maxWait);
|
||||||
|
return maxWait > 0 && timeout > 0 ? { maxWait, timeout } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PickErrorAction =
|
||||||
|
"RETRY" | "READ_WINNER" | "SERVICE_UNAVAILABLE" | "THROW";
|
||||||
|
|
||||||
|
const uniqueTarget = (target: unknown): string[] =>
|
||||||
|
(Array.isArray(target) ? target : [target])
|
||||||
|
.filter((field): field is string => typeof field === "string")
|
||||||
|
.map((field) => field.toLowerCase());
|
||||||
|
|
||||||
|
const isPickRequestRace = (target: unknown): boolean => {
|
||||||
|
const fields = uniqueTarget(target);
|
||||||
|
return (
|
||||||
|
fields.includes("bottle_pick_requests_picker_id_request_id_key") ||
|
||||||
|
(fields.length === 2 &&
|
||||||
|
fields.includes("picker_id") &&
|
||||||
|
fields.includes("request_id"))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCandidateRace = (target: unknown): boolean => {
|
||||||
|
const fields = uniqueTarget(target);
|
||||||
|
return (
|
||||||
|
fields.includes("bottle_pick_leases_one_active_per_bottle") ||
|
||||||
|
fields.includes("bottle_pick_history_bottle_id_picker_id_key") ||
|
||||||
|
(fields.length === 1 &&
|
||||||
|
(fields.includes("bottle_id") || fields.includes("active_lease_id"))) ||
|
||||||
|
(fields.length === 2 &&
|
||||||
|
fields.includes("bottle_id") &&
|
||||||
|
fields.includes("picker_id"))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function classifyPickError(error: unknown): PickErrorAction {
|
||||||
|
if (error instanceof CandidateBatchContended) return "RETRY";
|
||||||
|
if (!(error instanceof Prisma.PrismaClientKnownRequestError)) return "THROW";
|
||||||
|
if (error.code === "P2034") return "RETRY";
|
||||||
|
if (error.code === "P2028") return "SERVICE_UNAVAILABLE";
|
||||||
|
if (error.code === "P2002") {
|
||||||
|
if (isPickRequestRace(error.meta?.target)) return "READ_WINNER";
|
||||||
|
if (isCandidateRace(error.meta?.target)) return "RETRY";
|
||||||
|
}
|
||||||
|
return "THROW";
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Headers,
|
||||||
|
HttpStatus,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
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 { MatchService } from "./match.service.js";
|
||||||
|
import { ReturnBottleDto } from "./dto.js";
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class MatchController {
|
||||||
|
constructor(@Inject(MatchService) private readonly matches: MatchService) {}
|
||||||
|
@Post("bottles/pick")
|
||||||
|
pick(
|
||||||
|
@CurrentUser() user: AccessClaims,
|
||||||
|
@Headers("idempotency-key") key: string | undefined,
|
||||||
|
) {
|
||||||
|
const normalized = key?.trim();
|
||||||
|
if (!normalized || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(normalized))
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.VALIDATION_ERROR,
|
||||||
|
"Validation failed",
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
return this.matches.pick(user.sub, normalized);
|
||||||
|
}
|
||||||
|
@Post("bottles/:id/return")
|
||||||
|
returnBottle(
|
||||||
|
@CurrentUser() user: AccessClaims,
|
||||||
|
@Param("id", new ParseUUIDPipe()) id: string,
|
||||||
|
@Body() dto: ReturnBottleDto,
|
||||||
|
) {
|
||||||
|
return this.matches.returnBottle(user.sub, id, dto.leaseId, dto.token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Reflect.defineMetadata(
|
||||||
|
"design:paramtypes",
|
||||||
|
[Object, String],
|
||||||
|
MatchController.prototype,
|
||||||
|
"pick",
|
||||||
|
);
|
||||||
|
Reflect.defineMetadata(
|
||||||
|
"design:paramtypes",
|
||||||
|
[Object, String, ReturnBottleDto],
|
||||||
|
MatchController.prototype,
|
||||||
|
"returnBottle",
|
||||||
|
);
|
||||||
@@ -0,0 +1,611 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-return */
|
||||||
|
import "reflect-metadata";
|
||||||
|
import { type INestApplication } from "@nestjs/common";
|
||||||
|
import { Test } from "@nestjs/testing";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import request from "supertest";
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { assertSafeTestDatabaseUrl } from "../../../../prisma/database-safety.js";
|
||||||
|
import { AppModule } from "../app.module.js";
|
||||||
|
import { TokenService } from "../auth/token.service.js";
|
||||||
|
import { configureApp } from "../main.js";
|
||||||
|
import { CandidateRepository } from "./candidate.repository.js";
|
||||||
|
|
||||||
|
process.env.NODE_ENV = "test";
|
||||||
|
process.env.WEB_ORIGIN = "http://localhost:3000";
|
||||||
|
process.env.PHONE_ENCRYPTION_KEY =
|
||||||
|
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||||
|
process.env.PHONE_HMAC_KEY = "test-phone-hmac-key-with-at-least-32-bytes";
|
||||||
|
process.env.VERIFICATION_CODE_HMAC_KEY =
|
||||||
|
"test-code-hmac-key-with-at-least-32-bytes";
|
||||||
|
process.env.JWT_SECRET = "test-jwt-secret-with-at-least-thirty-two-bytes";
|
||||||
|
process.env.REFRESH_TOKEN_HMAC_KEY =
|
||||||
|
"test-refresh-hmac-key-with-at-least-32-bytes";
|
||||||
|
process.env.LEASE_TOKEN_HMAC_KEY = "test-lease-hmac-key-with-at-least-32-bytes";
|
||||||
|
process.env.LEASE_TOKEN_ENCRYPTION_KEY =
|
||||||
|
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
describe("match API with real PostgreSQL", () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
assertSafeTestDatabaseUrl(process.env.DATABASE_URL ?? "");
|
||||||
|
await prisma.$connect();
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
app = module.createNestApplication();
|
||||||
|
configureApp(app);
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "accounts" CASCADE`);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app?.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function actor(
|
||||||
|
deviceId: string,
|
||||||
|
profileStatus: "APPROVED" | "REVIEWING" = "APPROVED",
|
||||||
|
) {
|
||||||
|
const account = await prisma.account.create({
|
||||||
|
data: {
|
||||||
|
phoneCiphertext: Buffer.from("cipher"),
|
||||||
|
phoneHmac: randomUUID(),
|
||||||
|
anonymousProfile: {
|
||||||
|
create: {
|
||||||
|
nickname: `anon-${deviceId}`,
|
||||||
|
avatarColor: "#123456",
|
||||||
|
bio: `bio-${deviceId}`,
|
||||||
|
reviewStatus: profileStatus,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { anonymousProfile: true },
|
||||||
|
});
|
||||||
|
const session = await prisma.session.create({
|
||||||
|
data: {
|
||||||
|
accountId: account.id,
|
||||||
|
refreshTokenHash: randomUUID(),
|
||||||
|
deviceId,
|
||||||
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
|
tokenFamily: randomUUID(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const token = app.get(TokenService).issueAccess({
|
||||||
|
sub: account.id,
|
||||||
|
session_id: session.id,
|
||||||
|
device_id: deviceId,
|
||||||
|
scopes: ["user"],
|
||||||
|
token_version: 0,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: account.id,
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
profile: account.anonymousProfile!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bottle(authorId: string, contentText = "message in bottle") {
|
||||||
|
return prisma.bottle.create({
|
||||||
|
data: {
|
||||||
|
authorId,
|
||||||
|
clientRequestId: randomUUID(),
|
||||||
|
contentText,
|
||||||
|
reviewStatus: "APPROVED",
|
||||||
|
poolStatus: "IN_POOL",
|
||||||
|
approvedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const pick = (authorization: string, key: string = randomUUID()) =>
|
||||||
|
request(app.getHttpServer())
|
||||||
|
.post("/api/v1/bottles/pick")
|
||||||
|
.set("Authorization", authorization)
|
||||||
|
.set("Idempotency-Key", key)
|
||||||
|
.send({});
|
||||||
|
const returnBottle = (
|
||||||
|
authorization: string,
|
||||||
|
bottleId: string,
|
||||||
|
leaseId: string,
|
||||||
|
token: string,
|
||||||
|
) =>
|
||||||
|
request(app.getHttpServer())
|
||||||
|
.post(`/api/v1/bottles/${bottleId}/return`)
|
||||||
|
.set("Authorization", authorization)
|
||||||
|
.send({ leaseId, token });
|
||||||
|
|
||||||
|
it("requires authentication for both match endpoints", async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post("/api/v1/bottles/pick")
|
||||||
|
.set("Idempotency-Key", randomUUID())
|
||||||
|
.send({})
|
||||||
|
.expect(401);
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post(`/api/v1/bottles/${randomUUID()}/return`)
|
||||||
|
.send({ leaseId: randomUUID(), token: "x".repeat(43) })
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a bounded UUID keyset pivot so every region beyond the oldest 32 is reachable", async () => {
|
||||||
|
const author = await actor("author-device");
|
||||||
|
const picker = await actor("picker-device");
|
||||||
|
const ids = Array.from({ length: 96 }, (_, index) => {
|
||||||
|
const prefix = index.toString(16).padStart(8, "0");
|
||||||
|
return `${prefix}-0000-4000-8000-000000000000`;
|
||||||
|
});
|
||||||
|
await prisma.bottle.createMany({
|
||||||
|
data: ids.map((id, index) => ({
|
||||||
|
id,
|
||||||
|
authorId: author.id,
|
||||||
|
clientRequestId: `pivot-${index}`,
|
||||||
|
contentText: `pivot-${index}`,
|
||||||
|
reviewStatus: "APPROVED" as const,
|
||||||
|
poolStatus: "IN_POOL" as const,
|
||||||
|
approvedAt: new Date(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const repository = app.get(CandidateRepository);
|
||||||
|
const front = await repository.findBatch(prisma, picker.id, 32, ids[0]);
|
||||||
|
const middle = await repository.findBatch(prisma, picker.id, 32, ids[40]);
|
||||||
|
const back = await repository.findBatch(prisma, picker.id, 32, ids[88]);
|
||||||
|
|
||||||
|
expect(front).toHaveLength(32);
|
||||||
|
expect(middle[0]?.id).toBe(ids[40]);
|
||||||
|
expect(back.map(({ id }) => id)).toContain(ids[95]);
|
||||||
|
expect(back.map(({ id }) => id)).toContain(ids[0]);
|
||||||
|
expect(new Set(back.map(({ id }) => id)).size).toBe(back.length);
|
||||||
|
expect(middle.map(({ id }) => id)).not.toEqual(front.map(({ id }) => id));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("picks an approved in-pool bottle and exposes only an anonymous public snapshot", async () => {
|
||||||
|
const author = await actor("author-device");
|
||||||
|
const picker = await actor("picker-device");
|
||||||
|
const source = await bottle(author.id, "hello from sea");
|
||||||
|
const response = await pick(picker.authorization).expect(201);
|
||||||
|
expect(response.body.data).toMatchObject({
|
||||||
|
bottle: { id: source.id, contentText: "hello from sea" },
|
||||||
|
author: {
|
||||||
|
publicId: author.profile.publicId,
|
||||||
|
nickname: author.profile.nickname,
|
||||||
|
avatarColor: author.profile.avatarColor,
|
||||||
|
bio: author.profile.bio,
|
||||||
|
},
|
||||||
|
lease: {
|
||||||
|
id: expect.any(String),
|
||||||
|
token: expect.stringMatching(/^[A-Za-z0-9_-]{32,}$/),
|
||||||
|
expiresAt: expect.any(String),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(response.body.data)).not.toContain(author.id);
|
||||||
|
expect(JSON.stringify(response.body.data)).not.toContain("phone");
|
||||||
|
expect(
|
||||||
|
await prisma.bottle.findUniqueOrThrow({ where: { id: source.id } }),
|
||||||
|
).toMatchObject({ poolStatus: "LEASED", version: 2 });
|
||||||
|
expect(
|
||||||
|
await prisma.bottlePickHistory.count({
|
||||||
|
where: { bottleId: source.id, pickerId: picker.id },
|
||||||
|
}),
|
||||||
|
).toBe(1);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await prisma.dailyUsage.findFirstOrThrow({
|
||||||
|
where: { accountId: picker.id },
|
||||||
|
})
|
||||||
|
).bottlesPicked,
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("excludes own, history, both block directions, unapproved profile and unavailable bottle states", async () => {
|
||||||
|
const picker = await actor("picker-device");
|
||||||
|
const own = await bottle(picker.id, "own");
|
||||||
|
const historyAuthor = await actor("history-author");
|
||||||
|
const historical = await bottle(historyAuthor.id, "history");
|
||||||
|
await prisma.bottlePickHistory.create({
|
||||||
|
data: { bottleId: historical.id, pickerId: picker.id },
|
||||||
|
});
|
||||||
|
const blockedByPicker = await actor("blocked-one");
|
||||||
|
await bottle(blockedByPicker.id, "blocked outgoing");
|
||||||
|
await prisma.block.create({
|
||||||
|
data: { blockerId: picker.id, blockedId: blockedByPicker.id },
|
||||||
|
});
|
||||||
|
const blocksPicker = await actor("blocked-two");
|
||||||
|
await bottle(blocksPicker.id, "blocked incoming");
|
||||||
|
await prisma.block.create({
|
||||||
|
data: { blockerId: blocksPicker.id, blockedId: picker.id },
|
||||||
|
});
|
||||||
|
const pendingProfile = await actor("pending-profile", "REVIEWING");
|
||||||
|
await bottle(pendingProfile.id, "pending profile");
|
||||||
|
const validAuthor = await actor("valid-author");
|
||||||
|
const valid = await bottle(validAuthor.id, "valid");
|
||||||
|
await prisma.bottle.create({
|
||||||
|
data: {
|
||||||
|
authorId: validAuthor.id,
|
||||||
|
clientRequestId: randomUUID(),
|
||||||
|
contentText: "reviewing",
|
||||||
|
reviewStatus: "REVIEWING",
|
||||||
|
poolStatus: "IN_POOL",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const closed = await bottle(validAuthor.id, "closed");
|
||||||
|
await prisma.bottle.update({
|
||||||
|
where: { id: closed.id },
|
||||||
|
data: { poolStatus: "CLOSED" },
|
||||||
|
});
|
||||||
|
const leased = await bottle(validAuthor.id, "leased");
|
||||||
|
await prisma.bottle.update({
|
||||||
|
where: { id: leased.id },
|
||||||
|
data: { poolStatus: "LEASED" },
|
||||||
|
});
|
||||||
|
const activeLease = await bottle(validAuthor.id, "active lease id");
|
||||||
|
const leaseId = randomUUID();
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.bottle.update({
|
||||||
|
where: { id: activeLease.id },
|
||||||
|
data: { activeLeaseId: leaseId },
|
||||||
|
});
|
||||||
|
await tx.bottlePickLease.create({
|
||||||
|
data: {
|
||||||
|
id: leaseId,
|
||||||
|
bottleId: activeLease.id,
|
||||||
|
pickerId: picker.id,
|
||||||
|
leaseTokenHash: randomUUID(),
|
||||||
|
leaseTokenCiphertext: Buffer.from("ciphertext"),
|
||||||
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const picked = await pick(picker.authorization).expect(201);
|
||||||
|
expect(picked.body.data.bottle.id).toBe(valid.id);
|
||||||
|
expect(picked.body.data.bottle.id).not.toBe(own.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns pool empty without consuming quota", async () => {
|
||||||
|
const picker = await actor("picker-device");
|
||||||
|
const response = await pick(picker.authorization).expect(404);
|
||||||
|
expect(response.body.code).toBe("BOTTLE_POOL_EMPTY");
|
||||||
|
expect(
|
||||||
|
await prisma.dailyUsage.count({ where: { accountId: picker.id } }),
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates concurrent pick retries into one lease and one quota use", async () => {
|
||||||
|
const author = await actor("author-device");
|
||||||
|
const picker = await actor("picker-device");
|
||||||
|
await bottle(author.id);
|
||||||
|
const key = randomUUID();
|
||||||
|
const results = await Promise.all(
|
||||||
|
Array.from({ length: 8 }, () => pick(picker.authorization, key)),
|
||||||
|
);
|
||||||
|
expect(results.map((x) => [x.status, x.body.code])).toEqual(
|
||||||
|
Array.from({ length: 8 }, () => [201, "OK"]),
|
||||||
|
);
|
||||||
|
expect(new Set(results.map((x) => x.body.data.lease.id))).toHaveLength(1);
|
||||||
|
expect(new Set(results.map((x) => x.body.data.lease.token))).toHaveLength(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
expect(await prisma.bottlePickLease.count()).toBe(1);
|
||||||
|
expect((await prisma.dailyUsage.findFirstOrThrow()).bottlesPicked).toBe(1);
|
||||||
|
const token = results[0]!.body.data.lease.token as string;
|
||||||
|
const stored = await prisma.bottlePickLease.findFirstOrThrow();
|
||||||
|
expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||||
|
expect(stored.leaseTokenHash).not.toContain(token);
|
||||||
|
expect(
|
||||||
|
Buffer.from(stored.leaseTokenCiphertext).toString("utf8"),
|
||||||
|
).not.toContain(token);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed with a stable response when an idempotent lease token cannot be decrypted", async () => {
|
||||||
|
const author = await actor("author-device");
|
||||||
|
const picker = await actor("picker-device");
|
||||||
|
await bottle(author.id);
|
||||||
|
const key = randomUUID();
|
||||||
|
const first = await pick(picker.authorization, key).expect(201);
|
||||||
|
await prisma.bottlePickLease.update({
|
||||||
|
where: { id: first.body.data.lease.id },
|
||||||
|
data: { leaseTokenCiphertext: Buffer.from("corrupt") },
|
||||||
|
});
|
||||||
|
|
||||||
|
const retry = await pick(picker.authorization, key).expect(503);
|
||||||
|
|
||||||
|
expect(retry.body).toMatchObject({
|
||||||
|
code: "SERVICE_UNAVAILABLE",
|
||||||
|
message: "Bottle pick temporarily unavailable",
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(retry.body)).not.toContain("cipher");
|
||||||
|
expect(await prisma.bottlePickLease.count()).toBe(1);
|
||||||
|
expect((await prisma.dailyUsage.findFirstOrThrow()).bottlesPicked).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reveal an idempotent bottle or lease after either user blocks the other", async () => {
|
||||||
|
const author = await actor("author-device");
|
||||||
|
const picker = await actor("picker-device");
|
||||||
|
await bottle(author.id, "sensitive replay body");
|
||||||
|
const key = randomUUID();
|
||||||
|
await pick(picker.authorization, key).expect(201);
|
||||||
|
await prisma.block.create({
|
||||||
|
data: { blockerId: author.id, blockedId: picker.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const retry = await pick(picker.authorization, key).expect(403);
|
||||||
|
expect(retry.body.code).toBe("USER_BLOCKED");
|
||||||
|
expect(JSON.stringify(retry.body)).not.toContain("sensitive replay body");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["RETURNED", "EXPIRED"] as const)(
|
||||||
|
"returns the original pick response when the idempotent lease is %s",
|
||||||
|
async (status) => {
|
||||||
|
const author = await actor("author-device");
|
||||||
|
const picker = await actor("picker-device");
|
||||||
|
const source = await bottle(author.id);
|
||||||
|
const key = randomUUID();
|
||||||
|
const first = await pick(picker.authorization, key).expect(201);
|
||||||
|
if (status === "RETURNED") {
|
||||||
|
await returnBottle(
|
||||||
|
picker.authorization,
|
||||||
|
source.id,
|
||||||
|
first.body.data.lease.id,
|
||||||
|
first.body.data.lease.token,
|
||||||
|
).expect(201);
|
||||||
|
} else {
|
||||||
|
await prisma.bottlePickLease.update({
|
||||||
|
where: { id: first.body.data.lease.id },
|
||||||
|
data: { status: "EXPIRED", endedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const retry = await pick(picker.authorization, key).expect(201);
|
||||||
|
expect(retry.body.data).toEqual(first.body.data);
|
||||||
|
expect(await prisma.bottlePickLease.count()).toBe(1);
|
||||||
|
expect((await prisma.dailyUsage.findFirstOrThrow()).bottlesPicked).toBe(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("issues independent random opaque tokens for different requests", async () => {
|
||||||
|
const author = await actor("author-device");
|
||||||
|
const picker = await actor("picker-device");
|
||||||
|
await bottle(author.id, "one");
|
||||||
|
await bottle(author.id, "two");
|
||||||
|
const first = (await pick(picker.authorization, "request-one").expect(201))
|
||||||
|
.body.data.lease.token;
|
||||||
|
const second = (await pick(picker.authorization, "request-two").expect(201))
|
||||||
|
.body.data.lease.token;
|
||||||
|
expect(first).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||||
|
expect(second).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||||
|
expect(second).not.toBe(first);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed return UUIDs and non-canonical lease tokens at the DTO boundary", async () => {
|
||||||
|
const picker = await actor("picker-device");
|
||||||
|
await returnBottle(
|
||||||
|
picker.authorization,
|
||||||
|
"not-a-uuid",
|
||||||
|
randomUUID(),
|
||||||
|
"x".repeat(43),
|
||||||
|
).expect(400);
|
||||||
|
await returnBottle(
|
||||||
|
picker.authorization,
|
||||||
|
randomUUID(),
|
||||||
|
"not-a-uuid",
|
||||||
|
"x".repeat(43),
|
||||||
|
).expect(400);
|
||||||
|
await returnBottle(
|
||||||
|
picker.authorization,
|
||||||
|
randomUUID(),
|
||||||
|
randomUUID(),
|
||||||
|
"x".repeat(42),
|
||||||
|
).expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed idempotency keys and canonicalizes surrounding whitespace", async () => {
|
||||||
|
const author = await actor("author-device");
|
||||||
|
const picker = await actor("picker-device");
|
||||||
|
await bottle(author.id);
|
||||||
|
await pick(picker.authorization, "bad key").expect(400);
|
||||||
|
const first = await pick(picker.authorization, " stable-key ").expect(201);
|
||||||
|
const retry = await pick(picker.authorization, "stable-key").expect(201);
|
||||||
|
expect(retry.body.data.lease).toEqual(first.body.data.lease);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows only one of two users to concurrently lease the sole bottle", async () => {
|
||||||
|
const author = await actor("author-device");
|
||||||
|
const a = await actor("picker-a-device");
|
||||||
|
const b = await actor("picker-b-device");
|
||||||
|
const source = await bottle(author.id);
|
||||||
|
const results = await Promise.all([
|
||||||
|
pick(a.authorization),
|
||||||
|
pick(b.authorization),
|
||||||
|
]);
|
||||||
|
expect(results.filter((x) => x.status === 201)).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
results.filter(
|
||||||
|
(x) => x.status === 404 && x.body.code === "BOTTLE_POOL_EMPTY",
|
||||||
|
),
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(
|
||||||
|
await prisma.bottlePickLease.count({
|
||||||
|
where: { bottleId: source.id, status: "ACTIVE" },
|
||||||
|
}),
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces twenty successful picks atomically and resets on another UTC+8 day", async () => {
|
||||||
|
const author = await actor("author-device");
|
||||||
|
const picker = await actor("picker-device");
|
||||||
|
await prisma.bottle.createMany({
|
||||||
|
data: Array.from({ length: 22 }, (_, i) => ({
|
||||||
|
authorId: author.id,
|
||||||
|
clientRequestId: randomUUID(),
|
||||||
|
contentText: `bottle-${i}`,
|
||||||
|
reviewStatus: "APPROVED",
|
||||||
|
poolStatus: "IN_POOL",
|
||||||
|
approvedAt: new Date(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const results = await Promise.all(
|
||||||
|
Array.from({ length: 21 }, () => pick(picker.authorization)),
|
||||||
|
);
|
||||||
|
expect(results.filter((x) => x.status === 201)).toHaveLength(20);
|
||||||
|
expect(
|
||||||
|
results.filter(
|
||||||
|
(x) => x.status === 429 && x.body.code === "BOTTLE_DAILY_LIMIT",
|
||||||
|
),
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect((await prisma.dailyUsage.findFirstOrThrow()).bottlesPicked).toBe(20);
|
||||||
|
await prisma.dailyUsage.updateMany({
|
||||||
|
data: { usageDate: new Date("2020-01-01T00:00:00.000Z") },
|
||||||
|
});
|
||||||
|
await pick(picker.authorization).expect(201);
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
|
it("sustains twenty rounds of 21 concurrent distinct requests without picker-level serialization", async () => {
|
||||||
|
for (let round = 0; round < 20; round += 1) {
|
||||||
|
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "accounts" CASCADE`);
|
||||||
|
const author = await actor(`stress-author-${round}`);
|
||||||
|
const picker = await actor(`stress-picker-${round}`);
|
||||||
|
await prisma.bottle.createMany({
|
||||||
|
data: Array.from({ length: 96 }, (_, index) => ({
|
||||||
|
authorId: author.id,
|
||||||
|
clientRequestId: `stress-${round}-${index}`,
|
||||||
|
contentText: `stress-${round}-${index}`,
|
||||||
|
reviewStatus: "APPROVED" as const,
|
||||||
|
poolStatus: "IN_POOL" as const,
|
||||||
|
approvedAt: new Date(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
Array.from({ length: 21 }, (_, index) =>
|
||||||
|
pick(picker.authorization, `stress-key-${round}-${index}`),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(results.filter(({ status }) => status === 201)).toHaveLength(20);
|
||||||
|
expect(
|
||||||
|
results.filter(
|
||||||
|
({ status, body }) =>
|
||||||
|
status === 429 && body.code === "BOTTLE_DAILY_LIMIT",
|
||||||
|
),
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(results.filter(({ status }) => status >= 500)).toHaveLength(0);
|
||||||
|
expect(
|
||||||
|
await prisma.dailyUsage.findFirstOrThrow({
|
||||||
|
where: { accountId: picker.id },
|
||||||
|
}),
|
||||||
|
).toMatchObject({ bottlesPicked: 20 });
|
||||||
|
expect(
|
||||||
|
await prisma.bottlePickLease.count({ where: { pickerId: picker.id } }),
|
||||||
|
).toBe(20);
|
||||||
|
expect(
|
||||||
|
await prisma.bottlePickHistory.count({
|
||||||
|
where: { pickerId: picker.id },
|
||||||
|
}),
|
||||||
|
).toBe(20);
|
||||||
|
expect(
|
||||||
|
await prisma.bottlePickRequest.count({
|
||||||
|
where: { pickerId: picker.id },
|
||||||
|
}),
|
||||||
|
).toBe(20);
|
||||||
|
}
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
it.each(["SUSPENSION", "BAN"] as const)(
|
||||||
|
"rejects active %s sanction",
|
||||||
|
async (type) => {
|
||||||
|
const author = await actor("author-device");
|
||||||
|
const picker = await actor("picker-device");
|
||||||
|
await bottle(author.id);
|
||||||
|
await prisma.sanction.create({
|
||||||
|
data: { accountId: picker.id, type, reason: "policy" },
|
||||||
|
});
|
||||||
|
const response = await pick(picker.authorization).expect(401);
|
||||||
|
expect(response.body.code).toBe("AUTH_UNAUTHORIZED");
|
||||||
|
expect(await prisma.bottlePickLease.count()).toBe(0);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("returns an active lease, is idempotent, and permanently preserves pick history", async () => {
|
||||||
|
const author = await actor("author-device");
|
||||||
|
const picker = await actor("picker-device");
|
||||||
|
const source = await bottle(author.id);
|
||||||
|
const picked = (await pick(picker.authorization).expect(201)).body.data;
|
||||||
|
const first = await returnBottle(
|
||||||
|
picker.authorization,
|
||||||
|
source.id,
|
||||||
|
picked.lease.id,
|
||||||
|
picked.lease.token,
|
||||||
|
).expect(201);
|
||||||
|
const retry = await returnBottle(
|
||||||
|
picker.authorization,
|
||||||
|
source.id,
|
||||||
|
picked.lease.id,
|
||||||
|
picked.lease.token,
|
||||||
|
).expect(201);
|
||||||
|
expect(first.body.data).toEqual({
|
||||||
|
bottleId: source.id,
|
||||||
|
leaseId: picked.lease.id,
|
||||||
|
status: "RETURNED",
|
||||||
|
});
|
||||||
|
expect(retry.body.data).toEqual(first.body.data);
|
||||||
|
expect(
|
||||||
|
await prisma.bottle.findUniqueOrThrow({ where: { id: source.id } }),
|
||||||
|
).toMatchObject({ poolStatus: "IN_POOL", activeLeaseId: null, version: 3 });
|
||||||
|
await pick(picker.authorization).expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides another user's lease and rejects an invalid opaque token without mutation", async () => {
|
||||||
|
const author = await actor("author-device");
|
||||||
|
const picker = await actor("picker-device");
|
||||||
|
const stranger = await actor("stranger-device");
|
||||||
|
const source = await bottle(author.id);
|
||||||
|
const picked = (await pick(picker.authorization).expect(201)).body.data;
|
||||||
|
await returnBottle(
|
||||||
|
stranger.authorization,
|
||||||
|
source.id,
|
||||||
|
picked.lease.id,
|
||||||
|
picked.lease.token,
|
||||||
|
).expect(404);
|
||||||
|
await returnBottle(
|
||||||
|
picker.authorization,
|
||||||
|
source.id,
|
||||||
|
picked.lease.id,
|
||||||
|
"x".repeat(43),
|
||||||
|
).expect(404);
|
||||||
|
expect(
|
||||||
|
await prisma.bottlePickLease.findUniqueOrThrow({
|
||||||
|
where: { id: picked.lease.id },
|
||||||
|
}),
|
||||||
|
).toMatchObject({ status: "ACTIVE", endedAt: null });
|
||||||
|
expect(
|
||||||
|
(await prisma.bottle.findUniqueOrThrow({ where: { id: source.id } }))
|
||||||
|
.poolStatus,
|
||||||
|
).toBe("LEASED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects returning an expired lease without releasing it in the API", async () => {
|
||||||
|
const author = await actor("author-device");
|
||||||
|
const picker = await actor("picker-device");
|
||||||
|
const source = await bottle(author.id);
|
||||||
|
const picked = (await pick(picker.authorization).expect(201)).body.data;
|
||||||
|
await prisma.bottlePickLease.update({
|
||||||
|
where: { id: picked.lease.id },
|
||||||
|
data: { expiresAt: new Date(Date.now() - 1) },
|
||||||
|
});
|
||||||
|
const response = await returnBottle(
|
||||||
|
picker.authorization,
|
||||||
|
source.id,
|
||||||
|
picked.lease.id,
|
||||||
|
picked.lease.token,
|
||||||
|
).expect(410);
|
||||||
|
expect(response.body.code).toBe("BOTTLE_LEASE_EXPIRED");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { AuthModule } from "../auth/auth.module.js";
|
||||||
|
import { DatabaseModule } from "../database/database.module.js";
|
||||||
|
import { CandidateRepository } from "./candidate.repository.js";
|
||||||
|
import { MatchController } from "./match.controller.js";
|
||||||
|
import { MatchService } from "./match.service.js";
|
||||||
|
import { SafetyModule } from "../safety/safety.module.js";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [DatabaseModule, AuthModule, SafetyModule],
|
||||||
|
controllers: [MatchController],
|
||||||
|
providers: [CandidateRepository, MatchService],
|
||||||
|
})
|
||||||
|
export class MatchModule {}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { ErrorCode } from "@drift/contracts";
|
||||||
|
import { DomainException } from "../common/domain.exception.js";
|
||||||
|
import type { PrismaService } from "../database/prisma.service.js";
|
||||||
|
import type { CandidateRepository } from "./candidate.repository.js";
|
||||||
|
import { MatchService } from "./match.service.js";
|
||||||
|
import { SafetyLockService } from "../safety/safety-lock.service.js";
|
||||||
|
import type { Candidate } from "./candidate.repository.js";
|
||||||
|
|
||||||
|
const pickRequestRace = () =>
|
||||||
|
new Prisma.PrismaClientKnownRequestError("test", {
|
||||||
|
code: "P2002",
|
||||||
|
clientVersion: "test",
|
||||||
|
meta: { target: ["picker_id", "request_id"] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const transactionTimeout = () =>
|
||||||
|
new Prisma.PrismaClientKnownRequestError("test", {
|
||||||
|
code: "P2028",
|
||||||
|
clientVersion: "test",
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MatchService winner reads", () => {
|
||||||
|
it.each([
|
||||||
|
["idempotency race", pickRequestRace(), ErrorCode.SERVICE_UNAVAILABLE, 503],
|
||||||
|
[
|
||||||
|
"empty-pool visibility bridge",
|
||||||
|
new DomainException(
|
||||||
|
ErrorCode.BOTTLE_POOL_EMPTY,
|
||||||
|
"Bottle pool empty",
|
||||||
|
404,
|
||||||
|
),
|
||||||
|
ErrorCode.BOTTLE_POOL_EMPTY,
|
||||||
|
404,
|
||||||
|
],
|
||||||
|
])(
|
||||||
|
"bounds a stalled winner query after an %s",
|
||||||
|
async (_case, initialError, expectedCode, expectedStatus) => {
|
||||||
|
const previousEnvironment = {
|
||||||
|
budget: process.env.MATCH_PICK_RETRY_BUDGET_MS,
|
||||||
|
attempts: process.env.MATCH_PICK_MAX_ATTEMPTS,
|
||||||
|
maxWait: process.env.MATCH_PICK_TRANSACTION_MAX_WAIT_MS,
|
||||||
|
timeout: process.env.MATCH_PICK_TRANSACTION_TIMEOUT_MS,
|
||||||
|
};
|
||||||
|
process.env.MATCH_PICK_RETRY_BUDGET_MS = "40";
|
||||||
|
process.env.MATCH_PICK_MAX_ATTEMPTS = "1";
|
||||||
|
process.env.MATCH_PICK_TRANSACTION_MAX_WAIT_MS = "20";
|
||||||
|
process.env.MATCH_PICK_TRANSACTION_TIMEOUT_MS = "20";
|
||||||
|
|
||||||
|
const clock = vi.spyOn(performance, "now").mockReturnValue(0);
|
||||||
|
try {
|
||||||
|
const transactionOptions: Array<{ maxWait: number; timeout: number }> =
|
||||||
|
[];
|
||||||
|
let rejectWinnerRead!: (reason: unknown) => void;
|
||||||
|
let markWinnerReadStarted!: () => void;
|
||||||
|
const winnerReadStarted = new Promise<void>((resolve) => {
|
||||||
|
markWinnerReadStarted = resolve;
|
||||||
|
});
|
||||||
|
const rootRead = vi.fn(() => {
|
||||||
|
throw new Error("winner read escaped its bounded transaction");
|
||||||
|
});
|
||||||
|
const transaction = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(initialError)
|
||||||
|
.mockImplementationOnce(
|
||||||
|
(
|
||||||
|
callback: (tx: unknown) => Promise<unknown>,
|
||||||
|
options: { maxWait: number; timeout: number },
|
||||||
|
) => {
|
||||||
|
transactionOptions.push(options);
|
||||||
|
markWinnerReadStarted();
|
||||||
|
void callback({
|
||||||
|
bottlePickRequest: { findUnique: () => new Promise(() => {}) },
|
||||||
|
});
|
||||||
|
return new Promise((_, reject) => {
|
||||||
|
rejectWinnerRead = reject;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const prisma = {
|
||||||
|
$transaction: transaction,
|
||||||
|
bottlePickRequest: { findUnique: rootRead },
|
||||||
|
} as unknown as PrismaService;
|
||||||
|
const service = new MatchService(
|
||||||
|
prisma,
|
||||||
|
{} as unknown as CandidateRepository,
|
||||||
|
{} as unknown as SafetyLockService,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = service.pick("picker", "request");
|
||||||
|
await winnerReadStarted;
|
||||||
|
|
||||||
|
expect(transaction).toHaveBeenCalledTimes(2);
|
||||||
|
expect(rootRead).not.toHaveBeenCalled();
|
||||||
|
expect(transactionOptions).toHaveLength(1);
|
||||||
|
expect(transactionOptions[0]!.maxWait).toBeGreaterThan(0);
|
||||||
|
expect(transactionOptions[0]!.timeout).toBeGreaterThan(0);
|
||||||
|
expect(
|
||||||
|
transactionOptions[0]!.maxWait + transactionOptions[0]!.timeout,
|
||||||
|
).toBeLessThanOrEqual(40);
|
||||||
|
|
||||||
|
rejectWinnerRead(transactionTimeout());
|
||||||
|
await expect(result).rejects.toMatchObject({
|
||||||
|
code: expectedCode,
|
||||||
|
status: expectedStatus,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clock.mockRestore();
|
||||||
|
const restore = (name: string, value: string | undefined) => {
|
||||||
|
if (value === undefined) delete process.env[name];
|
||||||
|
else process.env[name] = value;
|
||||||
|
};
|
||||||
|
restore("MATCH_PICK_RETRY_BUDGET_MS", previousEnvironment.budget);
|
||||||
|
restore("MATCH_PICK_MAX_ATTEMPTS", previousEnvironment.attempts);
|
||||||
|
restore(
|
||||||
|
"MATCH_PICK_TRANSACTION_MAX_WAIT_MS",
|
||||||
|
previousEnvironment.maxWait,
|
||||||
|
);
|
||||||
|
restore(
|
||||||
|
"MATCH_PICK_TRANSACTION_TIMEOUT_MS",
|
||||||
|
previousEnvironment.timeout,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MatchService candidate policy locking", () => {
|
||||||
|
it("locks the complete batch in canonical order before trying reverse-ordered candidates", async () => {
|
||||||
|
const picker = "00000000-0000-4000-8000-000000000002";
|
||||||
|
const authorA = "00000000-0000-4000-8000-000000000001";
|
||||||
|
const authorB = "00000000-0000-4000-8000-000000000003";
|
||||||
|
const candidate = (authorId: string): Candidate => ({
|
||||||
|
id: authorId,
|
||||||
|
authorId,
|
||||||
|
version: 1,
|
||||||
|
contentText: "test",
|
||||||
|
publicId: authorId,
|
||||||
|
nickname: "test",
|
||||||
|
avatarColor: "#ffffff",
|
||||||
|
bio: null,
|
||||||
|
});
|
||||||
|
const locked: string[][] = [];
|
||||||
|
const tx = {
|
||||||
|
$executeRaw: vi.fn().mockResolvedValue(1),
|
||||||
|
bottlePickRequest: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||||
|
account: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({ status: "ACTIVE" }),
|
||||||
|
count: vi.fn().mockResolvedValue(2),
|
||||||
|
},
|
||||||
|
sanction: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||||
|
block: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||||
|
bottle: { findFirst: vi.fn().mockResolvedValue(null) },
|
||||||
|
};
|
||||||
|
const transactionClient = tx;
|
||||||
|
const prisma = {
|
||||||
|
$transaction: async (
|
||||||
|
fn: (client: typeof transactionClient) => Promise<unknown>,
|
||||||
|
) => fn(transactionClient),
|
||||||
|
} as unknown as PrismaService;
|
||||||
|
const candidates = {
|
||||||
|
findBatch: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([candidate(authorB), candidate(authorA)]),
|
||||||
|
} as unknown as CandidateRepository;
|
||||||
|
const locks = new SafetyLockService();
|
||||||
|
const realLockAccounts = locks.lockAccounts.bind(locks);
|
||||||
|
const lockAccounts = vi
|
||||||
|
.spyOn(locks, "lockAccounts")
|
||||||
|
.mockImplementation(async (client, ids) => {
|
||||||
|
const before = tx.$executeRaw.mock.calls.length;
|
||||||
|
await realLockAccounts(client, ids);
|
||||||
|
const queries = tx.$executeRaw.mock.calls.slice(before) as Array<
|
||||||
|
[TemplateStringsArray, string]
|
||||||
|
>;
|
||||||
|
locked.push(queries.map(([, value]) => value.slice("safety:".length)));
|
||||||
|
});
|
||||||
|
const service = new MatchService(prisma, candidates, locks);
|
||||||
|
await expect(service.pick(picker, "request")).rejects.toMatchObject({
|
||||||
|
code: ErrorCode.BOTTLE_POOL_EMPTY,
|
||||||
|
});
|
||||||
|
expect(tx.bottle.findFirst).toHaveBeenCalledTimes(2);
|
||||||
|
expect(lockAccounts).toHaveBeenCalledTimes(2);
|
||||||
|
expect(locked).toEqual([[authorA, picker, authorB], [picker]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
import { randomBytes, randomInt, randomUUID } from "node:crypto";
|
||||||
|
import { HttpStatus, Inject, Injectable } from "@nestjs/common";
|
||||||
|
import { ErrorCode } from "@drift/contracts";
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import { DomainException } from "../common/domain.exception.js";
|
||||||
|
import { PrismaService } from "../database/prisma.service.js";
|
||||||
|
import { utc8UsageDate } from "../bottle/usage-date.js";
|
||||||
|
import {
|
||||||
|
decryptLeaseToken,
|
||||||
|
encryptLeaseToken,
|
||||||
|
leaseHmac,
|
||||||
|
} from "../auth/auth.config.js";
|
||||||
|
import { CandidateRepository, type Candidate } from "./candidate.repository.js";
|
||||||
|
import {
|
||||||
|
CandidateBatchContended,
|
||||||
|
classifyPickError,
|
||||||
|
readMatchRetryConfig,
|
||||||
|
transactionLimits,
|
||||||
|
} from "./match-retry-policy.js";
|
||||||
|
import { SafetyLockService } from "../safety/safety-lock.service.js";
|
||||||
|
|
||||||
|
type LeaseWithBottle = {
|
||||||
|
id: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
leaseTokenCiphertext: Uint8Array;
|
||||||
|
bottle: {
|
||||||
|
id: string;
|
||||||
|
authorId: string;
|
||||||
|
contentText: string;
|
||||||
|
author: {
|
||||||
|
anonymousProfile: {
|
||||||
|
publicId: string;
|
||||||
|
nickname: string;
|
||||||
|
avatarColor: string;
|
||||||
|
bio: string | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function positiveInt(
|
||||||
|
value: string | undefined,
|
||||||
|
fallback: number,
|
||||||
|
name: string,
|
||||||
|
) {
|
||||||
|
const parsed = value === undefined ? fallback : Number(value);
|
||||||
|
if (!Number.isInteger(parsed) || parsed <= 0)
|
||||||
|
throw new Error(`${name} must be a positive integer`);
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MatchService {
|
||||||
|
private readonly ttlMs = positiveInt(
|
||||||
|
process.env.BOTTLE_LEASE_TTL_MS,
|
||||||
|
300_000,
|
||||||
|
"BOTTLE_LEASE_TTL_MS",
|
||||||
|
);
|
||||||
|
private readonly retry = readMatchRetryConfig(process.env);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||||
|
@Inject(CandidateRepository)
|
||||||
|
private readonly candidates: CandidateRepository,
|
||||||
|
@Inject(SafetyLockService) private readonly locks: SafetyLockService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async pick(pickerId: string, requestId: string) {
|
||||||
|
const deadline = performance.now() + this.retry.budgetMs;
|
||||||
|
for (let attempt = 0; attempt < this.retry.maxAttempts; attempt += 1) {
|
||||||
|
const limits = transactionLimits(this.retry, performance.now(), deadline);
|
||||||
|
if (!limits) break;
|
||||||
|
try {
|
||||||
|
const result = await this.prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
const previous = await this.findPrevious(tx, pickerId, requestId);
|
||||||
|
if (previous?.lease) {
|
||||||
|
await this.locks.lockAccounts(tx, [
|
||||||
|
pickerId,
|
||||||
|
previous.lease.bottle.authorId,
|
||||||
|
]);
|
||||||
|
await this.assertPairAllowed(
|
||||||
|
tx,
|
||||||
|
pickerId,
|
||||||
|
previous.lease.bottle.authorId,
|
||||||
|
);
|
||||||
|
return this.replayResponse(previous.lease);
|
||||||
|
}
|
||||||
|
await this.assertEligible(tx, pickerId);
|
||||||
|
const usageDate = utc8UsageDate(new Date());
|
||||||
|
const batch = await this.candidates.findBatch(tx, pickerId);
|
||||||
|
if (!batch.length) {
|
||||||
|
// An in-flight same-key winner may have claimed the sole bottle
|
||||||
|
// without committing its request row yet. Let it commit before
|
||||||
|
// deciding this request actually saw an empty pool.
|
||||||
|
await this.locks.lockAccounts(tx, [pickerId]);
|
||||||
|
if (await this.findPrevious(tx, pickerId, requestId))
|
||||||
|
throw new CandidateBatchContended();
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.BOTTLE_POOL_EMPTY,
|
||||||
|
"Bottle pool empty",
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Lock the entire subject set once, in global order. Locking pairs
|
||||||
|
// across candidates could otherwise form A→B / B→A cycles. This
|
||||||
|
// trades wider short-lived locks for correctness; the concurrent
|
||||||
|
// sole-bottle and same-key retry E2E cases exercise contention.
|
||||||
|
await this.locks.lockAccounts(tx, [
|
||||||
|
pickerId,
|
||||||
|
...batch.map((candidate) => candidate.authorId),
|
||||||
|
]);
|
||||||
|
while (batch.length) {
|
||||||
|
const index = Math.floor(Math.random() * batch.length);
|
||||||
|
const candidate = batch.splice(index, 1)[0]!;
|
||||||
|
const result = await this.claim(
|
||||||
|
tx,
|
||||||
|
pickerId,
|
||||||
|
requestId,
|
||||||
|
usageDate,
|
||||||
|
candidate,
|
||||||
|
);
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
throw new CandidateBatchContended();
|
||||||
|
},
|
||||||
|
{
|
||||||
|
isolationLevel: "ReadCommitted",
|
||||||
|
...limits,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DomainException) {
|
||||||
|
if (error.code === ErrorCode.BOTTLE_POOL_EMPTY) {
|
||||||
|
try {
|
||||||
|
const winner = await this.readWinnerUntil(
|
||||||
|
pickerId,
|
||||||
|
requestId,
|
||||||
|
Math.min(deadline, performance.now() + 250),
|
||||||
|
);
|
||||||
|
if (winner) return winner;
|
||||||
|
} catch (readError) {
|
||||||
|
if (classifyPickError(readError) === "SERVICE_UNAVAILABLE")
|
||||||
|
throw error;
|
||||||
|
throw readError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const action = classifyPickError(error);
|
||||||
|
if (action === "THROW") throw error;
|
||||||
|
if (action === "SERVICE_UNAVAILABLE") break;
|
||||||
|
if (action === "READ_WINNER") {
|
||||||
|
try {
|
||||||
|
const winner = await this.readWinner(pickerId, requestId, deadline);
|
||||||
|
if (winner) return winner;
|
||||||
|
} catch (readError) {
|
||||||
|
if (classifyPickError(readError) === "SERVICE_UNAVAILABLE") break;
|
||||||
|
throw readError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const remaining = deadline - performance.now();
|
||||||
|
if (remaining <= 1) break;
|
||||||
|
await new Promise((resolve) =>
|
||||||
|
setTimeout(
|
||||||
|
resolve,
|
||||||
|
Math.min(
|
||||||
|
randomInt(5, 21) * 2 ** Math.min(attempt, 3),
|
||||||
|
100,
|
||||||
|
remaining,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.SERVICE_UNAVAILABLE,
|
||||||
|
"Bottle pick temporarily unavailable",
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private findPrevious(
|
||||||
|
client: Prisma.TransactionClient | PrismaService,
|
||||||
|
pickerId: string,
|
||||||
|
requestId: string,
|
||||||
|
) {
|
||||||
|
return client.bottlePickRequest.findUnique({
|
||||||
|
where: { pickerId_requestId: { pickerId, requestId } },
|
||||||
|
include: {
|
||||||
|
lease: {
|
||||||
|
include: {
|
||||||
|
bottle: {
|
||||||
|
include: { author: { include: { anonymousProfile: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readWinner(
|
||||||
|
pickerId: string,
|
||||||
|
requestId: string,
|
||||||
|
deadline: number,
|
||||||
|
) {
|
||||||
|
const limits = transactionLimits(this.retry, performance.now(), deadline);
|
||||||
|
if (!limits) return null;
|
||||||
|
const result = await this.prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
const previous = await this.findPrevious(tx, pickerId, requestId);
|
||||||
|
if (!previous?.lease) return null;
|
||||||
|
await this.locks.lockAccounts(tx, [
|
||||||
|
pickerId,
|
||||||
|
previous.lease.bottle.authorId,
|
||||||
|
]);
|
||||||
|
await this.assertPairAllowed(
|
||||||
|
tx,
|
||||||
|
pickerId,
|
||||||
|
previous.lease.bottle.authorId,
|
||||||
|
);
|
||||||
|
return this.replayResponse(previous.lease);
|
||||||
|
},
|
||||||
|
{ isolationLevel: "ReadCommitted", ...limits },
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readWinnerUntil(
|
||||||
|
pickerId: string,
|
||||||
|
requestId: string,
|
||||||
|
deadline: number,
|
||||||
|
) {
|
||||||
|
while (performance.now() < deadline) {
|
||||||
|
const winner = await this.readWinner(pickerId, requestId, deadline);
|
||||||
|
if (winner) return winner;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async claim(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
pickerId: string,
|
||||||
|
requestId: string,
|
||||||
|
usageDate: string,
|
||||||
|
candidate: Candidate,
|
||||||
|
) {
|
||||||
|
await this.assertPairAllowed(tx, pickerId, candidate.authorId);
|
||||||
|
const eligible = await tx.bottle.findFirst({
|
||||||
|
where: {
|
||||||
|
id: candidate.id,
|
||||||
|
authorId: candidate.authorId,
|
||||||
|
version: candidate.version,
|
||||||
|
reviewStatus: "APPROVED",
|
||||||
|
poolStatus: "IN_POOL",
|
||||||
|
activeLeaseId: null,
|
||||||
|
author: { anonymousProfile: { reviewStatus: "APPROVED" } },
|
||||||
|
pickHistory: { none: { pickerId } },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!eligible) return null;
|
||||||
|
const token = randomBytes(32).toString("base64url");
|
||||||
|
const expiresAt = new Date(Date.now() + this.ttlMs);
|
||||||
|
const leaseId = randomUUID();
|
||||||
|
const claimed = await tx.bottle.updateMany({
|
||||||
|
where: {
|
||||||
|
id: candidate.id,
|
||||||
|
version: candidate.version,
|
||||||
|
poolStatus: "IN_POOL",
|
||||||
|
activeLeaseId: null,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
poolStatus: "LEASED",
|
||||||
|
activeLeaseId: leaseId,
|
||||||
|
version: { increment: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (claimed.count !== 1) return null;
|
||||||
|
const lease = await tx.bottlePickLease.create({
|
||||||
|
data: {
|
||||||
|
id: leaseId,
|
||||||
|
bottleId: candidate.id,
|
||||||
|
pickerId,
|
||||||
|
leaseTokenHash: leaseHmac(token),
|
||||||
|
leaseTokenCiphertext: encryptLeaseToken(token, leaseId),
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.bottlePickHistory.create({
|
||||||
|
data: { bottleId: candidate.id, pickerId },
|
||||||
|
});
|
||||||
|
const rows = await tx.$queryRaw<Array<{ bottles_picked: number }>>`
|
||||||
|
INSERT INTO "daily_usage" ("id", "account_id", "usage_date", "bottles_picked", "updated_at")
|
||||||
|
VALUES (gen_random_uuid(), ${pickerId}::uuid, ${usageDate}::date, 1, now())
|
||||||
|
ON CONFLICT ("account_id", "usage_date") DO UPDATE
|
||||||
|
SET "bottles_picked" = "daily_usage"."bottles_picked" + 1, "updated_at" = now()
|
||||||
|
WHERE "daily_usage"."bottles_picked" < 20
|
||||||
|
RETURNING "bottles_picked"`;
|
||||||
|
if (!rows.length) this.limit();
|
||||||
|
await tx.bottlePickRequest.create({
|
||||||
|
data: { pickerId, requestId, leaseId: lease.id },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
bottle: { id: candidate.id, contentText: candidate.contentText },
|
||||||
|
author: {
|
||||||
|
publicId: candidate.publicId,
|
||||||
|
nickname: candidate.nickname,
|
||||||
|
avatarColor: candidate.avatarColor,
|
||||||
|
bio: candidate.bio,
|
||||||
|
},
|
||||||
|
lease: { id: lease.id, token, expiresAt },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertEligible(tx: Prisma.TransactionClient, pickerId: string) {
|
||||||
|
const now = new Date();
|
||||||
|
const account = await tx.account.findUnique({
|
||||||
|
where: { id: pickerId },
|
||||||
|
select: { status: true },
|
||||||
|
});
|
||||||
|
const sanction = await tx.sanction.findFirst({
|
||||||
|
where: {
|
||||||
|
accountId: pickerId,
|
||||||
|
type: { in: ["SUSPENSION", "BAN"] },
|
||||||
|
revokedAt: null,
|
||||||
|
startsAt: { lte: now },
|
||||||
|
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (account?.status !== "ACTIVE" || sanction)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.ACCOUNT_SANCTIONED,
|
||||||
|
"Account sanctioned",
|
||||||
|
HttpStatus.FORBIDDEN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertPairAllowed(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
pickerId: string,
|
||||||
|
authorId: string,
|
||||||
|
) {
|
||||||
|
const now = new Date();
|
||||||
|
const [accounts, block, sanction] = await Promise.all([
|
||||||
|
tx.account.count({
|
||||||
|
where: { id: { in: [pickerId, authorId] }, status: "ACTIVE" },
|
||||||
|
}),
|
||||||
|
tx.block.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ blockerId: pickerId, blockedId: authorId },
|
||||||
|
{ blockerId: authorId, blockedId: pickerId },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
}),
|
||||||
|
tx.sanction.findFirst({
|
||||||
|
where: {
|
||||||
|
accountId: { in: [pickerId, authorId] },
|
||||||
|
type: { in: ["SUSPENSION", "BAN"] },
|
||||||
|
revokedAt: null,
|
||||||
|
startsAt: { lte: now },
|
||||||
|
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (block)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.USER_BLOCKED,
|
||||||
|
"User blocked",
|
||||||
|
HttpStatus.FORBIDDEN,
|
||||||
|
);
|
||||||
|
if (accounts !== 2 || sanction)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.ACCOUNT_SANCTIONED,
|
||||||
|
"Account sanctioned",
|
||||||
|
HttpStatus.FORBIDDEN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private replayResponse(lease: LeaseWithBottle) {
|
||||||
|
try {
|
||||||
|
return this.response(
|
||||||
|
lease,
|
||||||
|
decryptLeaseToken(lease.leaseTokenCiphertext, lease.id),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DomainException) throw error;
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.SERVICE_UNAVAILABLE,
|
||||||
|
"Bottle pick temporarily unavailable",
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async returnBottle(
|
||||||
|
pickerId: string,
|
||||||
|
bottleId: string,
|
||||||
|
leaseId: string,
|
||||||
|
token: string,
|
||||||
|
) {
|
||||||
|
const tokenHash = leaseHmac(token);
|
||||||
|
const hidden = () =>
|
||||||
|
new DomainException(
|
||||||
|
ErrorCode.NOT_FOUND,
|
||||||
|
"Not Found",
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
);
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const rows = await tx.$queryRaw<
|
||||||
|
Array<{ id: string; status: string; expiresAt: Date }>
|
||||||
|
>`
|
||||||
|
SELECT "id", "status", "expires_at" AS "expiresAt"
|
||||||
|
FROM "bottle_pick_leases"
|
||||||
|
WHERE "id" = ${leaseId}::uuid AND "bottle_id" = ${bottleId}::uuid
|
||||||
|
AND "picker_id" = ${pickerId}::uuid AND "lease_token_hash" = ${tokenHash}
|
||||||
|
FOR UPDATE`;
|
||||||
|
const lease = rows[0];
|
||||||
|
if (!lease) throw hidden();
|
||||||
|
if (lease.status === "RETURNED")
|
||||||
|
return { bottleId, leaseId, status: "RETURNED" as const };
|
||||||
|
if (lease.status !== "ACTIVE") throw hidden();
|
||||||
|
if (lease.expiresAt <= new Date())
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.BOTTLE_LEASE_EXPIRED,
|
||||||
|
"Bottle lease expired",
|
||||||
|
HttpStatus.GONE,
|
||||||
|
);
|
||||||
|
const released = await tx.bottle.updateMany({
|
||||||
|
where: { id: bottleId, poolStatus: "LEASED", activeLeaseId: leaseId },
|
||||||
|
data: {
|
||||||
|
poolStatus: "IN_POOL",
|
||||||
|
activeLeaseId: null,
|
||||||
|
version: { increment: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (released.count !== 1) throw hidden();
|
||||||
|
await tx.bottlePickLease.update({
|
||||||
|
where: { id: leaseId },
|
||||||
|
data: { status: "RETURNED", endedAt: new Date() },
|
||||||
|
});
|
||||||
|
return { bottleId, leaseId, status: "RETURNED" as const };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private response(lease: LeaseWithBottle, token: string) {
|
||||||
|
const profile = lease.bottle.author.anonymousProfile;
|
||||||
|
if (!profile)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.BOTTLE_POOL_EMPTY,
|
||||||
|
"Bottle pool empty",
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
bottle: { id: lease.bottle.id, contentText: lease.bottle.contentText },
|
||||||
|
author: {
|
||||||
|
publicId: profile.publicId,
|
||||||
|
nickname: profile.nickname,
|
||||||
|
avatarColor: profile.avatarColor,
|
||||||
|
bio: profile.bio,
|
||||||
|
},
|
||||||
|
lease: { id: lease.id, token, expiresAt: lease.expiresAt },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private limit(): never {
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.BOTTLE_DAILY_LIMIT,
|
||||||
|
"Daily pick limit reached",
|
||||||
|
HttpStatus.TOO_MANY_REQUESTS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Inject,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { AuthGuard } from "../auth/auth.guard.js";
|
||||||
|
import { CurrentUser } from "../auth/current-user.decorator.js";
|
||||||
|
import type { AccessClaims } from "../auth/token.service.js";
|
||||||
|
import { StateChangingOriginGuard } from "../conversation/state-changing-origin.guard.js";
|
||||||
|
import {
|
||||||
|
NotificationQueryDto,
|
||||||
|
PushPreferenceDto,
|
||||||
|
ReadNotificationsDto,
|
||||||
|
} from "../safety/dto.js";
|
||||||
|
import { NotificationService } from "./notification.service.js";
|
||||||
|
@Controller()
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class NotificationController {
|
||||||
|
constructor(
|
||||||
|
@Inject(NotificationService)
|
||||||
|
private readonly notifications: NotificationService,
|
||||||
|
) {}
|
||||||
|
@Get("notifications") list(
|
||||||
|
@CurrentUser() u: AccessClaims,
|
||||||
|
@Query() q: NotificationQueryDto,
|
||||||
|
) {
|
||||||
|
return this.notifications.list(u.sub, q.cursor, q.limit);
|
||||||
|
}
|
||||||
|
@UseGuards(StateChangingOriginGuard) @Post("notifications/read") read(
|
||||||
|
@CurrentUser() u: AccessClaims,
|
||||||
|
@Body() d: ReadNotificationsDto,
|
||||||
|
) {
|
||||||
|
return this.notifications.read(u.sub, d.ids);
|
||||||
|
}
|
||||||
|
@Get("settings/push") pref(@CurrentUser() u: AccessClaims) {
|
||||||
|
return this.notifications.preference(u.sub);
|
||||||
|
}
|
||||||
|
@UseGuards(StateChangingOriginGuard) @Put("settings/push") update(
|
||||||
|
@CurrentUser() u: AccessClaims,
|
||||||
|
@Body() d: PushPreferenceDto,
|
||||||
|
) {
|
||||||
|
return this.notifications.updatePreference(u.sub, d.inAppEnabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Reflect.defineMetadata(
|
||||||
|
"design:paramtypes",
|
||||||
|
[Object, NotificationQueryDto],
|
||||||
|
NotificationController.prototype,
|
||||||
|
"list",
|
||||||
|
);
|
||||||
|
Reflect.defineMetadata(
|
||||||
|
"design:paramtypes",
|
||||||
|
[Object, ReadNotificationsDto],
|
||||||
|
NotificationController.prototype,
|
||||||
|
"read",
|
||||||
|
);
|
||||||
|
Reflect.defineMetadata(
|
||||||
|
"design:paramtypes",
|
||||||
|
[Object],
|
||||||
|
NotificationController.prototype,
|
||||||
|
"pref",
|
||||||
|
);
|
||||||
|
Reflect.defineMetadata(
|
||||||
|
"design:paramtypes",
|
||||||
|
[Object, PushPreferenceDto],
|
||||||
|
NotificationController.prototype,
|
||||||
|
"update",
|
||||||
|
);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { AuthModule } from "../auth/auth.module.js";
|
||||||
|
import { DatabaseModule } from "../database/database.module.js";
|
||||||
|
import { StateChangingOriginGuard } from "../conversation/state-changing-origin.guard.js";
|
||||||
|
import { NotificationController } from "./notification.controller.js";
|
||||||
|
import { NotificationService } from "./notification.service.js";
|
||||||
|
@Module({
|
||||||
|
imports: [DatabaseModule, AuthModule],
|
||||||
|
controllers: [NotificationController],
|
||||||
|
providers: [NotificationService, StateChangingOriginGuard],
|
||||||
|
exports: [NotificationService],
|
||||||
|
})
|
||||||
|
export class NotificationModule {}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { HttpStatus, Inject, Injectable } from "@nestjs/common";
|
||||||
|
import { ErrorCode } from "@drift/contracts";
|
||||||
|
import { PrismaService } from "../database/prisma.service.js";
|
||||||
|
import { DomainException } from "../common/domain.exception.js";
|
||||||
|
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||||
|
type Db = Prisma.TransactionClient | PrismaClient;
|
||||||
|
@Injectable()
|
||||||
|
export class NotificationService {
|
||||||
|
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
|
||||||
|
async createInApp(
|
||||||
|
tx: Db,
|
||||||
|
accountId: string,
|
||||||
|
dedupeKey: string,
|
||||||
|
type: string,
|
||||||
|
payload: Prisma.InputJsonObject,
|
||||||
|
) {
|
||||||
|
const preference = await tx.pushPreference.findUnique({
|
||||||
|
where: { accountId },
|
||||||
|
select: { inAppEnabled: true },
|
||||||
|
});
|
||||||
|
if (preference?.inAppEnabled === false) return null;
|
||||||
|
return tx.notification.upsert({
|
||||||
|
where: { accountId_dedupeKey: { accountId, dedupeKey } },
|
||||||
|
create: {
|
||||||
|
accountId,
|
||||||
|
dedupeKey,
|
||||||
|
type,
|
||||||
|
payload,
|
||||||
|
status: "PENDING",
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async notifyAdmins(tx: Db, reportId: string) {
|
||||||
|
const admins = await tx.account.findMany({
|
||||||
|
where: {
|
||||||
|
role: "ADMIN",
|
||||||
|
status: "ACTIVE",
|
||||||
|
OR: [
|
||||||
|
{ pushPreference: null },
|
||||||
|
{ pushPreference: { is: { inAppEnabled: true } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
await Promise.all(
|
||||||
|
admins.map(({ id }) =>
|
||||||
|
this.createInApp(
|
||||||
|
tx,
|
||||||
|
id,
|
||||||
|
`report:${reportId}:admin-pending`,
|
||||||
|
"ADMIN_REPORT_PENDING",
|
||||||
|
{ reportId },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async list(accountId: string, cursor: string | undefined, limit: number) {
|
||||||
|
const preference = await this.prisma.pushPreference.findUnique({
|
||||||
|
where: { accountId },
|
||||||
|
select: { inAppEnabled: true },
|
||||||
|
});
|
||||||
|
if (preference?.inAppEnabled === false)
|
||||||
|
return { items: [], nextCursor: null, unreadCount: 0 };
|
||||||
|
let decoded: { t: string; id: string } | undefined;
|
||||||
|
try {
|
||||||
|
if (cursor) {
|
||||||
|
const value: unknown = JSON.parse(
|
||||||
|
Buffer.from(cursor, "base64url").toString("utf8"),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
typeof value !== "object" ||
|
||||||
|
value === null ||
|
||||||
|
Array.isArray(value) ||
|
||||||
|
Object.keys(value).sort().join(",") !== "id,t" ||
|
||||||
|
!("t" in value) ||
|
||||||
|
typeof value.t !== "string" ||
|
||||||
|
!("id" in value) ||
|
||||||
|
typeof value.id !== "string" ||
|
||||||
|
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||||
|
value.id,
|
||||||
|
) ||
|
||||||
|
!Number.isFinite(Date.parse(value.t)) ||
|
||||||
|
new Date(value.t).toISOString() !== value.t
|
||||||
|
)
|
||||||
|
throw new Error("Invalid cursor");
|
||||||
|
decoded = { t: value.t, id: value.id };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.VALIDATION_ERROR,
|
||||||
|
"Invalid cursor",
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const date = decoded ? new Date(decoded.t) : undefined;
|
||||||
|
const [rows, unreadCount] = await Promise.all([
|
||||||
|
this.prisma.notification.findMany({
|
||||||
|
where: {
|
||||||
|
accountId,
|
||||||
|
status: { in: ["SENT", "READ"] },
|
||||||
|
...(decoded && date
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ createdAt: { lt: date } },
|
||||||
|
{ createdAt: date, id: { lt: decoded.id } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||||
|
take: limit + 1,
|
||||||
|
}),
|
||||||
|
this.prisma.notification.count({
|
||||||
|
where: { accountId, status: "SENT", readAt: null },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const page = rows.slice(0, limit);
|
||||||
|
return {
|
||||||
|
unreadCount,
|
||||||
|
items: page.map((x) => ({
|
||||||
|
id: x.id,
|
||||||
|
type: x.type,
|
||||||
|
payload: x.payload,
|
||||||
|
status: x.status,
|
||||||
|
readAt: x.readAt,
|
||||||
|
createdAt: x.createdAt,
|
||||||
|
})),
|
||||||
|
nextCursor:
|
||||||
|
rows.length > limit && page.length
|
||||||
|
? Buffer.from(
|
||||||
|
JSON.stringify({
|
||||||
|
t: page.at(-1)!.createdAt.toISOString(),
|
||||||
|
id: page.at(-1)!.id,
|
||||||
|
}),
|
||||||
|
).toString("base64url")
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async read(accountId: string, ids: string[]) {
|
||||||
|
const owned = await this.prisma.notification.count({
|
||||||
|
where: { accountId, id: { in: ids }, status: { in: ["SENT", "READ"] } },
|
||||||
|
});
|
||||||
|
if (owned !== new Set(ids).size)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.NOT_FOUND,
|
||||||
|
"Not found",
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
);
|
||||||
|
await this.prisma.notification.updateMany({
|
||||||
|
where: { accountId, id: { in: ids }, status: { in: ["SENT", "READ"] } },
|
||||||
|
data: { status: "READ", readAt: new Date() },
|
||||||
|
});
|
||||||
|
return { read: owned };
|
||||||
|
}
|
||||||
|
async preference(accountId: string) {
|
||||||
|
const row = await this.prisma.pushPreference.findUnique({
|
||||||
|
where: { accountId },
|
||||||
|
});
|
||||||
|
return { inAppEnabled: row?.inAppEnabled ?? true };
|
||||||
|
}
|
||||||
|
async updatePreference(accountId: string, inAppEnabled: boolean) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
// The worker locks notification -> account -> preference. A settings
|
||||||
|
// change has no notification row; it locks account -> preference too.
|
||||||
|
// Account locking covers the initially absent preference row.
|
||||||
|
await tx.$queryRaw`SELECT "id" FROM "accounts" WHERE "id"=${accountId}::uuid FOR UPDATE`;
|
||||||
|
return tx.pushPreference.upsert({
|
||||||
|
where: { accountId },
|
||||||
|
create: { accountId, inAppEnabled },
|
||||||
|
update: { inAppEnabled },
|
||||||
|
select: { inAppEnabled: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,9 @@ process.env.PHONE_ENCRYPTION_KEY =
|
|||||||
process.env.PHONE_HMAC_KEY = "test-phone-hmac-key-with-at-least-32-bytes";
|
process.env.PHONE_HMAC_KEY = "test-phone-hmac-key-with-at-least-32-bytes";
|
||||||
process.env.VERIFICATION_CODE_HMAC_KEY =
|
process.env.VERIFICATION_CODE_HMAC_KEY =
|
||||||
"test-code-hmac-key-with-at-least-32-bytes";
|
"test-code-hmac-key-with-at-least-32-bytes";
|
||||||
|
process.env.LEASE_TOKEN_HMAC_KEY = "test-lease-hmac-key-with-at-least-32-bytes";
|
||||||
|
process.env.LEASE_TOKEN_ENCRYPTION_KEY =
|
||||||
|
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=";
|
||||||
process.env.JWT_SECRET = "test-jwt-secret-with-at-least-thirty-two-bytes";
|
process.env.JWT_SECRET = "test-jwt-secret-with-at-least-thirty-two-bytes";
|
||||||
process.env.REFRESH_TOKEN_HMAC_KEY =
|
process.env.REFRESH_TOKEN_HMAC_KEY =
|
||||||
"test-refresh-hmac-key-with-at-least-32-bytes";
|
"test-refresh-hmac-key-with-at-least-32-bytes";
|
||||||
|
|||||||
@@ -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,53 @@
|
|||||||
|
import { Transform, Type } from "class-transformer";
|
||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
IsArray,
|
||||||
|
IsEnum,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Matches,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
const trim = ({ value }: { value: unknown }) =>
|
||||||
|
typeof value === "string" ? value.trim() : value;
|
||||||
|
export enum ReportTargetType {
|
||||||
|
ACCOUNT = "ACCOUNT",
|
||||||
|
BOTTLE = "BOTTLE",
|
||||||
|
CONVERSATION = "CONVERSATION",
|
||||||
|
MESSAGE = "MESSAGE",
|
||||||
|
}
|
||||||
|
export enum ReportReason {
|
||||||
|
HARASSMENT = "HARASSMENT",
|
||||||
|
SPAM = "SPAM",
|
||||||
|
SEXUAL = "SEXUAL",
|
||||||
|
VIOLENCE = "VIOLENCE",
|
||||||
|
FRAUD = "FRAUD",
|
||||||
|
OTHER = "OTHER",
|
||||||
|
}
|
||||||
|
export class CreateReportDto {
|
||||||
|
@IsEnum(ReportTargetType) targetType!: ReportTargetType;
|
||||||
|
@IsUUID() targetId!: string;
|
||||||
|
@IsEnum(ReportReason) reason!: ReportReason;
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(trim)
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(1000)
|
||||||
|
details?: string;
|
||||||
|
}
|
||||||
|
export class ReadNotificationsDto {
|
||||||
|
@IsArray() @ArrayMaxSize(100) @IsUUID("4", { each: true }) ids!: string[];
|
||||||
|
}
|
||||||
|
export class NotificationQueryDto {
|
||||||
|
@IsOptional() @Matches(/^[A-Za-z0-9_-]{1,512}$/) cursor?: string;
|
||||||
|
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) limit = 20;
|
||||||
|
}
|
||||||
|
export class PushPreferenceDto {
|
||||||
|
@IsEnum([true, false]) inAppEnabled!: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,990 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||||
|
import "reflect-metadata";
|
||||||
|
import { type INestApplication } from "@nestjs/common";
|
||||||
|
import { Test } from "@nestjs/testing";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import request from "supertest";
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { AppModule } from "../app.module.js";
|
||||||
|
import { NotificationProcessor } from "../../../worker/src/notification.processor.js";
|
||||||
|
import { TokenService } from "../auth/token.service.js";
|
||||||
|
import { configureApp } from "../main.js";
|
||||||
|
import { assertSafeTestDatabaseUrl } from "../../../../prisma/database-safety.js";
|
||||||
|
|
||||||
|
process.env.NODE_ENV = "test";
|
||||||
|
process.env.WEB_ORIGIN = "http://localhost:3000";
|
||||||
|
process.env.PHONE_ENCRYPTION_KEY =
|
||||||
|
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||||
|
process.env.PHONE_HMAC_KEY = "test-phone-hmac-key-with-at-least-32-bytes";
|
||||||
|
process.env.VERIFICATION_CODE_HMAC_KEY =
|
||||||
|
"test-code-hmac-key-with-at-least-32-bytes";
|
||||||
|
process.env.LEASE_TOKEN_HMAC_KEY = "test-lease-hmac-key-with-at-least-32-bytes";
|
||||||
|
process.env.LEASE_TOKEN_ENCRYPTION_KEY =
|
||||||
|
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=";
|
||||||
|
process.env.JWT_SECRET = "test-jwt-secret-with-at-least-thirty-two-bytes";
|
||||||
|
process.env.REFRESH_TOKEN_HMAC_KEY =
|
||||||
|
"test-refresh-hmac-key-with-at-least-32-bytes";
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const origin = { Origin: process.env.WEB_ORIGIN };
|
||||||
|
|
||||||
|
type Actor = { id: string; publicId: string; authorization: string };
|
||||||
|
|
||||||
|
describe("safety, notification and admin governance", () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let alice: Actor;
|
||||||
|
let bob: Actor;
|
||||||
|
let admin: Actor;
|
||||||
|
let conversationId: string;
|
||||||
|
let messageId: string;
|
||||||
|
|
||||||
|
async function actor(
|
||||||
|
name: string,
|
||||||
|
role: "USER" | "ADMIN" = "USER",
|
||||||
|
): Promise<Actor> {
|
||||||
|
const account = await prisma.account.create({
|
||||||
|
data: {
|
||||||
|
phoneCiphertext: Buffer.from(`cipher-${name}`),
|
||||||
|
phoneHmac: randomUUID(),
|
||||||
|
role,
|
||||||
|
anonymousProfile: {
|
||||||
|
create: {
|
||||||
|
nickname: name,
|
||||||
|
avatarColor: "#66CCFF",
|
||||||
|
reviewStatus: "APPROVED",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { anonymousProfile: true },
|
||||||
|
});
|
||||||
|
const session = await prisma.session.create({
|
||||||
|
data: {
|
||||||
|
accountId: account.id,
|
||||||
|
refreshTokenHash: randomUUID(),
|
||||||
|
deviceId: name.padEnd(8, "x"),
|
||||||
|
tokenVersion: 0,
|
||||||
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
|
tokenFamily: randomUUID(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const token = app.get(TokenService).issueAccess({
|
||||||
|
sub: account.id,
|
||||||
|
session_id: session.id,
|
||||||
|
device_id: name.padEnd(8, "x"),
|
||||||
|
scopes: ["user"],
|
||||||
|
token_version: 0,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: account.id,
|
||||||
|
publicId: account.anonymousProfile!.publicId,
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
assertSafeTestDatabaseUrl(process.env.DATABASE_URL ?? "");
|
||||||
|
await prisma.$connect();
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
app = module.createNestApplication();
|
||||||
|
configureApp(app);
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.outboxEvent.deleteMany();
|
||||||
|
await prisma.$executeRawUnsafe('TRUNCATE TABLE "accounts" CASCADE');
|
||||||
|
alice = await actor("alice");
|
||||||
|
bob = await actor("bob");
|
||||||
|
admin = await actor("admin", "ADMIN");
|
||||||
|
const bottle = await prisma.bottle.create({
|
||||||
|
data: {
|
||||||
|
authorId: alice.id,
|
||||||
|
contentText: "private bottle",
|
||||||
|
reviewStatus: "APPROVED",
|
||||||
|
poolStatus: "CONSUMED",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const conversation = await prisma.conversation.create({
|
||||||
|
data: {
|
||||||
|
sourceBottleId: bottle.id,
|
||||||
|
nextSeq: 2n,
|
||||||
|
members: {
|
||||||
|
create: [
|
||||||
|
{ accountId: alice.id, peerAliasSnapshot: "bob" },
|
||||||
|
{ accountId: bob.id, peerAliasSnapshot: "alice" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
create: {
|
||||||
|
senderId: bob.id,
|
||||||
|
senderPublicId: bob.publicId,
|
||||||
|
clientMsgId: randomUUID(),
|
||||||
|
seq: 1n,
|
||||||
|
contentText: "reported secret",
|
||||||
|
reviewStatus: "APPROVED",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { messages: true },
|
||||||
|
});
|
||||||
|
conversationId = conversation.id;
|
||||||
|
messageId = conversation.messages[0]!.id;
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await app?.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates an authorized immutable message report exactly once without leaking body to outbox/audit", async () => {
|
||||||
|
const key = randomUUID();
|
||||||
|
const body = {
|
||||||
|
targetType: "MESSAGE",
|
||||||
|
targetId: messageId,
|
||||||
|
reason: "HARASSMENT",
|
||||||
|
details: "please review",
|
||||||
|
};
|
||||||
|
const first = await request(
|
||||||
|
app.getHttpServer() as Parameters<typeof request>[0],
|
||||||
|
)
|
||||||
|
.post("/api/v1/reports")
|
||||||
|
.set("Authorization", alice.authorization)
|
||||||
|
.set(origin)
|
||||||
|
.set("Idempotency-Key", key)
|
||||||
|
.send(body)
|
||||||
|
.expect(201);
|
||||||
|
const retry = await request(
|
||||||
|
app.getHttpServer() as Parameters<typeof request>[0],
|
||||||
|
)
|
||||||
|
.post("/api/v1/reports")
|
||||||
|
.set("Authorization", alice.authorization)
|
||||||
|
.set(origin)
|
||||||
|
.set("Idempotency-Key", key)
|
||||||
|
.send(body)
|
||||||
|
.expect(201);
|
||||||
|
expect(retry.body.data.id).toBe(first.body.data.id);
|
||||||
|
const report = await prisma.report.findUniqueOrThrow({
|
||||||
|
where: { id: first.body.data.id as string },
|
||||||
|
});
|
||||||
|
expect(report.targetSnapshot).toMatchObject({
|
||||||
|
targetType: "MESSAGE",
|
||||||
|
contentText: "reported secret",
|
||||||
|
senderPublicId: bob.publicId,
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(first.body)).not.toContain("reported secret");
|
||||||
|
expect(
|
||||||
|
await prisma.moderationTask.count({ where: { reportId: report.id } }),
|
||||||
|
).toBe(1);
|
||||||
|
expect(
|
||||||
|
await prisma.auditLog.count({
|
||||||
|
where: { entityId: report.id, action: "REPORT_CREATED" },
|
||||||
|
}),
|
||||||
|
).toBe(1);
|
||||||
|
expect(
|
||||||
|
JSON.stringify(
|
||||||
|
await prisma.auditLog.findMany({ where: { entityId: report.id } }),
|
||||||
|
),
|
||||||
|
).not.toContain("reported secret");
|
||||||
|
const events = await prisma.outboxEvent.findMany({
|
||||||
|
where: { aggregateId: report.id },
|
||||||
|
});
|
||||||
|
expect(events).toHaveLength(0);
|
||||||
|
expect(JSON.stringify(events)).not.toContain("reported secret");
|
||||||
|
await request(app.getHttpServer() as Parameters<typeof request>[0])
|
||||||
|
.post("/api/v1/reports")
|
||||||
|
.set("Authorization", admin.authorization)
|
||||||
|
.set(origin)
|
||||||
|
.set("Idempotency-Key", randomUUID())
|
||||||
|
.send(body)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks only a conversation peer immediately, lists/unblocks by public id, and is idempotent", async () => {
|
||||||
|
const endpoint = `/api/v1/conversations/${conversationId}/block`;
|
||||||
|
await request(app.getHttpServer() as Parameters<typeof request>[0])
|
||||||
|
.post(endpoint)
|
||||||
|
.set("Authorization", alice.authorization)
|
||||||
|
.set(origin)
|
||||||
|
.send({})
|
||||||
|
.expect(201);
|
||||||
|
await request(app.getHttpServer() as Parameters<typeof request>[0])
|
||||||
|
.post(endpoint)
|
||||||
|
.set("Authorization", alice.authorization)
|
||||||
|
.set(origin)
|
||||||
|
.send({})
|
||||||
|
.expect(201);
|
||||||
|
expect(await prisma.block.count()).toBe(1);
|
||||||
|
await request(app.getHttpServer() as Parameters<typeof request>[0])
|
||||||
|
.post(`/api/v1/conversations/${conversationId}/messages/prepare`)
|
||||||
|
.set("Authorization", bob.authorization)
|
||||||
|
.set(origin)
|
||||||
|
.send({ conversationId, clientMsgId: randomUUID(), text: "blocked" })
|
||||||
|
.expect(403);
|
||||||
|
const listed = await request(
|
||||||
|
app.getHttpServer() as Parameters<typeof request>[0],
|
||||||
|
)
|
||||||
|
.get("/api/v1/me/blocks")
|
||||||
|
.set("Authorization", alice.authorization)
|
||||||
|
.expect(200);
|
||||||
|
expect(listed.body.data.items).toEqual([
|
||||||
|
expect.objectContaining({ publicId: bob.publicId }),
|
||||||
|
]);
|
||||||
|
await request(app.getHttpServer() as Parameters<typeof request>[0])
|
||||||
|
.delete(`/api/v1/me/blocks/${bob.publicId}`)
|
||||||
|
.set("Authorization", alice.authorization)
|
||||||
|
.set(origin)
|
||||||
|
.expect(200);
|
||||||
|
expect(await prisma.block.count()).toBe(0);
|
||||||
|
expect(
|
||||||
|
await prisma.conversationMember.count({
|
||||||
|
where: { conversationId, blockedAt: { not: null } },
|
||||||
|
}),
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("audits authorized report and moderation queries without sensitive metadata", async () => {
|
||||||
|
const report = await request(
|
||||||
|
app.getHttpServer() as Parameters<typeof request>[0],
|
||||||
|
)
|
||||||
|
.post("/api/v1/reports")
|
||||||
|
.set("Authorization", alice.authorization)
|
||||||
|
.set(origin)
|
||||||
|
.set("Idempotency-Key", randomUUID())
|
||||||
|
.send({ targetType: "MESSAGE", targetId: messageId, reason: "SPAM" })
|
||||||
|
.expect(201);
|
||||||
|
const reportId = report.body.data.id as string;
|
||||||
|
await request(app.getHttpServer() as Parameters<typeof request>[0])
|
||||||
|
.get("/api/v1/admin/reports?status=PENDING&limit=1")
|
||||||
|
.set("Authorization", bob.authorization)
|
||||||
|
.expect(403);
|
||||||
|
const reports = await request(
|
||||||
|
app.getHttpServer() as Parameters<typeof request>[0],
|
||||||
|
)
|
||||||
|
.get("/api/v1/admin/reports?status=PENDING&limit=1")
|
||||||
|
.set("Authorization", admin.authorization)
|
||||||
|
.expect(200);
|
||||||
|
expect(reports.body.data.items).toHaveLength(1);
|
||||||
|
await request(app.getHttpServer() as Parameters<typeof request>[0])
|
||||||
|
.get("/api/v1/admin/moderation?limit=2")
|
||||||
|
.set("Authorization", bob.authorization)
|
||||||
|
.expect(403);
|
||||||
|
await request(app.getHttpServer() as Parameters<typeof request>[0])
|
||||||
|
.get("/api/v1/admin/moderation?limit=2")
|
||||||
|
.set("Authorization", admin.authorization)
|
||||||
|
.expect(200);
|
||||||
|
const logs = await prisma.auditLog.findMany({
|
||||||
|
where: { action: { in: ["REPORTS_QUERIED", "MODERATION_QUERIED"] } },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
expect(logs).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
logs.map(({ actorId, action, entityType, metadata }) => ({
|
||||||
|
actorId,
|
||||||
|
action,
|
||||||
|
entityType,
|
||||||
|
metadata,
|
||||||
|
})),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
actorId: admin.id,
|
||||||
|
action: "REPORTS_QUERIED",
|
||||||
|
entityType: "REPORT",
|
||||||
|
metadata: { status: "PENDING", limit: 1, resultCount: 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
actorId: admin.id,
|
||||||
|
action: "MODERATION_QUERIED",
|
||||||
|
entityType: "MODERATION_TASK",
|
||||||
|
metadata: { limit: 2, resultCount: 0 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const serialized = JSON.stringify(logs);
|
||||||
|
for (const secret of [
|
||||||
|
"reported secret",
|
||||||
|
"private bottle",
|
||||||
|
bob.id,
|
||||||
|
alice.id,
|
||||||
|
reportId,
|
||||||
|
messageId,
|
||||||
|
])
|
||||||
|
expect(serialized).not.toContain(secret);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires admin, resolves once with sanction, audit and reporter/target notifications", async () => {
|
||||||
|
const report = await request(
|
||||||
|
app.getHttpServer() as Parameters<typeof request>[0],
|
||||||
|
)
|
||||||
|
.post("/api/v1/reports")
|
||||||
|
.set("Authorization", alice.authorization)
|
||||||
|
.set(origin)
|
||||||
|
.set("Idempotency-Key", randomUUID())
|
||||||
|
.send({
|
||||||
|
targetType: "MESSAGE",
|
||||||
|
targetId: messageId,
|
||||||
|
reason: "HARASSMENT",
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
await request(app.getHttpServer() as Parameters<typeof request>[0])
|
||||||
|
.get("/api/v1/admin/reports")
|
||||||
|
.set("Authorization", bob.authorization)
|
||||||
|
.expect(403);
|
||||||
|
const resolved = await request(
|
||||||
|
app.getHttpServer() as Parameters<typeof request>[0],
|
||||||
|
)
|
||||||
|
.post(`/api/v1/admin/reports/${report.body.data.id}/resolve`)
|
||||||
|
.set("Authorization", admin.authorization)
|
||||||
|
.set(origin)
|
||||||
|
.send({
|
||||||
|
decision: "UPHELD",
|
||||||
|
resolution: "confirmed",
|
||||||
|
sanction: {
|
||||||
|
type: "MUTE",
|
||||||
|
reason: "harassment",
|
||||||
|
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
expect(resolved.body.data.status).toBe("RESOLVED");
|
||||||
|
await request(app.getHttpServer() as Parameters<typeof request>[0])
|
||||||
|
.post(`/api/v1/admin/reports/${report.body.data.id}/resolve`)
|
||||||
|
.set("Authorization", admin.authorization)
|
||||||
|
.set(origin)
|
||||||
|
.send({ decision: "DISMISSED", resolution: "second" })
|
||||||
|
.expect(409);
|
||||||
|
expect(
|
||||||
|
await prisma.sanction.count({
|
||||||
|
where: { accountId: bob.id, type: "MUTE" },
|
||||||
|
}),
|
||||||
|
).toBe(1);
|
||||||
|
expect(
|
||||||
|
await prisma.auditLog.count({
|
||||||
|
where: {
|
||||||
|
action: "REPORT_RESOLVED",
|
||||||
|
entityId: report.body.data.id as string,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBe(1);
|
||||||
|
expect(
|
||||||
|
await prisma.notification.count({
|
||||||
|
where: { accountId: { in: [alice.id, bob.id] } },
|
||||||
|
}),
|
||||||
|
).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upholds a report without sanction without notifying the target of account action", async () => {
|
||||||
|
const report = await request(
|
||||||
|
app.getHttpServer() as Parameters<typeof request>[0],
|
||||||
|
)
|
||||||
|
.post("/api/v1/reports")
|
||||||
|
.set("Authorization", alice.authorization)
|
||||||
|
.set(origin)
|
||||||
|
.set("Idempotency-Key", randomUUID())
|
||||||
|
.send({ targetType: "MESSAGE", targetId: messageId, reason: "SPAM" })
|
||||||
|
.expect(201);
|
||||||
|
await request(app.getHttpServer() as Parameters<typeof request>[0])
|
||||||
|
.post(`/api/v1/admin/reports/${report.body.data.id}/resolve`)
|
||||||
|
.set("Authorization", admin.authorization)
|
||||||
|
.set(origin)
|
||||||
|
.send({ decision: "UPHELD", resolution: "upheld without penalty" })
|
||||||
|
.expect(201);
|
||||||
|
expect(await prisma.sanction.count({ where: { accountId: bob.id } })).toBe(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await prisma.notification.count({
|
||||||
|
where: { accountId: bob.id, type: "ACCOUNT_ACTION" },
|
||||||
|
}),
|
||||||
|
).toBe(0);
|
||||||
|
expect(
|
||||||
|
await prisma.notification.count({
|
||||||
|
where: { accountId: alice.id, type: "REPORT_RESOLVED" },
|
||||||
|
}),
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects conflicting report replay and unauthorized targets without storing new reports", async () => {
|
||||||
|
const key = randomUUID();
|
||||||
|
const create = (targetId: string) =>
|
||||||
|
request(app.getHttpServer() as Parameters<typeof request>[0])
|
||||||
|
.post("/api/v1/reports")
|
||||||
|
.set("Authorization", alice.authorization)
|
||||||
|
.set(origin)
|
||||||
|
.set("Idempotency-Key", key)
|
||||||
|
.send({ targetType: "MESSAGE", targetId, reason: "SPAM" });
|
||||||
|
await create(messageId).expect(201);
|
||||||
|
await create(randomUUID()).expect(409);
|
||||||
|
expect(await prisma.report.count()).toBe(1);
|
||||||
|
await request(app.getHttpServer() as Parameters<typeof request>[0])
|
||||||
|
.post("/api/v1/reports")
|
||||||
|
.set("Authorization", admin.authorization)
|
||||||
|
.set(origin)
|
||||||
|
.set("Idempotency-Key", randomUUID())
|
||||||
|
.send({ targetType: "ACCOUNT", targetId: bob.publicId, reason: "SPAM" })
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queues a minimal account action for an independent sanction, respecting opt-out", async () => {
|
||||||
|
const sanction = () =>
|
||||||
|
request(app.getHttpServer() as Parameters<typeof request>[0])
|
||||||
|
.post(`/api/v1/admin/accounts/${bob.publicId}/sanctions`)
|
||||||
|
.set("Authorization", admin.authorization)
|
||||||
|
.set(origin)
|
||||||
|
.send({ type: "WARNING", reason: "private investigation" });
|
||||||
|
await sanction().expect(201);
|
||||||
|
const first = await prisma.notification.findFirstOrThrow({
|
||||||
|
where: { accountId: bob.id, type: "ACCOUNT_ACTION" },
|
||||||
|
});
|
||||||
|
expect(first.status).toBe("PENDING");
|
||||||
|
expect(first.sentAt).toBeNull();
|
||||||
|
expect(first.payload).toEqual({ type: "ACCOUNT_ACTION" });
|
||||||
|
expect(JSON.stringify(first)).not.toContain("private investigation");
|
||||||
|
await prisma.pushPreference.create({
|
||||||
|
data: { accountId: bob.id, inAppEnabled: false },
|
||||||
|
});
|
||||||
|
await sanction().expect(201);
|
||||||
|
expect(
|
||||||
|
await prisma.notification.count({
|
||||||
|
where: { accountId: bob.id, type: "ACCOUNT_ACTION" },
|
||||||
|
}),
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects expired sanctions, dismissed sanctions, and demoted admins", async () => {
|
||||||
|
const sanction = (expiresAt: string) =>
|
||||||
|
request(app.getHttpServer() as Parameters<typeof request>[0])
|
||||||
|
.post(`/api/v1/admin/accounts/${bob.publicId}/sanctions`)
|
||||||
|
.set("Authorization", admin.authorization)
|
||||||
|
.set(origin)
|
||||||
|
.send({ type: "MUTE", reason: "reason", expiresAt });
|
||||||
|
await sanction(new Date(Date.now() - 60_000).toISOString()).expect(400);
|
||||||
|
await request(app.getHttpServer() as Parameters<typeof request>[0])
|
||||||
|
.post(`/api/v1/admin/reports/${randomUUID()}/resolve`)
|
||||||
|
.set("Authorization", admin.authorization)
|
||||||
|
.set(origin)
|
||||||
|
.send({
|
||||||
|
decision: "DISMISSED",
|
||||||
|
resolution: "no",
|
||||||
|
sanction: { type: "BAN", reason: "no" },
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
await prisma.account.update({
|
||||||
|
where: { id: admin.id },
|
||||||
|
data: { role: "USER" },
|
||||||
|
});
|
||||||
|
await request(app.getHttpServer() as Parameters<typeof request>[0])
|
||||||
|
.get("/api/v1/admin/reports")
|
||||||
|
.set("Authorization", admin.authorization)
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects pending moderation and overrides only a published, intact manual-review bottle", async () => {
|
||||||
|
const bottle = await prisma.bottle.create({
|
||||||
|
data: {
|
||||||
|
authorId: alice.id,
|
||||||
|
contentText: "review-word admin",
|
||||||
|
reviewStatus: "REVIEWING",
|
||||||
|
poolStatus: "CLOSED",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { createHash } = await import("node:crypto");
|
||||||
|
const task = await prisma.moderationTask.create({
|
||||||
|
data: {
|
||||||
|
targetType: "BOTTLE",
|
||||||
|
targetId: bottle.id,
|
||||||
|
provider: "SIMULATED",
|
||||||
|
riskLabels: [],
|
||||||
|
payloadHash: createHash("sha256")
|
||||||
|
.update(bottle.contentText)
|
||||||
|
.digest("hex"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const event = await prisma.outboxEvent.create({
|
||||||
|
data: {
|
||||||
|
aggregateType: "BOTTLE",
|
||||||
|
aggregateId: bottle.id,
|
||||||
|
eventType: "BOTTLE_MODERATION_REQUESTED",
|
||||||
|
dedupeKey: `bottle:${bottle.id}:moderation`,
|
||||||
|
payload: { bottleId: bottle.id, taskId: task.id },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const endpoint = `/api/v1/admin/moderation/${task.id}/resolve`;
|
||||||
|
const moderate = () =>
|
||||||
|
request(app.getHttpServer() as Parameters<typeof request>[0])
|
||||||
|
.post(endpoint)
|
||||||
|
.set("Authorization", admin.authorization)
|
||||||
|
.set(origin)
|
||||||
|
.send({ decision: "APPROVED", reason: "human verified" });
|
||||||
|
await moderate().expect(409);
|
||||||
|
expect(
|
||||||
|
(await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }))
|
||||||
|
.status,
|
||||||
|
).toBe("PENDING");
|
||||||
|
await prisma.bottle.update({
|
||||||
|
where: { id: bottle.id },
|
||||||
|
data: { reviewStatus: "MANUAL_REVIEW" },
|
||||||
|
});
|
||||||
|
await prisma.moderationTask.update({
|
||||||
|
where: { id: task.id },
|
||||||
|
data: {
|
||||||
|
status: "COMPLETED",
|
||||||
|
decision: "MANUAL_REVIEW",
|
||||||
|
result: { decision: "MANUAL_REVIEW" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await moderate().expect(409);
|
||||||
|
await prisma.outboxEvent.update({
|
||||||
|
where: { id: event.id },
|
||||||
|
data: { status: "PUBLISHED", publishedAt: new Date() },
|
||||||
|
});
|
||||||
|
const queue = await request(
|
||||||
|
app.getHttpServer() as Parameters<typeof request>[0],
|
||||||
|
)
|
||||||
|
.get("/api/v1/admin/moderation")
|
||||||
|
.set("Authorization", admin.authorization)
|
||||||
|
.expect(200);
|
||||||
|
expect(queue.body.data.items).toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ id: task.id })]),
|
||||||
|
);
|
||||||
|
await moderate().expect(201);
|
||||||
|
await moderate().expect(409);
|
||||||
|
expect(
|
||||||
|
(await prisma.bottle.findUniqueOrThrow({ where: { id: bottle.id } }))
|
||||||
|
.reviewStatus,
|
||||||
|
).toBe("APPROVED");
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await prisma.moderationTask.findUniqueOrThrow({
|
||||||
|
where: { id: task.id },
|
||||||
|
})
|
||||||
|
).result,
|
||||||
|
).toEqual({ decision: "APPROVED" });
|
||||||
|
expect(
|
||||||
|
(await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }))
|
||||||
|
.status,
|
||||||
|
).toBe("PUBLISHED");
|
||||||
|
expect(
|
||||||
|
await prisma.auditLog.count({
|
||||||
|
where: { action: "MODERATION_RESOLVED", entityId: bottle.id },
|
||||||
|
}),
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes settings changes behind worker delivery without leaking after opt-out", async () => {
|
||||||
|
const pending = await prisma.notification.create({
|
||||||
|
data: {
|
||||||
|
accountId: alice.id,
|
||||||
|
dedupeKey: randomUUID(),
|
||||||
|
type: "TEST",
|
||||||
|
payload: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let release!: () => void;
|
||||||
|
let locked!: () => void;
|
||||||
|
const held = new Promise<void>((resolve) => {
|
||||||
|
locked = resolve;
|
||||||
|
});
|
||||||
|
const gate = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
const blocker = prisma.$transaction(async (tx) => {
|
||||||
|
await tx.$queryRaw`SELECT "id" FROM "accounts" WHERE "id"=${alice.id}::uuid FOR UPDATE`;
|
||||||
|
locked();
|
||||||
|
await gate;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await held;
|
||||||
|
// Hold the shared account lock while both API and worker try to acquire
|
||||||
|
// it. PostgreSQL's lock queue gives the earlier API request priority.
|
||||||
|
const preference = request(
|
||||||
|
app.getHttpServer() as Parameters<typeof request>[0],
|
||||||
|
)
|
||||||
|
.put("/api/v1/settings/push")
|
||||||
|
.set("Authorization", alice.authorization)
|
||||||
|
.set(origin)
|
||||||
|
.send({ inAppEnabled: false });
|
||||||
|
const updating = preference.then((response) => response);
|
||||||
|
// Poll database lock state, not wall-clock time, so the API is known to
|
||||||
|
// be waiting for the account lock before the worker starts.
|
||||||
|
let apiWaiting = false;
|
||||||
|
for (let attempt = 0; attempt < 100 && !apiWaiting; attempt += 1) {
|
||||||
|
const waiting = await prisma.$queryRaw<Array<{ ready: boolean }>>`
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM pg_stat_activity
|
||||||
|
WHERE datname = current_database()
|
||||||
|
AND pid <> pg_backend_pid()
|
||||||
|
AND wait_event_type = 'Lock'
|
||||||
|
AND query LIKE '%FROM "accounts"%FOR UPDATE%'
|
||||||
|
) AS ready`;
|
||||||
|
apiWaiting = waiting[0]?.ready ?? false;
|
||||||
|
if (!apiWaiting) await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
expect(apiWaiting).toBe(true);
|
||||||
|
// The API is queued on the shared account lock before worker delivery
|
||||||
|
// begins; neither operation has completed when the gate opens.
|
||||||
|
const worker = new NotificationProcessor(prisma).runOnce();
|
||||||
|
let workerClaimed = false;
|
||||||
|
for (let attempt = 0; attempt < 100 && !workerClaimed; attempt += 1) {
|
||||||
|
try {
|
||||||
|
await prisma.$transaction(async (probe) => {
|
||||||
|
await probe.$queryRaw`SELECT "id" FROM "notifications" WHERE "id"=${pending.id}::uuid FOR UPDATE NOWAIT`;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
workerClaimed = true;
|
||||||
|
}
|
||||||
|
if (!workerClaimed)
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
expect(workerClaimed).toBe(true);
|
||||||
|
release();
|
||||||
|
await blocker;
|
||||||
|
const concurrent = await Promise.all([updating, worker]);
|
||||||
|
expect(concurrent[0].status).toBe(200);
|
||||||
|
expect(concurrent[1]).toBe(true);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await prisma.notification.findUniqueOrThrow({
|
||||||
|
where: { id: pending.id },
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe("FAILED");
|
||||||
|
const visible = await request(
|
||||||
|
app.getHttpServer() as Parameters<typeof request>[0],
|
||||||
|
)
|
||||||
|
.get("/api/v1/notifications")
|
||||||
|
.set("Authorization", alice.authorization)
|
||||||
|
.expect(200);
|
||||||
|
expect(visible.body.data).toMatchObject({ items: [], unreadCount: 0 });
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
await blocker;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delivers new API notifications only in the worker and hides pending rows", async () => {
|
||||||
|
const report = await request(
|
||||||
|
app.getHttpServer() as Parameters<typeof request>[0],
|
||||||
|
)
|
||||||
|
.post("/api/v1/reports")
|
||||||
|
.set("Authorization", alice.authorization)
|
||||||
|
.set(origin)
|
||||||
|
.set("Idempotency-Key", randomUUID())
|
||||||
|
.send({ targetType: "MESSAGE", targetId: messageId, reason: "SPAM" })
|
||||||
|
.expect(201);
|
||||||
|
const before = await request(
|
||||||
|
app.getHttpServer() as Parameters<typeof request>[0],
|
||||||
|
)
|
||||||
|
.get("/api/v1/notifications")
|
||||||
|
.set("Authorization", admin.authorization)
|
||||||
|
.expect(200);
|
||||||
|
expect(before.body.data).toMatchObject({ items: [], unreadCount: 0 });
|
||||||
|
const pending = await prisma.notification.findFirstOrThrow({
|
||||||
|
where: { accountId: admin.id, type: "ADMIN_REPORT_PENDING" },
|
||||||
|
});
|
||||||
|
expect(pending).toMatchObject({ status: "PENDING", sentAt: null });
|
||||||
|
expect(pending.payload).toEqual({
|
||||||
|
reportId: report.body.data.id as string,
|
||||||
|
});
|
||||||
|
expect(await new NotificationProcessor(prisma).runOnce()).toBe(true);
|
||||||
|
const after = await request(
|
||||||
|
app.getHttpServer() as Parameters<typeof request>[0],
|
||||||
|
)
|
||||||
|
.get("/api/v1/notifications")
|
||||||
|
.set("Authorization", admin.authorization)
|
||||||
|
.expect(200);
|
||||||
|
expect(after.body.data).toMatchObject({
|
||||||
|
unreadCount: 1,
|
||||||
|
items: [expect.objectContaining({ id: pending.id, status: "SENT" })],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts only visible delivered unread notifications across pagination and read boundaries", async () => {
|
||||||
|
const make = (
|
||||||
|
status: "PENDING" | "SENT" | "FAILED" | "READ",
|
||||||
|
readAt?: Date,
|
||||||
|
) =>
|
||||||
|
prisma.notification.create({
|
||||||
|
data: {
|
||||||
|
accountId: alice.id,
|
||||||
|
dedupeKey: randomUUID(),
|
||||||
|
type: "TEST",
|
||||||
|
payload: {},
|
||||||
|
status,
|
||||||
|
...(readAt ? { readAt } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const pending = await make("PENDING");
|
||||||
|
const failed = await make("FAILED");
|
||||||
|
const unread = await make("SENT");
|
||||||
|
const alreadyRead = await make("READ", new Date());
|
||||||
|
const sentButRead = await make("SENT", new Date());
|
||||||
|
const list = () =>
|
||||||
|
request(app.getHttpServer() as Parameters<typeof request>[0])
|
||||||
|
.get("/api/v1/notifications?limit=1")
|
||||||
|
.set("Authorization", alice.authorization)
|
||||||
|
.expect(200);
|
||||||
|
const initial = await list();
|
||||||
|
expect(initial.body.data.unreadCount).toBe(1);
|
||||||
|
expect(initial.body.data.items[0].id).toBe(sentButRead.id);
|
||||||
|
expect(JSON.stringify(initial.body.data)).not.toContain(pending.id);
|
||||||
|
expect(JSON.stringify(initial.body.data)).not.toContain(failed.id);
|
||||||
|
const ids: string[] = [initial.body.data.items[0].id as string];
|
||||||
|
let cursor = initial.body.data.nextCursor as string | null;
|
||||||
|
while (cursor) {
|
||||||
|
const page = await request(
|
||||||
|
app.getHttpServer() as Parameters<typeof request>[0],
|
||||||
|
)
|
||||||
|
.get(`/api/v1/notifications?limit=1&cursor=${cursor}`)
|
||||||
|
.set("Authorization", alice.authorization)
|
||||||
|
.expect(200);
|
||||||
|
expect(page.body.data.unreadCount).toBe(1);
|
||||||
|
ids.push(page.body.data.items[0].id as string);
|
||||||
|
cursor = page.body.data.nextCursor as string | null;
|
||||||
|
}
|
||||||
|
expect(ids).toEqual([sentButRead.id, alreadyRead.id, unread.id]);
|
||||||
|
await request(app.getHttpServer() as Parameters<typeof request>[0])
|
||||||
|
.post("/api/v1/notifications/read")
|
||||||
|
.set("Authorization", alice.authorization)
|
||||||
|
.set(origin)
|
||||||
|
.send({ ids: [pending.id] })
|
||||||
|
.expect(404);
|
||||||
|
await request(app.getHttpServer() as Parameters<typeof request>[0])
|
||||||
|
.post("/api/v1/notifications/read")
|
||||||
|
.set("Authorization", alice.authorization)
|
||||||
|
.set(origin)
|
||||||
|
.send({ ids: [unread.id] })
|
||||||
|
.expect(201);
|
||||||
|
expect((await list()).body.data.unreadCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps notifications private, cursor-paged/readable, and stores in-app preferences", async () => {
|
||||||
|
const own = await prisma.notification.create({
|
||||||
|
data: {
|
||||||
|
accountId: alice.id,
|
||||||
|
dedupeKey: randomUUID(),
|
||||||
|
type: "TEST",
|
||||||
|
payload: { safe: true },
|
||||||
|
status: "SENT",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.notification.create({
|
||||||
|
data: {
|
||||||
|
accountId: bob.id,
|
||||||
|
dedupeKey: randomUUID(),
|
||||||
|
type: "SECRET",
|
||||||
|
payload: { safe: false },
|
||||||
|
status: "SENT",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const list = await request(
|
||||||
|
app.getHttpServer() as Parameters<typeof request>[0],
|
||||||
|
)
|
||||||
|
.get("/api/v1/notifications?limit=1")
|
||||||
|
.set("Authorization", alice.authorization)
|
||||||
|
.expect(200);
|
||||||
|
expect(list.body.data.unreadCount).toBe(1);
|
||||||
|
expect(list.body.data.items).toHaveLength(1);
|
||||||
|
expect(list.body.data.items[0].id).toBe(own.id);
|
||||||
|
await request(app.getHttpServer() as Parameters<typeof request>[0])
|
||||||
|
.post("/api/v1/notifications/read")
|
||||||
|
.set("Authorization", alice.authorization)
|
||||||
|
.set(origin)
|
||||||
|
.send({ ids: [own.id] })
|
||||||
|
.expect(201);
|
||||||
|
const afterRead = await request(
|
||||||
|
app.getHttpServer() as Parameters<typeof request>[0],
|
||||||
|
)
|
||||||
|
.get("/api/v1/notifications?limit=1")
|
||||||
|
.set("Authorization", alice.authorization)
|
||||||
|
.expect(200);
|
||||||
|
expect(afterRead.body.data.unreadCount).toBe(0);
|
||||||
|
await request(app.getHttpServer() as Parameters<typeof request>[0])
|
||||||
|
.post("/api/v1/notifications/read")
|
||||||
|
.set("Authorization", alice.authorization)
|
||||||
|
.set(origin)
|
||||||
|
.send({
|
||||||
|
ids: [
|
||||||
|
(
|
||||||
|
await prisma.notification.findFirstOrThrow({
|
||||||
|
where: { accountId: bob.id },
|
||||||
|
})
|
||||||
|
).id,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.expect(404);
|
||||||
|
await request(app.getHttpServer() as Parameters<typeof request>[0])
|
||||||
|
.put("/api/v1/settings/push")
|
||||||
|
.set("Authorization", alice.authorization)
|
||||||
|
.set(origin)
|
||||||
|
.send({ inAppEnabled: false })
|
||||||
|
.expect(200);
|
||||||
|
const pref = await request(
|
||||||
|
app.getHttpServer() as Parameters<typeof request>[0],
|
||||||
|
)
|
||||||
|
.get("/api/v1/settings/push")
|
||||||
|
.set("Authorization", alice.authorization)
|
||||||
|
.expect(200);
|
||||||
|
expect(pref.body.data).toEqual({ inAppEnabled: false });
|
||||||
|
const hidden = await request(
|
||||||
|
app.getHttpServer() as Parameters<typeof request>[0],
|
||||||
|
)
|
||||||
|
.get("/api/v1/notifications")
|
||||||
|
.set("Authorization", alice.authorization)
|
||||||
|
.expect(200);
|
||||||
|
expect(hidden.body.data).toEqual({
|
||||||
|
items: [],
|
||||||
|
nextCursor: null,
|
||||||
|
unreadCount: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honors notification preferences and never links a dismissed report to its target", async () => {
|
||||||
|
await prisma.pushPreference.create({
|
||||||
|
data: { accountId: admin.id, inAppEnabled: false },
|
||||||
|
});
|
||||||
|
const report = await request(
|
||||||
|
app.getHttpServer() as Parameters<typeof request>[0],
|
||||||
|
)
|
||||||
|
.post("/api/v1/reports")
|
||||||
|
.set("Authorization", alice.authorization)
|
||||||
|
.set(origin)
|
||||||
|
.set("Idempotency-Key", randomUUID())
|
||||||
|
.send({ targetType: "MESSAGE", targetId: messageId, reason: "SPAM" })
|
||||||
|
.expect(201);
|
||||||
|
expect(
|
||||||
|
await prisma.notification.count({
|
||||||
|
where: { accountId: admin.id, type: "ADMIN_REPORT_PENDING" },
|
||||||
|
}),
|
||||||
|
).toBe(0);
|
||||||
|
await request(app.getHttpServer() as Parameters<typeof request>[0])
|
||||||
|
.post(`/api/v1/admin/reports/${report.body.data.id}/resolve`)
|
||||||
|
.set("Authorization", admin.authorization)
|
||||||
|
.set(origin)
|
||||||
|
.send({ decision: "DISMISSED", resolution: "not upheld" })
|
||||||
|
.expect(201);
|
||||||
|
expect(
|
||||||
|
await prisma.notification.count({ where: { accountId: bob.id } }),
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces visibility and idempotency independently for each report target", async () => {
|
||||||
|
const bottle = await prisma.bottle.findFirstOrThrow({
|
||||||
|
where: { authorId: alice.id },
|
||||||
|
});
|
||||||
|
const targets = [
|
||||||
|
["ACCOUNT", bob.publicId],
|
||||||
|
["BOTTLE", bottle.id],
|
||||||
|
["CONVERSATION", conversationId],
|
||||||
|
["MESSAGE", messageId],
|
||||||
|
] as const;
|
||||||
|
const submit = (
|
||||||
|
authorization: string,
|
||||||
|
targetType: (typeof targets)[number][0],
|
||||||
|
targetId: string,
|
||||||
|
key: string,
|
||||||
|
details = "original detail",
|
||||||
|
) =>
|
||||||
|
request(app.getHttpServer() as Parameters<typeof request>[0])
|
||||||
|
.post("/api/v1/reports")
|
||||||
|
.set("Authorization", authorization)
|
||||||
|
.set(origin)
|
||||||
|
.set("Idempotency-Key", key)
|
||||||
|
.send({ targetType, targetId, reason: "SPAM", details });
|
||||||
|
for (const [targetType, targetId] of targets) {
|
||||||
|
const key = randomUUID();
|
||||||
|
const first = await submit(
|
||||||
|
alice.authorization,
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
key,
|
||||||
|
).expect(201);
|
||||||
|
await submit(alice.authorization, targetType, targetId, key).expect(201);
|
||||||
|
await submit(
|
||||||
|
alice.authorization,
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
key,
|
||||||
|
"changed",
|
||||||
|
).expect(409);
|
||||||
|
await submit(
|
||||||
|
admin.authorization,
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
randomUUID(),
|
||||||
|
).expect(403);
|
||||||
|
const snapshot = (
|
||||||
|
await prisma.report.findUniqueOrThrow({
|
||||||
|
where: { id: first.body.data.id as string },
|
||||||
|
})
|
||||||
|
).targetSnapshot;
|
||||||
|
expect(snapshot).toMatchObject({ targetType });
|
||||||
|
expect(
|
||||||
|
await prisma.report.count({ where: { reporterId: alice.id } }),
|
||||||
|
).toBe(targets.findIndex(([kind]) => kind === targetType) + 1);
|
||||||
|
}
|
||||||
|
const snapshotBefore = (
|
||||||
|
await prisma.report.findFirstOrThrow({
|
||||||
|
where: { messageId },
|
||||||
|
})
|
||||||
|
).targetSnapshot;
|
||||||
|
await prisma.message.update({
|
||||||
|
where: { id: messageId },
|
||||||
|
data: { contentText: "edited after report" },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(await prisma.report.findFirstOrThrow({ where: { messageId } }))
|
||||||
|
.targetSnapshot,
|
||||||
|
).toEqual(snapshotBefore);
|
||||||
|
await prisma.conversationMember.updateMany({
|
||||||
|
where: { conversationId, accountId: alice.id },
|
||||||
|
data: { leftAt: new Date() },
|
||||||
|
});
|
||||||
|
for (const targetType of ["CONVERSATION", "MESSAGE"] as const)
|
||||||
|
await submit(
|
||||||
|
alice.authorization,
|
||||||
|
targetType,
|
||||||
|
targetType === "MESSAGE" ? messageId : conversationId,
|
||||||
|
randomUUID(),
|
||||||
|
).expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores deidentified immutable snapshots for all four report target kinds", async () => {
|
||||||
|
const bottle = await prisma.bottle.findFirstOrThrow({
|
||||||
|
where: { authorId: alice.id },
|
||||||
|
});
|
||||||
|
const targets = [
|
||||||
|
["MESSAGE", messageId],
|
||||||
|
["CONVERSATION", conversationId],
|
||||||
|
["BOTTLE", bottle.id],
|
||||||
|
["ACCOUNT", bob.publicId],
|
||||||
|
] as const;
|
||||||
|
for (const [targetType, targetId] of targets) {
|
||||||
|
await request(app.getHttpServer() as Parameters<typeof request>[0])
|
||||||
|
.post("/api/v1/reports")
|
||||||
|
.set("Authorization", alice.authorization)
|
||||||
|
.set(origin)
|
||||||
|
.set("Idempotency-Key", randomUUID())
|
||||||
|
.send({ targetType, targetId, reason: "SPAM" })
|
||||||
|
.expect(201);
|
||||||
|
}
|
||||||
|
const reports = await prisma.report.findMany({
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
expect(reports).toHaveLength(4);
|
||||||
|
const serialized = JSON.stringify(reports.map((row) => row.targetSnapshot));
|
||||||
|
expect(serialized).not.toContain(alice.id);
|
||||||
|
expect(serialized).not.toContain(bob.id);
|
||||||
|
expect(serialized).not.toContain("accountId");
|
||||||
|
expect(serialized).not.toContain("reportedAccountId");
|
||||||
|
expect(serialized).toContain("senderPublicId");
|
||||||
|
expect(serialized).toContain("ownerPublicId");
|
||||||
|
expect(serialized).toContain("members");
|
||||||
|
expect(serialized).toContain(bob.publicId);
|
||||||
|
const adminList = await request(
|
||||||
|
app.getHttpServer() as Parameters<typeof request>[0],
|
||||||
|
)
|
||||||
|
.get("/api/v1/admin/reports")
|
||||||
|
.set("Authorization", admin.authorization)
|
||||||
|
.expect(200);
|
||||||
|
expect(JSON.stringify(adminList.body.data)).not.toContain(bob.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,79 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Headers,
|
||||||
|
HttpStatus,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
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 { StateChangingOriginGuard } from "../conversation/state-changing-origin.guard.js";
|
||||||
|
import { CreateReportDto } from "./dto.js";
|
||||||
|
import { SafetyService } from "./safety.service.js";
|
||||||
|
@Controller()
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class SafetyController {
|
||||||
|
constructor(@Inject(SafetyService) private readonly safety: SafetyService) {}
|
||||||
|
@UseGuards(StateChangingOriginGuard) @Post("reports") report(
|
||||||
|
@CurrentUser() user: AccessClaims,
|
||||||
|
@Headers("idempotency-key") key: string,
|
||||||
|
@Body() dto: CreateReportDto,
|
||||||
|
) {
|
||||||
|
if (!key || key.length > 128)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.VALIDATION_ERROR,
|
||||||
|
"Invalid idempotency key",
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
);
|
||||||
|
return this.safety.report(user.sub, key, dto);
|
||||||
|
}
|
||||||
|
@UseGuards(StateChangingOriginGuard) @Post("conversations/:id/block") block(
|
||||||
|
@CurrentUser() user: AccessClaims,
|
||||||
|
@Param("id", new ParseUUIDPipe()) id: string,
|
||||||
|
) {
|
||||||
|
return this.safety.block(user.sub, id);
|
||||||
|
}
|
||||||
|
@Get("me/blocks") blocks(@CurrentUser() user: AccessClaims) {
|
||||||
|
return this.safety.blocks(user.sub);
|
||||||
|
}
|
||||||
|
@UseGuards(StateChangingOriginGuard) @Delete("me/blocks/:publicId") unblock(
|
||||||
|
@CurrentUser() user: AccessClaims,
|
||||||
|
@Param("publicId", new ParseUUIDPipe()) id: string,
|
||||||
|
) {
|
||||||
|
return this.safety.unblock(user.sub, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Reflect.defineMetadata(
|
||||||
|
"design:paramtypes",
|
||||||
|
[Object, String, CreateReportDto],
|
||||||
|
SafetyController.prototype,
|
||||||
|
"report",
|
||||||
|
);
|
||||||
|
Reflect.defineMetadata(
|
||||||
|
"design:paramtypes",
|
||||||
|
[Object, String],
|
||||||
|
SafetyController.prototype,
|
||||||
|
"block",
|
||||||
|
);
|
||||||
|
Reflect.defineMetadata(
|
||||||
|
"design:paramtypes",
|
||||||
|
[Object],
|
||||||
|
SafetyController.prototype,
|
||||||
|
"blocks",
|
||||||
|
);
|
||||||
|
Reflect.defineMetadata(
|
||||||
|
"design:paramtypes",
|
||||||
|
[Object, String],
|
||||||
|
SafetyController.prototype,
|
||||||
|
"unblock",
|
||||||
|
);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { AuthModule } from "../auth/auth.module.js";
|
||||||
|
import { DatabaseModule } from "../database/database.module.js";
|
||||||
|
import { StateChangingOriginGuard } from "../conversation/state-changing-origin.guard.js";
|
||||||
|
import { SafetyController } from "./safety.controller.js";
|
||||||
|
import { SafetyLockService } from "./safety-lock.service.js";
|
||||||
|
import { SafetyService } from "./safety.service.js";
|
||||||
|
import { NotificationModule } from "../notification/notification.module.js";
|
||||||
|
@Module({
|
||||||
|
imports: [DatabaseModule, AuthModule, NotificationModule],
|
||||||
|
controllers: [SafetyController],
|
||||||
|
providers: [SafetyLockService, SafetyService, StateChangingOriginGuard],
|
||||||
|
exports: [SafetyLockService],
|
||||||
|
})
|
||||||
|
export class SafetyModule {}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { HttpStatus, Inject, Injectable } from "@nestjs/common";
|
||||||
|
import { ErrorCode } from "@drift/contracts";
|
||||||
|
import { Prisma, type PrismaClient } from "@prisma/client";
|
||||||
|
import { DomainException } from "../common/domain.exception.js";
|
||||||
|
import { PrismaService } from "../database/prisma.service.js";
|
||||||
|
import { SafetyLockService } from "./safety-lock.service.js";
|
||||||
|
import type { CreateReportDto, ReportTargetType } from "./dto.js";
|
||||||
|
import { NotificationService } from "../notification/notification.service.js";
|
||||||
|
|
||||||
|
type Db = Prisma.TransactionClient | PrismaClient;
|
||||||
|
const targetType = (dto: CreateReportDto): string => dto.targetType;
|
||||||
|
@Injectable()
|
||||||
|
export class SafetyService {
|
||||||
|
constructor(
|
||||||
|
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||||
|
@Inject(SafetyLockService) private readonly locks: SafetyLockService,
|
||||||
|
@Inject(NotificationService)
|
||||||
|
private readonly notifications: NotificationService,
|
||||||
|
) {}
|
||||||
|
async report(reporterId: string, key: string, dto: CreateReportDto) {
|
||||||
|
const prior = await this.prisma.report.findUnique({
|
||||||
|
where: { reporterId_idempotencyKey: { reporterId, idempotencyKey: key } },
|
||||||
|
});
|
||||||
|
if (prior) return this.replay(prior, dto);
|
||||||
|
return this.prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
const existing = await tx.report.findUnique({
|
||||||
|
where: {
|
||||||
|
reporterId_idempotencyKey: { reporterId, idempotencyKey: key },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existing) return this.replay(existing, dto);
|
||||||
|
const target = await this.snapshot(
|
||||||
|
tx,
|
||||||
|
reporterId,
|
||||||
|
dto.targetType,
|
||||||
|
dto.targetId,
|
||||||
|
);
|
||||||
|
const report = await tx.report.create({
|
||||||
|
data: {
|
||||||
|
reporterId,
|
||||||
|
idempotencyKey: key,
|
||||||
|
reason: dto.reason,
|
||||||
|
details: dto.details ?? null,
|
||||||
|
targetSnapshot: target.snapshot,
|
||||||
|
reportedAccountId:
|
||||||
|
targetType(dto) === "ACCOUNT" ? (target.accountId ?? null) : null,
|
||||||
|
...this.targetFk(dto.targetType, dto.targetId),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.moderationTask.create({
|
||||||
|
data: {
|
||||||
|
targetType: "REPORT",
|
||||||
|
targetId: report.id,
|
||||||
|
provider: "ADMIN",
|
||||||
|
riskLabels: [],
|
||||||
|
payloadHash: createHash("sha256").update(report.id).digest("hex"),
|
||||||
|
reportId: report.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.auditLog.create({
|
||||||
|
data: {
|
||||||
|
actorId: reporterId,
|
||||||
|
action: "REPORT_CREATED",
|
||||||
|
entityType: "REPORT",
|
||||||
|
entityId: report.id,
|
||||||
|
metadata: { targetType: dto.targetType, reason: dto.reason },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await this.notifications.notifyAdmins(tx, report.id);
|
||||||
|
return this.publicReport(report);
|
||||||
|
},
|
||||||
|
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async block(accountId: string, conversationId: string) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const members = await tx.conversationMember.findMany({
|
||||||
|
where: { conversationId, leftAt: null },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!members.some((x) => x.accountId === accountId) ||
|
||||||
|
members.length !== 2
|
||||||
|
)
|
||||||
|
throw this.forbidden();
|
||||||
|
const peer = members.find((x) => x.accountId !== accountId)!;
|
||||||
|
await this.locks.lockAccounts(tx, [accountId, peer.accountId]);
|
||||||
|
await tx.block.upsert({
|
||||||
|
where: {
|
||||||
|
blockerId_blockedId: {
|
||||||
|
blockerId: accountId,
|
||||||
|
blockedId: peer.accountId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: { blockerId: accountId, blockedId: peer.accountId },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
await tx.conversationMember.updateMany({
|
||||||
|
where: { conversationId },
|
||||||
|
data: { blockedAt: new Date() },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
conversationId,
|
||||||
|
blockedPublicId: (
|
||||||
|
await tx.anonymousProfile.findUniqueOrThrow({
|
||||||
|
where: { accountId: peer.accountId },
|
||||||
|
})
|
||||||
|
).publicId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async blocks(accountId: string) {
|
||||||
|
const rows = await this.prisma.block.findMany({
|
||||||
|
where: { blockerId: accountId },
|
||||||
|
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||||
|
include: { blocked: { select: { anonymousProfile: true } } },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
items: rows.map((x) => ({
|
||||||
|
publicId: x.blocked.anonymousProfile!.publicId,
|
||||||
|
createdAt: x.createdAt,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async unblock(accountId: string, publicId: string) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const peer = await tx.anonymousProfile.findUnique({
|
||||||
|
where: { publicId },
|
||||||
|
});
|
||||||
|
if (!peer) throw this.notFound();
|
||||||
|
await this.locks.lockAccounts(tx, [accountId, peer.accountId]);
|
||||||
|
const removed = await tx.block.deleteMany({
|
||||||
|
where: { blockerId: accountId, blockedId: peer.accountId },
|
||||||
|
});
|
||||||
|
if (!removed.count) throw this.notFound();
|
||||||
|
const conversations = await tx.conversation.findMany({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
{ members: { some: { accountId } } },
|
||||||
|
{ members: { some: { accountId: peer.accountId } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const ids = conversations.map((x) => x.id);
|
||||||
|
if (ids.length) {
|
||||||
|
const reverse = await tx.block.count({
|
||||||
|
where: { blockerId: peer.accountId, blockedId: accountId },
|
||||||
|
});
|
||||||
|
if (!reverse)
|
||||||
|
await tx.conversationMember.updateMany({
|
||||||
|
where: { conversationId: { in: ids } },
|
||||||
|
data: { blockedAt: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { publicId, unblocked: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
private async snapshot(
|
||||||
|
tx: Db,
|
||||||
|
reporterId: string,
|
||||||
|
type: ReportTargetType,
|
||||||
|
id: string,
|
||||||
|
): Promise<{ snapshot: Prisma.InputJsonObject; accountId?: string }> {
|
||||||
|
if (String(type) === "MESSAGE") {
|
||||||
|
const row = await tx.message.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
conversation: {
|
||||||
|
members: { some: { accountId: reporterId, leftAt: null } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!row) throw this.forbidden();
|
||||||
|
return {
|
||||||
|
accountId: row.senderId,
|
||||||
|
snapshot: {
|
||||||
|
targetType: type,
|
||||||
|
messageId: row.id,
|
||||||
|
conversationId: row.conversationId,
|
||||||
|
senderPublicId: row.senderPublicId,
|
||||||
|
contentText: row.contentText,
|
||||||
|
sentAt: row.sentAt.toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (String(type) === "CONVERSATION") {
|
||||||
|
const row = await tx.conversation.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
members: { some: { accountId: reporterId, leftAt: null } },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
members: {
|
||||||
|
include: { account: { select: { anonymousProfile: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!row) throw this.forbidden();
|
||||||
|
const accountId = row.members.find(
|
||||||
|
(x) => x.accountId !== reporterId,
|
||||||
|
)?.accountId;
|
||||||
|
return {
|
||||||
|
...(accountId ? { accountId } : {}),
|
||||||
|
snapshot: {
|
||||||
|
targetType: type,
|
||||||
|
conversationId: id,
|
||||||
|
members: row.members.map((member) => ({
|
||||||
|
publicId: member.account.anonymousProfile?.publicId,
|
||||||
|
peerAlias: member.peerAliasSnapshot,
|
||||||
|
})),
|
||||||
|
subjectPublicId: row.members.find(
|
||||||
|
(member) => member.accountId !== reporterId,
|
||||||
|
)?.account.anonymousProfile?.publicId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (String(type) === "BOTTLE") {
|
||||||
|
const row = await tx.bottle.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
OR: [
|
||||||
|
{ authorId: reporterId },
|
||||||
|
{ leases: { some: { pickerId: reporterId } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: { author: { select: { anonymousProfile: true } } },
|
||||||
|
});
|
||||||
|
if (!row) throw this.forbidden();
|
||||||
|
return {
|
||||||
|
accountId: row.authorId,
|
||||||
|
snapshot: {
|
||||||
|
targetType: type,
|
||||||
|
bottleId: id,
|
||||||
|
ownerPublicId: row.author.anonymousProfile?.publicId,
|
||||||
|
contentText: row.contentText,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const profile = await tx.anonymousProfile.findUnique({
|
||||||
|
where: { publicId: id },
|
||||||
|
});
|
||||||
|
if (!profile || profile.accountId === reporterId) throw this.forbidden();
|
||||||
|
const related = await tx.conversation.count({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
{ members: { some: { accountId: reporterId } } },
|
||||||
|
{ members: { some: { accountId: profile.accountId } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!related) throw this.forbidden();
|
||||||
|
return {
|
||||||
|
accountId: profile.accountId,
|
||||||
|
snapshot: {
|
||||||
|
targetType: type,
|
||||||
|
publicId: profile.publicId,
|
||||||
|
nickname: profile.nickname,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
private targetFk(type: ReportTargetType, id: string) {
|
||||||
|
return String(type) === "MESSAGE"
|
||||||
|
? { messageId: id }
|
||||||
|
: String(type) === "CONVERSATION"
|
||||||
|
? { conversationId: id }
|
||||||
|
: String(type) === "BOTTLE"
|
||||||
|
? { bottleId: id }
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
private replay(
|
||||||
|
row: {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
reason: string;
|
||||||
|
details: string | null;
|
||||||
|
targetSnapshot: Prisma.JsonValue;
|
||||||
|
reportedAccountId: string | null;
|
||||||
|
bottleId: string | null;
|
||||||
|
conversationId: string | null;
|
||||||
|
messageId: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
},
|
||||||
|
dto: CreateReportDto,
|
||||||
|
) {
|
||||||
|
const snap = row.targetSnapshot as {
|
||||||
|
targetType?: string;
|
||||||
|
publicId?: string;
|
||||||
|
};
|
||||||
|
const id =
|
||||||
|
targetType(dto) === "ACCOUNT"
|
||||||
|
? snap.publicId
|
||||||
|
: targetType(dto) === "BOTTLE"
|
||||||
|
? row.bottleId
|
||||||
|
: targetType(dto) === "CONVERSATION"
|
||||||
|
? row.conversationId
|
||||||
|
: row.messageId;
|
||||||
|
if (
|
||||||
|
snap.targetType !== dto.targetType ||
|
||||||
|
id !== dto.targetId ||
|
||||||
|
row.reason !== String(dto.reason) ||
|
||||||
|
row.details !== (dto.details ?? null)
|
||||||
|
)
|
||||||
|
throw new DomainException(
|
||||||
|
ErrorCode.IDEMPOTENCY_CONFLICT,
|
||||||
|
"Conflicting request",
|
||||||
|
HttpStatus.CONFLICT,
|
||||||
|
);
|
||||||
|
return this.publicReport(row);
|
||||||
|
}
|
||||||
|
private publicReport(row: {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
reason: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
status: row.status,
|
||||||
|
reason: row.reason,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
private forbidden() {
|
||||||
|
return new DomainException(
|
||||||
|
ErrorCode.CONVERSATION_FORBIDDEN,
|
||||||
|
"Forbidden",
|
||||||
|
HttpStatus.FORBIDDEN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
private notFound() {
|
||||||
|
return new DomainException(
|
||||||
|
ErrorCode.NOT_FOUND,
|
||||||
|
"Not found",
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
export class LeaseReaper {
|
||||||
|
constructor(private readonly prisma: PrismaClient) {}
|
||||||
|
|
||||||
|
async runOnce(): Promise<boolean> {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const rows = await tx.$queryRaw<Array<{ id: string; bottleId: string }>>`
|
||||||
|
SELECT "id", "bottle_id" AS "bottleId"
|
||||||
|
FROM "bottle_pick_leases"
|
||||||
|
WHERE "status" = 'ACTIVE' AND "expires_at" <= now()
|
||||||
|
ORDER BY "expires_at", "id"
|
||||||
|
FOR UPDATE SKIP LOCKED LIMIT 1`;
|
||||||
|
const lease = rows[0];
|
||||||
|
if (!lease) return false;
|
||||||
|
const expired = await tx.bottlePickLease.updateMany({
|
||||||
|
where: {
|
||||||
|
id: lease.id,
|
||||||
|
status: "ACTIVE",
|
||||||
|
expiresAt: { lte: new Date() },
|
||||||
|
},
|
||||||
|
data: { status: "EXPIRED", endedAt: new Date() },
|
||||||
|
});
|
||||||
|
if (expired.count !== 1) return true;
|
||||||
|
await tx.bottle.updateMany({
|
||||||
|
where: {
|
||||||
|
id: lease.bottleId,
|
||||||
|
poolStatus: "LEASED",
|
||||||
|
activeLeaseId: lease.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
poolStatus: "IN_POOL",
|
||||||
|
activeLeaseId: null,
|
||||||
|
version: { increment: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { assertSafeTestDatabaseUrl } from "../../../prisma/database-safety.js";
|
||||||
|
import { LeaseReaper } from "./lease-reaper.processor.js";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
describe("lease reaper with real PostgreSQL", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
assertSafeTestDatabaseUrl(process.env.DATABASE_URL ?? "");
|
||||||
|
await prisma.$connect();
|
||||||
|
});
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "accounts" CASCADE`);
|
||||||
|
});
|
||||||
|
afterAll(() => prisma.$disconnect());
|
||||||
|
|
||||||
|
async function fixture(expiresAt: Date) {
|
||||||
|
const [author, picker] = await Promise.all([
|
||||||
|
prisma.account.create({
|
||||||
|
data: { phoneCiphertext: Buffer.from("a"), phoneHmac: randomUUID() },
|
||||||
|
}),
|
||||||
|
prisma.account.create({
|
||||||
|
data: { phoneCiphertext: Buffer.from("p"), phoneHmac: randomUUID() },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const bottle = await prisma.bottle.create({
|
||||||
|
data: {
|
||||||
|
authorId: author.id,
|
||||||
|
clientRequestId: randomUUID(),
|
||||||
|
contentText: "secret body",
|
||||||
|
reviewStatus: "APPROVED",
|
||||||
|
poolStatus: "IN_POOL",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const leaseId = randomUUID();
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.bottle.update({
|
||||||
|
where: { id: bottle.id },
|
||||||
|
data: {
|
||||||
|
poolStatus: "LEASED",
|
||||||
|
activeLeaseId: leaseId,
|
||||||
|
version: { increment: 1 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.bottlePickLease.create({
|
||||||
|
data: {
|
||||||
|
id: leaseId,
|
||||||
|
bottleId: bottle.id,
|
||||||
|
pickerId: picker.id,
|
||||||
|
leaseTokenHash: randomUUID(),
|
||||||
|
leaseTokenCiphertext: Buffer.from("ciphertext"),
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { bottle, leaseId };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("atomically expires a due active lease and returns its current bottle to the pool", async () => {
|
||||||
|
const { bottle, leaseId } = await fixture(new Date(Date.now() - 1000));
|
||||||
|
expect(await new LeaseReaper(prisma).runOnce()).toBe(true);
|
||||||
|
expect(
|
||||||
|
await prisma.bottlePickLease.findUniqueOrThrow({
|
||||||
|
where: { id: leaseId },
|
||||||
|
}),
|
||||||
|
).toMatchObject({ status: "EXPIRED", endedAt: expect.any(Date) });
|
||||||
|
expect(
|
||||||
|
await prisma.bottle.findUniqueOrThrow({ where: { id: bottle.id } }),
|
||||||
|
).toMatchObject({ poolStatus: "IN_POOL", activeLeaseId: null, version: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reap a future lease", async () => {
|
||||||
|
const { bottle, leaseId } = await fixture(new Date(Date.now() + 60_000));
|
||||||
|
expect(await new LeaseReaper(prisma).runOnce()).toBe(false);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await prisma.bottlePickLease.findUniqueOrThrow({
|
||||||
|
where: { id: leaseId },
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe("ACTIVE");
|
||||||
|
expect(
|
||||||
|
(await prisma.bottle.findUniqueOrThrow({ where: { id: bottle.id } }))
|
||||||
|
.activeLeaseId,
|
||||||
|
).toBe(leaseId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never releases a newer lease when processing stale old ownership", async () => {
|
||||||
|
const { bottle, leaseId: oldLeaseId } = await fixture(
|
||||||
|
new Date(Date.now() - 1000),
|
||||||
|
);
|
||||||
|
const picker = await prisma.account.create({
|
||||||
|
data: { phoneCiphertext: Buffer.from("n"), phoneHmac: randomUUID() },
|
||||||
|
});
|
||||||
|
const newLeaseId = randomUUID();
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.bottlePickLease.update({
|
||||||
|
where: { id: oldLeaseId },
|
||||||
|
data: { status: "EXPIRED", endedAt: new Date() },
|
||||||
|
});
|
||||||
|
await tx.bottle.update({
|
||||||
|
where: { id: bottle.id },
|
||||||
|
data: { activeLeaseId: newLeaseId },
|
||||||
|
});
|
||||||
|
await tx.bottlePickLease.create({
|
||||||
|
data: {
|
||||||
|
id: newLeaseId,
|
||||||
|
bottleId: bottle.id,
|
||||||
|
pickerId: picker.id,
|
||||||
|
leaseTokenHash: randomUUID(),
|
||||||
|
leaseTokenCiphertext: Buffer.from("ciphertext"),
|
||||||
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(await new LeaseReaper(prisma).runOnce()).toBe(false);
|
||||||
|
expect(
|
||||||
|
(await prisma.bottle.findUniqueOrThrow({ where: { id: bottle.id } }))
|
||||||
|
.activeLeaseId,
|
||||||
|
).toBe(newLeaseId);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,30 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { handleTopLevelError, logTopLevelError, runWorker } from "./main.js";
|
import {
|
||||||
|
createCombinedWorker,
|
||||||
|
handleTopLevelError,
|
||||||
|
logTopLevelError,
|
||||||
|
runWorker,
|
||||||
|
} from "./main.js";
|
||||||
|
|
||||||
describe("worker lifecycle", () => {
|
describe("worker lifecycle", () => {
|
||||||
|
it("runs both reaping and moderation every iteration without starvation", async () => {
|
||||||
|
const reaper = { runOnce: vi.fn().mockResolvedValue(true) };
|
||||||
|
const moderation = { runOnce: vi.fn().mockResolvedValue(false) };
|
||||||
|
const worker = createCombinedWorker(reaper, moderation);
|
||||||
|
|
||||||
|
await expect(worker.runOnce()).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(reaper.runOnce).toHaveBeenCalledTimes(1);
|
||||||
|
expect(moderation.runOnce).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
it("invokes reaping, moderation and notification each iteration", async () => {
|
||||||
|
const reaper = { runOnce: vi.fn().mockResolvedValue(true) };
|
||||||
|
const moderation = { runOnce: vi.fn().mockResolvedValue(false) };
|
||||||
|
const notification = { runOnce: vi.fn().mockResolvedValue(true) };
|
||||||
|
const worker = createCombinedWorker(reaper, moderation, notification);
|
||||||
|
await expect(worker.runOnce()).resolves.toBe(true);
|
||||||
|
expect(notification.runOnce).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
it("waits for the current run before disconnecting on SIGTERM", async () => {
|
it("waits for the current run before disconnecting on SIGTERM", async () => {
|
||||||
let finish!: () => void;
|
let finish!: () => void;
|
||||||
const runOnce = vi.fn(
|
const runOnce = vi.fn(
|
||||||
|
|||||||
+23
-1
@@ -1,6 +1,8 @@
|
|||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import { ModerationWorker } from "./moderation-worker.js";
|
import { ModerationWorker } from "./moderation-worker.js";
|
||||||
|
import { LeaseReaper } from "./lease-reaper.processor.js";
|
||||||
|
import { NotificationProcessor } from "./notification.processor.js";
|
||||||
|
|
||||||
type SignalSource = {
|
type SignalSource = {
|
||||||
once(signal: "SIGTERM" | "SIGINT", listener: () => void): unknown;
|
once(signal: "SIGTERM" | "SIGINT", listener: () => void): unknown;
|
||||||
@@ -16,6 +18,23 @@ type RunWorkerOptions = {
|
|||||||
signals?: SignalSource;
|
signals?: SignalSource;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Worker = { runOnce(): Promise<boolean> };
|
||||||
|
|
||||||
|
export function createCombinedWorker(
|
||||||
|
reaper: Worker,
|
||||||
|
moderation: Worker,
|
||||||
|
notification?: Worker,
|
||||||
|
): Worker {
|
||||||
|
return {
|
||||||
|
async runOnce() {
|
||||||
|
const reaped = await reaper.runOnce();
|
||||||
|
const moderated = await moderation.runOnce();
|
||||||
|
const notified = notification ? await notification.runOnce() : false;
|
||||||
|
return reaped || moderated || notified;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function runWorker(options: RunWorkerOptions) {
|
export async function runWorker(options: RunWorkerOptions) {
|
||||||
const signals = options.signals ?? process;
|
const signals = options.signals ?? process;
|
||||||
const sleep =
|
const sleep =
|
||||||
@@ -47,7 +66,10 @@ export async function runWorker(options: RunWorkerOptions) {
|
|||||||
|
|
||||||
export async function main() {
|
export async function main() {
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
const worker = new ModerationWorker(prisma);
|
const moderation = new ModerationWorker(prisma);
|
||||||
|
const reaper = new LeaseReaper(prisma);
|
||||||
|
const notification = new NotificationProcessor(prisma);
|
||||||
|
const worker = createCombinedWorker(reaper, moderation, notification);
|
||||||
await runWorker({
|
await runWorker({
|
||||||
worker,
|
worker,
|
||||||
connect: () => prisma.$connect(),
|
connect: () => prisma.$connect(),
|
||||||
|
|||||||
@@ -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, () => {
|
||||||
@@ -282,7 +318,12 @@ describe("moderation worker with real PostgreSQL", () => {
|
|||||||
expect(decide).not.toHaveBeenCalled();
|
expect(decide).not.toHaveBeenCalled();
|
||||||
expect(
|
expect(
|
||||||
await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }),
|
await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }),
|
||||||
).toMatchObject({ status: "FAILED", attempts: 5, lockedAt: null });
|
).toMatchObject({
|
||||||
|
status: "FAILED",
|
||||||
|
attempts: 5,
|
||||||
|
lockToken: null,
|
||||||
|
lockedAt: null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -349,7 +390,11 @@ describe("moderation worker with real PostgreSQL", () => {
|
|||||||
expect(decide).toHaveBeenCalledTimes(1);
|
expect(decide).toHaveBeenCalledTimes(1);
|
||||||
expect(
|
expect(
|
||||||
await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }),
|
await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }),
|
||||||
).toMatchObject({ status: "PUBLISHED" });
|
).toMatchObject({
|
||||||
|
status: "PUBLISHED",
|
||||||
|
lockToken: null,
|
||||||
|
lockedAt: null,
|
||||||
|
});
|
||||||
expect(
|
expect(
|
||||||
await prisma.anonymousProfile.findUniqueOrThrow({
|
await prisma.anonymousProfile.findUniqueOrThrow({
|
||||||
where: { id: profile.id },
|
where: { id: profile.id },
|
||||||
@@ -360,6 +405,32 @@ describe("moderation worker with real PostgreSQL", () => {
|
|||||||
).toMatchObject({ status: "PENDING", decision: null });
|
).toMatchObject({ status: "PENDING", decision: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fails a same-version profile event whose task hash drifted", async () => {
|
||||||
|
const { profile, event, task } = await profileFixture("ordinary", "first");
|
||||||
|
await prisma.moderationTask.update({
|
||||||
|
where: { id: task.id },
|
||||||
|
data: { payloadHash: "0".repeat(64) },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await new ModerationWorker(prisma).runOnce()).toBe(true);
|
||||||
|
expect(
|
||||||
|
await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }),
|
||||||
|
).toMatchObject({
|
||||||
|
status: "FAILED",
|
||||||
|
attempts: 1,
|
||||||
|
lockToken: null,
|
||||||
|
lockedAt: null,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await prisma.anonymousProfile.findUniqueOrThrow({
|
||||||
|
where: { id: profile.id },
|
||||||
|
}),
|
||||||
|
).toMatchObject({ version: 1, reviewStatus: "REVIEWING" });
|
||||||
|
expect(
|
||||||
|
await prisma.moderationTask.findUniqueOrThrow({ where: { id: task.id } }),
|
||||||
|
).toMatchObject({ status: "PENDING", decision: null });
|
||||||
|
});
|
||||||
|
|
||||||
it("fails a replay when the completed profile task drifted from current state", async () => {
|
it("fails a replay when the completed profile task drifted from current state", async () => {
|
||||||
const { profile, event, task } = await profileFixture("ordinary", "first");
|
const { profile, event, task } = await profileFixture("ordinary", "first");
|
||||||
await prisma.moderationTask.update({
|
await prisma.moderationTask.update({
|
||||||
@@ -387,6 +458,37 @@ describe("moderation worker with real PostgreSQL", () => {
|
|||||||
).toMatchObject({ status: "COMPLETED", decision: "APPROVED" });
|
).toMatchObject({ status: "COMPLETED", decision: "APPROVED" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fails a completed profile task with a non-terminal reviewing decision", async () => {
|
||||||
|
const { profile, event, task } = await profileFixture("ordinary", "first");
|
||||||
|
await prisma.moderationTask.update({
|
||||||
|
where: { id: task.id },
|
||||||
|
data: {
|
||||||
|
status: "COMPLETED",
|
||||||
|
decision: "REVIEWING",
|
||||||
|
result: { decision: "REVIEWING" },
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await new ModerationWorker(prisma).runOnce()).toBe(true);
|
||||||
|
expect(
|
||||||
|
await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }),
|
||||||
|
).toMatchObject({
|
||||||
|
status: "FAILED",
|
||||||
|
attempts: 1,
|
||||||
|
lockToken: null,
|
||||||
|
lockedAt: null,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await prisma.anonymousProfile.findUniqueOrThrow({
|
||||||
|
where: { id: profile.id },
|
||||||
|
}),
|
||||||
|
).toMatchObject({ reviewStatus: "REVIEWING" });
|
||||||
|
expect(
|
||||||
|
await prisma.moderationTask.findUniqueOrThrow({ where: { id: task.id } }),
|
||||||
|
).toMatchObject({ status: "COMPLETED", decision: "REVIEWING" });
|
||||||
|
});
|
||||||
|
|
||||||
it("logs a safe error category without payload or exception message", async () => {
|
it("logs a safe error category without payload or exception message", async () => {
|
||||||
const { event } = await fixture("secret正文");
|
const { event } = await fixture("secret正文");
|
||||||
const entries: unknown[] = [];
|
const entries: unknown[] = [];
|
||||||
|
|||||||
@@ -269,10 +269,12 @@ export class ModerationWorker {
|
|||||||
profile.bio,
|
profile.bio,
|
||||||
]);
|
]);
|
||||||
const hash = createHash("sha256").update(text).digest("hex");
|
const hash = createHash("sha256").update(text).digest("hex");
|
||||||
if (profile.version !== version || task.payloadHash !== hash) {
|
if (profile.version !== version) {
|
||||||
await this.prisma.$transaction((tx) => this.publish(tx, event));
|
await this.prisma.$transaction((tx) => this.publish(tx, event));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (task.payloadHash !== hash)
|
||||||
|
throw new Error("profile moderation payload drift");
|
||||||
if (task.status === "COMPLETED") {
|
if (task.status === "COMPLETED") {
|
||||||
if (!this.completedProfileMatches(profile, task, hash, version))
|
if (!this.completedProfileMatches(profile, task, hash, version))
|
||||||
throw new Error("completed profile moderation state drift");
|
throw new Error("completed profile moderation state drift");
|
||||||
@@ -294,12 +296,8 @@ export class ModerationWorker {
|
|||||||
const currentHash = createHash("sha256")
|
const currentHash = createHash("sha256")
|
||||||
.update(currentText)
|
.update(currentText)
|
||||||
.digest("hex");
|
.digest("hex");
|
||||||
if (
|
if (currentProfile.version !== version) return;
|
||||||
currentProfile.version !== version ||
|
if (currentHash !== hash || currentTask.payloadHash !== currentHash)
|
||||||
currentTask.payloadHash !== hash
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
if (currentHash !== hash)
|
|
||||||
throw new Error("profile moderation payload drift");
|
throw new Error("profile moderation payload drift");
|
||||||
if (currentTask.status === "COMPLETED") {
|
if (currentTask.status === "COMPLETED") {
|
||||||
if (
|
if (
|
||||||
@@ -333,12 +331,15 @@ export class ModerationWorker {
|
|||||||
hash: string,
|
hash: string,
|
||||||
version: number,
|
version: number,
|
||||||
) {
|
) {
|
||||||
|
const decision = task.decision;
|
||||||
return (
|
return (
|
||||||
profile.version === version &&
|
profile.version === version &&
|
||||||
task.payloadHash === hash &&
|
task.payloadHash === hash &&
|
||||||
task.decision !== null &&
|
(decision === "APPROVED" ||
|
||||||
resultDecision(task.result) === task.decision &&
|
decision === "REJECTED" ||
|
||||||
profile.reviewStatus === task.decision
|
decision === "MANUAL_REVIEW") &&
|
||||||
|
resultDecision(task.result) === decision &&
|
||||||
|
profile.reviewStatus === decision
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,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 },
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { assertSafeTestDatabaseUrl } from "../../../prisma/database-safety.js";
|
||||||
|
import { NotificationProcessor } from "./notification.processor.js";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
describe("NotificationProcessor", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
assertSafeTestDatabaseUrl(process.env.DATABASE_URL ?? "");
|
||||||
|
await prisma.$connect();
|
||||||
|
});
|
||||||
|
afterAll(async () => prisma.$disconnect());
|
||||||
|
|
||||||
|
it("lets a preference disable committed before worker delivery win the race", async () => {
|
||||||
|
const account = await prisma.account.create({
|
||||||
|
data: { phoneCiphertext: Buffer.from("race"), phoneHmac: randomUUID() },
|
||||||
|
});
|
||||||
|
const preference = await prisma.pushPreference.create({
|
||||||
|
data: { accountId: account.id, inAppEnabled: true },
|
||||||
|
});
|
||||||
|
const notification = await prisma.notification.create({
|
||||||
|
data: {
|
||||||
|
accountId: account.id,
|
||||||
|
dedupeKey: randomUUID(),
|
||||||
|
type: "TEST",
|
||||||
|
payload: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.notification.updateMany({
|
||||||
|
where: { status: "PENDING", id: { not: notification.id } },
|
||||||
|
data: { status: "FAILED" },
|
||||||
|
});
|
||||||
|
let release!: () => void;
|
||||||
|
let locked!: () => void;
|
||||||
|
const lockHeld = new Promise<void>((resolve) => {
|
||||||
|
locked = resolve;
|
||||||
|
});
|
||||||
|
const gate = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
const disabling = prisma.$transaction(async (tx) => {
|
||||||
|
await tx.$queryRaw`SELECT "account_id" FROM "push_preferences" WHERE "account_id"=${preference.accountId}::uuid FOR UPDATE`;
|
||||||
|
locked();
|
||||||
|
await gate;
|
||||||
|
await tx.pushPreference.update({
|
||||||
|
where: { accountId: account.id },
|
||||||
|
data: { inAppEnabled: false },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await lockHeld;
|
||||||
|
const delivery = new NotificationProcessor(prisma).runOnce();
|
||||||
|
// Wait until the worker holds the notification lock and has reached the
|
||||||
|
// preference boundary; an unlocked preference would expose stale true.
|
||||||
|
let claimed = false;
|
||||||
|
for (let attempt = 0; attempt < 100 && !claimed; attempt += 1) {
|
||||||
|
try {
|
||||||
|
await prisma.$transaction(async (probe) => {
|
||||||
|
await probe.$queryRaw`SELECT "id" FROM "notifications" WHERE "id"=${notification.id}::uuid FOR UPDATE NOWAIT`;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
claimed = true;
|
||||||
|
}
|
||||||
|
if (!claimed) await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
}
|
||||||
|
expect(claimed).toBe(true);
|
||||||
|
release();
|
||||||
|
await disabling;
|
||||||
|
expect(await delivery).toBe(true);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await prisma.notification.findUniqueOrThrow({
|
||||||
|
where: { id: notification.id },
|
||||||
|
})
|
||||||
|
).status,
|
||||||
|
).toBe("FAILED");
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
await disabling;
|
||||||
|
await prisma.account.delete({ where: { id: account.id } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delivers a pending in-app notification once and skips disabled preferences", async () => {
|
||||||
|
const account = await prisma.account.create({
|
||||||
|
data: { phoneCiphertext: Buffer.from("test"), phoneHmac: randomUUID() },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const enabled = await prisma.notification.create({
|
||||||
|
data: {
|
||||||
|
accountId: account.id,
|
||||||
|
type: "TEST",
|
||||||
|
dedupeKey: randomUUID(),
|
||||||
|
payload: { safe: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.notification.updateMany({
|
||||||
|
where: { status: "PENDING", id: { not: enabled.id } },
|
||||||
|
data: { status: "FAILED" },
|
||||||
|
});
|
||||||
|
const worker = new NotificationProcessor(prisma);
|
||||||
|
expect(await worker.runOnce()).toBe(true);
|
||||||
|
const delivered = await prisma.notification.findUniqueOrThrow({
|
||||||
|
where: { id: enabled.id },
|
||||||
|
});
|
||||||
|
expect(delivered).toMatchObject({ status: "SENT", readAt: null });
|
||||||
|
expect(delivered.sentAt).toBeInstanceOf(Date);
|
||||||
|
expect(await worker.runOnce()).toBe(false);
|
||||||
|
await prisma.pushPreference.create({
|
||||||
|
data: { accountId: account.id, inAppEnabled: false },
|
||||||
|
});
|
||||||
|
const disabled = await prisma.notification.create({
|
||||||
|
data: {
|
||||||
|
accountId: account.id,
|
||||||
|
type: "TEST",
|
||||||
|
dedupeKey: randomUUID(),
|
||||||
|
payload: { safe: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(await worker.runOnce()).toBe(true);
|
||||||
|
expect(
|
||||||
|
await prisma.notification.findUniqueOrThrow({
|
||||||
|
where: { id: disabled.id },
|
||||||
|
}),
|
||||||
|
).toMatchObject({ status: "FAILED", sentAt: null });
|
||||||
|
} finally {
|
||||||
|
await prisma.account.delete({ where: { id: account.id } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
/** Deliver committed PENDING in-app rows after serializing against opt-out. */
|
||||||
|
export class NotificationProcessor {
|
||||||
|
constructor(private readonly prisma: PrismaClient) {}
|
||||||
|
|
||||||
|
async runOnce(): Promise<boolean> {
|
||||||
|
// Claim and update share a transaction; the row lock lives until commit.
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const claimed = await tx.$queryRaw<
|
||||||
|
Array<{ id: string; accountId: string }>
|
||||||
|
>`
|
||||||
|
SELECT n."id", n."account_id" AS "accountId"
|
||||||
|
FROM "notifications" n
|
||||||
|
WHERE n."status" = 'PENDING'
|
||||||
|
ORDER BY n."created_at", n."id"
|
||||||
|
FOR UPDATE OF n SKIP LOCKED LIMIT 1`;
|
||||||
|
const notification = claimed[0];
|
||||||
|
if (!notification) return false;
|
||||||
|
// n -> account -> preference is the fixed lock order. Locking the
|
||||||
|
// account also serializes preference-row insertion when none exists.
|
||||||
|
await tx.$queryRaw`
|
||||||
|
SELECT "id" FROM "accounts"
|
||||||
|
WHERE "id"=${notification.accountId}::uuid FOR UPDATE`;
|
||||||
|
const preference = await tx.$queryRaw<Array<{ enabled: boolean }>>`
|
||||||
|
SELECT "in_app_enabled" AS "enabled" FROM "push_preferences"
|
||||||
|
WHERE "account_id"=${notification.accountId}::uuid FOR UPDATE`;
|
||||||
|
await tx.notification.update({
|
||||||
|
where: { id: notification.id },
|
||||||
|
data:
|
||||||
|
preference[0]?.enabled !== false
|
||||||
|
? { status: "SENT", sentAt: new Date() }
|
||||||
|
: { status: "FAILED" },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "vitest run --no-file-parallelism",
|
"test": "vitest run --no-file-parallelism",
|
||||||
"test:database": "vitest run tests/integration/database.spec.ts",
|
"test:database": "vitest run tests/integration/database.spec.ts",
|
||||||
"test:integration": "vitest run tests/integration --no-file-parallelism",
|
"test:integration": "vitest run tests/integration --no-file-parallelism && corepack pnpm --filter @drift/api exec vitest run --config vitest.config.ts src/safety/safety-admin.e2e-spec.ts --no-file-parallelism",
|
||||||
|
"test:security": "corepack pnpm --filter @drift/api exec vitest run --config vitest.config.ts src/safety/safety-admin.e2e-spec.ts src/auth/auth.e2e-spec.ts src/conversation/conversation.e2e-spec.ts --no-file-parallelism",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.base.json",
|
"typecheck": "tsc --noEmit -p tsconfig.base.json",
|
||||||
"lint": "eslint . --ext .ts --max-warnings 0 && prettier --check .",
|
"lint": "eslint . --ext .ts --max-warnings 0 && prettier --check .",
|
||||||
"build": "corepack pnpm prisma generate && corepack pnpm --recursive run build",
|
"build": "corepack pnpm prisma generate && corepack pnpm --recursive run build",
|
||||||
|
|||||||
Generated
+241
-9
@@ -49,19 +49,25 @@ importers:
|
|||||||
|
|
||||||
apps/api:
|
apps/api:
|
||||||
dependencies:
|
dependencies:
|
||||||
"@drift/contracts":
|
'@drift/contracts':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/contracts
|
version: link:../../packages/contracts
|
||||||
"@nestjs/common":
|
'@nestjs/common':
|
||||||
specifier: ^11.1.6
|
specifier: ^11.1.6
|
||||||
version: 11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
version: 11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
"@nestjs/core":
|
'@nestjs/core':
|
||||||
specifier: ^11.1.6
|
specifier: ^11.1.6
|
||||||
version: 11.2.3(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.2.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
version: 11.2.3(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.2.3)(@nestjs/websockets@11.2.5)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
"@nestjs/platform-express":
|
'@nestjs/platform-express':
|
||||||
specifier: ^11.1.6
|
specifier: ^11.1.6
|
||||||
version: 11.2.3(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.3)
|
version: 11.2.3(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.3)
|
||||||
"@prisma/client":
|
'@nestjs/platform-socket.io':
|
||||||
|
specifier: ^11.1.6
|
||||||
|
version: 11.2.5(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.2.5)(rxjs@7.8.2)
|
||||||
|
'@nestjs/websockets':
|
||||||
|
specifier: ^11.1.6
|
||||||
|
version: 11.2.5(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.3)(@nestjs/platform-socket.io@11.2.5)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@prisma/client':
|
||||||
specifier: 6.19.0
|
specifier: 6.19.0
|
||||||
version: 6.19.0(prisma@6.19.0(typescript@5.9.3))(typescript@5.9.3)
|
version: 6.19.0(prisma@6.19.0(typescript@5.9.3))(typescript@5.9.3)
|
||||||
class-transformer:
|
class-transformer:
|
||||||
@@ -85,16 +91,22 @@ importers:
|
|||||||
rxjs:
|
rxjs:
|
||||||
specifier: ^7.8.2
|
specifier: ^7.8.2
|
||||||
version: 7.8.2
|
version: 7.8.2
|
||||||
|
socket.io:
|
||||||
|
specifier: ^4.8.1
|
||||||
|
version: 4.8.3
|
||||||
devDependencies:
|
devDependencies:
|
||||||
"@nestjs/testing":
|
'@nestjs/testing':
|
||||||
specifier: ^11.1.6
|
specifier: ^11.1.6
|
||||||
version: 11.2.3(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.3)(@nestjs/platform-express@11.2.3)
|
version: 11.2.3(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.3)(@nestjs/platform-express@11.2.3)
|
||||||
"@types/express":
|
'@types/express':
|
||||||
specifier: ^5.0.3
|
specifier: ^5.0.3
|
||||||
version: 5.0.6
|
version: 5.0.6
|
||||||
"@types/supertest":
|
'@types/supertest':
|
||||||
specifier: ^6.0.3
|
specifier: ^6.0.3
|
||||||
version: 6.0.3
|
version: 6.0.3
|
||||||
|
socket.io-client:
|
||||||
|
specifier: ^4.8.1
|
||||||
|
version: 4.8.3
|
||||||
supertest:
|
supertest:
|
||||||
specifier: ^7.1.4
|
specifier: ^7.1.4
|
||||||
version: 7.2.2
|
version: 7.2.2
|
||||||
@@ -3252,6 +3264,92 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">=10" }
|
engines: { node: ">=10" }
|
||||||
|
|
||||||
|
'@nestjs/platform-socket.io@11.2.5':
|
||||||
|
resolution: {integrity: sha512-xOUCwjWfhnhpR5HBKMS3pYOcMYhXhKKR6duxmV7WLFmhgeIZjBalW/b2LEr3C/07+bq3BGTPZsVYunnTYBJDVw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@nestjs/common': ^11.0.0
|
||||||
|
'@nestjs/websockets': ^11.0.0
|
||||||
|
rxjs: ^7.1.0
|
||||||
|
|
||||||
|
'@nestjs/websockets@11.2.5':
|
||||||
|
resolution: {integrity: sha512-KSTpTWKFSrBYxxXyclDg3ir9EzPWUU8iuKK4VFaVno2L+kwZcZzsKmnXrcMnmx8Sl2glgqXQg26Esu8hbo6a0Q==}
|
||||||
|
peerDependencies:
|
||||||
|
'@nestjs/common': ^11.0.0
|
||||||
|
'@nestjs/core': ^11.0.0
|
||||||
|
'@nestjs/platform-socket.io': ^11.0.0
|
||||||
|
reflect-metadata: ^0.1.12 || ^0.2.0
|
||||||
|
rxjs: ^7.1.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@nestjs/platform-socket.io':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@socket.io/component-emitter@3.1.2':
|
||||||
|
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
|
||||||
|
|
||||||
|
'@types/cors@2.8.19':
|
||||||
|
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
|
||||||
|
|
||||||
|
'@types/ws@8.18.1':
|
||||||
|
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||||
|
|
||||||
|
accepts@1.3.8:
|
||||||
|
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
base64id@2.0.0:
|
||||||
|
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
|
||||||
|
engines: {node: ^4.5.0 || >= 5.9}
|
||||||
|
|
||||||
|
engine.io-client@6.6.6:
|
||||||
|
resolution: {integrity: sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==}
|
||||||
|
|
||||||
|
engine.io-parser@5.2.3:
|
||||||
|
resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
|
engine.io@6.6.10:
|
||||||
|
resolution: {integrity: sha512-9/lX2bdlizlCXMHRMOIm03VBQHQYC7VvydcxtTAUJRxNW1QzM/2PMFSmr6h/lCiMHcyCP6abK+t9Q+j4vekk8Q==}
|
||||||
|
engines: {node: '>=10.2.0'}
|
||||||
|
|
||||||
|
negotiator@0.6.3:
|
||||||
|
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
object-hash@3.0.0:
|
||||||
|
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
socket.io-adapter@2.5.8:
|
||||||
|
resolution: {integrity: sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==}
|
||||||
|
|
||||||
|
socket.io-client@4.8.3:
|
||||||
|
resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
|
socket.io-parser@4.2.7:
|
||||||
|
resolution: {integrity: sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
|
socket.io@4.8.3:
|
||||||
|
resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==}
|
||||||
|
engines: {node: '>=10.2.0'}
|
||||||
|
|
||||||
|
ws@8.21.3:
|
||||||
|
resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
bufferutil: ^4.0.1
|
||||||
|
utf-8-validate: '>=5.0.2'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
bufferutil:
|
||||||
|
optional: true
|
||||||
|
utf-8-validate:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
xmlhttprequest-ssl@2.1.2:
|
||||||
|
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
|
||||||
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
"@borewit/text-codec@0.2.2": {}
|
"@borewit/text-codec@0.2.2": {}
|
||||||
|
|
||||||
@@ -5004,3 +5102,137 @@ snapshots:
|
|||||||
wrappy@1.0.2: {}
|
wrappy@1.0.2: {}
|
||||||
|
|
||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
|
'@nestjs/core@11.2.3(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.2.3)(@nestjs/websockets@11.2.5)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
||||||
|
dependencies:
|
||||||
|
'@nestjs/common': 11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
fast-safe-stringify: 2.1.1
|
||||||
|
iterare: 1.2.1
|
||||||
|
path-to-regexp: 8.4.2
|
||||||
|
reflect-metadata: 0.2.2
|
||||||
|
rxjs: 7.8.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
uid: 2.0.2
|
||||||
|
optionalDependencies:
|
||||||
|
'@nestjs/platform-express': 11.2.3(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.3)
|
||||||
|
'@nestjs/websockets': 11.2.5(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.3)(@nestjs/platform-socket.io@11.2.5)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
|
||||||
|
'@nestjs/platform-socket.io@11.2.5(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.2.5)(rxjs@7.8.2)':
|
||||||
|
dependencies:
|
||||||
|
'@nestjs/common': 11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@nestjs/websockets': 11.2.5(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.3)(@nestjs/platform-socket.io@11.2.5)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
rxjs: 7.8.2
|
||||||
|
socket.io: 4.8.3
|
||||||
|
tslib: 2.8.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
|
'@nestjs/websockets@11.2.5(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.2.3)(@nestjs/platform-socket.io@11.2.5)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
||||||
|
dependencies:
|
||||||
|
'@nestjs/common': 11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@nestjs/core': 11.2.3(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.2.3)(@nestjs/websockets@11.2.5)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
iterare: 1.2.1
|
||||||
|
object-hash: 3.0.0
|
||||||
|
reflect-metadata: 0.2.2
|
||||||
|
rxjs: 7.8.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
optionalDependencies:
|
||||||
|
'@nestjs/platform-socket.io': 11.2.5(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.2.5)(rxjs@7.8.2)
|
||||||
|
|
||||||
|
'@socket.io/component-emitter@3.1.2': {}
|
||||||
|
|
||||||
|
'@types/cors@2.8.19':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 22.19.3
|
||||||
|
|
||||||
|
'@types/ws@8.18.1':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 22.19.3
|
||||||
|
|
||||||
|
accepts@1.3.8:
|
||||||
|
dependencies:
|
||||||
|
mime-types: 2.1.35
|
||||||
|
negotiator: 0.6.3
|
||||||
|
|
||||||
|
base64id@2.0.0: {}
|
||||||
|
|
||||||
|
engine.io-client@6.6.6:
|
||||||
|
dependencies:
|
||||||
|
'@socket.io/component-emitter': 3.1.2
|
||||||
|
debug: 4.4.3
|
||||||
|
engine.io-parser: 5.2.3
|
||||||
|
ws: 8.21.3
|
||||||
|
xmlhttprequest-ssl: 2.1.2
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
|
engine.io-parser@5.2.3: {}
|
||||||
|
|
||||||
|
engine.io@6.6.10:
|
||||||
|
dependencies:
|
||||||
|
'@types/cors': 2.8.19
|
||||||
|
'@types/node': 22.19.3
|
||||||
|
'@types/ws': 8.18.1
|
||||||
|
accepts: 1.3.8
|
||||||
|
cookie: 0.7.2
|
||||||
|
cors: 2.8.6
|
||||||
|
debug: 4.4.3
|
||||||
|
engine.io-parser: 5.2.3
|
||||||
|
ws: 8.21.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
|
negotiator@0.6.3: {}
|
||||||
|
|
||||||
|
object-hash@3.0.0: {}
|
||||||
|
|
||||||
|
socket.io-adapter@2.5.8:
|
||||||
|
dependencies:
|
||||||
|
debug: 4.4.3
|
||||||
|
ws: 8.21.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
|
socket.io-client@4.8.3:
|
||||||
|
dependencies:
|
||||||
|
'@socket.io/component-emitter': 3.1.2
|
||||||
|
debug: 4.4.3
|
||||||
|
engine.io-client: 6.6.6
|
||||||
|
socket.io-parser: 4.2.7
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
|
socket.io-parser@4.2.7:
|
||||||
|
dependencies:
|
||||||
|
'@socket.io/component-emitter': 3.1.2
|
||||||
|
debug: 4.4.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
socket.io@4.8.3:
|
||||||
|
dependencies:
|
||||||
|
accepts: 1.3.8
|
||||||
|
base64id: 2.0.0
|
||||||
|
cors: 2.8.6
|
||||||
|
debug: 4.4.3
|
||||||
|
engine.io: 6.6.10
|
||||||
|
socket.io-adapter: 2.5.8
|
||||||
|
socket.io-parser: 4.2.7
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
|
ws@8.21.3: {}
|
||||||
|
|
||||||
|
xmlhttprequest-ssl@2.1.2: {}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
ALTER TABLE "bottles" ADD COLUMN "active_lease_id" UUID;
|
||||||
|
-- Legacy leases predate recoverable idempotency tokens. Expire them rather than
|
||||||
|
-- fabricating ciphertext that could ever be returned as a valid bearer secret.
|
||||||
|
ALTER TABLE "bottle_pick_leases" ADD COLUMN "lease_token_ciphertext" BYTEA;
|
||||||
|
WITH expired AS (
|
||||||
|
UPDATE "bottle_pick_leases"
|
||||||
|
SET "status" = 'EXPIRED', "ended_at" = COALESCE("ended_at", CURRENT_TIMESTAMP)
|
||||||
|
WHERE "status" = 'ACTIVE'
|
||||||
|
RETURNING "bottle_id"
|
||||||
|
)
|
||||||
|
UPDATE "bottles" b
|
||||||
|
SET "pool_status" = 'IN_POOL',
|
||||||
|
"active_lease_id" = NULL,
|
||||||
|
"version" = b."version" + 1
|
||||||
|
WHERE b."pool_status" = 'LEASED'
|
||||||
|
AND b."active_lease_id" IS NULL
|
||||||
|
AND b."id" IN (SELECT "bottle_id" FROM expired);
|
||||||
|
UPDATE "bottle_pick_leases"
|
||||||
|
SET "lease_token_ciphertext" = decode(repeat('00', 48), 'hex')
|
||||||
|
WHERE "lease_token_ciphertext" IS NULL;
|
||||||
|
ALTER TABLE "bottle_pick_leases"
|
||||||
|
ALTER COLUMN "lease_token_ciphertext" SET NOT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE "bottle_pick_requests" (
|
||||||
|
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"picker_id" UUID NOT NULL,
|
||||||
|
"request_id" VARCHAR(128) NOT NULL,
|
||||||
|
"lease_id" UUID,
|
||||||
|
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "bottle_pick_requests_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "bottles_active_lease_id_key" ON "bottles"("active_lease_id");
|
||||||
|
CREATE UNIQUE INDEX "bottle_pick_requests_picker_id_request_id_key" ON "bottle_pick_requests"("picker_id", "request_id");
|
||||||
|
CREATE UNIQUE INDEX "bottle_pick_requests_lease_id_key" ON "bottle_pick_requests"("lease_id");
|
||||||
|
CREATE INDEX "bottle_pick_requests_created_at_idx" ON "bottle_pick_requests"("created_at");
|
||||||
|
ALTER TABLE "bottles" ADD CONSTRAINT "bottles_active_lease_id_fkey" FOREIGN KEY ("active_lease_id") REFERENCES "bottle_pick_leases"("id") ON DELETE SET NULL ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED;
|
||||||
|
ALTER TABLE "bottle_pick_requests" ADD CONSTRAINT "bottle_pick_requests_picker_id_fkey" FOREIGN KEY ("picker_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "bottle_pick_requests" ADD CONSTRAINT "bottle_pick_requests_lease_id_fkey" FOREIGN KEY ("lease_id") REFERENCES "bottle_pick_leases"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
CREATE INDEX "bottles_pick_candidates_id_idx"
|
||||||
|
ON "bottles"("id")
|
||||||
|
WHERE "review_status" = 'APPROVED'
|
||||||
|
AND "pool_status" = 'IN_POOL'
|
||||||
|
AND "active_lease_id" IS NULL;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- Task 7 chat identity snapshots, query and delivery indexes.
|
||||||
|
ALTER TABLE "messages" ADD COLUMN "sender_public_id" UUID;
|
||||||
|
UPDATE "messages" AS m
|
||||||
|
SET "sender_public_id" = p."public_id"
|
||||||
|
FROM "anonymous_profiles" AS p
|
||||||
|
WHERE p."account_id" = m."sender_id";
|
||||||
|
-- Legacy/seed rows may predate anonymous profiles. Preserve an unlinkable
|
||||||
|
-- snapshot instead of deriving or exposing the private account identifier.
|
||||||
|
UPDATE "messages"
|
||||||
|
SET "sender_public_id" = gen_random_uuid()
|
||||||
|
WHERE "sender_public_id" IS NULL;
|
||||||
|
ALTER TABLE "messages" ALTER COLUMN "sender_public_id" SET NOT NULL;
|
||||||
|
|
||||||
|
-- Outbox events already provide durable message event dedupe.
|
||||||
|
CREATE INDEX "messages_conversation_seq_inbox_idx"
|
||||||
|
ON "messages" ("conversation_id", "seq");
|
||||||
|
CREATE INDEX "conversation_members_account_conversation_idx"
|
||||||
|
ON "conversation_members" ("account_id", "conversation_id")
|
||||||
|
WHERE "left_at" IS NULL;
|
||||||
|
CREATE INDEX "conversations_last_message_cursor_idx"
|
||||||
|
ON "conversations" ("last_message_at" DESC, "id" DESC);
|
||||||
|
CREATE INDEX "sanctions_active_account_idx"
|
||||||
|
ON "sanctions" ("account_id", "starts_at", "expires_at")
|
||||||
|
WHERE "revoked_at" IS NULL;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
CREATE TYPE "AccountRole" AS ENUM ('USER', 'ADMIN');
|
||||||
|
ALTER TABLE "accounts" ADD COLUMN "role" "AccountRole" NOT NULL DEFAULT 'USER';
|
||||||
|
ALTER TABLE "reports" ADD COLUMN "idempotency_key" VARCHAR(128) DEFAULT gen_random_uuid()::text;
|
||||||
|
UPDATE "reports" SET "idempotency_key" = "id"::text WHERE "idempotency_key" IS NULL;
|
||||||
|
ALTER TABLE "reports" ALTER COLUMN "idempotency_key" SET NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX "reports_reporter_id_idempotency_key_key" ON "reports"("reporter_id", "idempotency_key");
|
||||||
|
ALTER TABLE "notifications" ADD COLUMN "dedupe_key" VARCHAR(255);
|
||||||
|
UPDATE "notifications" SET "dedupe_key" = "id"::text WHERE "dedupe_key" IS NULL;
|
||||||
|
ALTER TABLE "notifications" ALTER COLUMN "dedupe_key" SET NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX "notifications_account_id_dedupe_key_key" ON "notifications"("account_id", "dedupe_key");
|
||||||
|
CREATE TABLE "push_preferences" (
|
||||||
|
"account_id" UUID NOT NULL,
|
||||||
|
"in_app_enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "push_preferences_pkey" PRIMARY KEY ("account_id"),
|
||||||
|
CONSTRAINT "push_preferences_account_id_fkey" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
@@ -13,6 +13,11 @@ enum AccountStatus {
|
|||||||
DELETED
|
DELETED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AccountRole {
|
||||||
|
USER
|
||||||
|
ADMIN
|
||||||
|
}
|
||||||
|
|
||||||
enum AuthProvider {
|
enum AuthProvider {
|
||||||
PHONE
|
PHONE
|
||||||
APPLE
|
APPLE
|
||||||
@@ -97,6 +102,7 @@ model Account {
|
|||||||
phoneCiphertext Bytes @map("phone_ciphertext")
|
phoneCiphertext Bytes @map("phone_ciphertext")
|
||||||
phoneHmac String @unique @map("phone_hmac") @db.VarChar(128)
|
phoneHmac String @unique @map("phone_hmac") @db.VarChar(128)
|
||||||
status AccountStatus @default(ACTIVE)
|
status AccountStatus @default(ACTIVE)
|
||||||
|
role AccountRole @default(USER)
|
||||||
tokenVersion Int @default(0) @map("token_version")
|
tokenVersion Int @default(0) @map("token_version")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
@@ -118,6 +124,8 @@ model Account {
|
|||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
auditLogs AuditLog[] @relation("AuditActor")
|
auditLogs AuditLog[] @relation("AuditActor")
|
||||||
dailyUsage DailyUsage[]
|
dailyUsage DailyUsage[]
|
||||||
|
pickRequests BottlePickRequest[]
|
||||||
|
pushPreference PushPreference?
|
||||||
|
|
||||||
@@map("accounts")
|
@@map("accounts")
|
||||||
}
|
}
|
||||||
@@ -196,6 +204,7 @@ model RefreshToken {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Bottle {
|
model Bottle {
|
||||||
|
/// Candidate partial index bottles_pick_candidates_id_idx is database-only and managed in 0009 SQL.
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
authorId String @map("author_id") @db.Uuid
|
authorId String @map("author_id") @db.Uuid
|
||||||
clientRequestId String @default(uuid()) @map("client_request_id") @db.VarChar(128)
|
clientRequestId String @default(uuid()) @map("client_request_id") @db.VarChar(128)
|
||||||
@@ -213,6 +222,8 @@ model Bottle {
|
|||||||
pickHistory BottlePickHistory[]
|
pickHistory BottlePickHistory[]
|
||||||
conversation Conversation?
|
conversation Conversation?
|
||||||
reports Report[]
|
reports Report[]
|
||||||
|
activeLeaseId String? @unique @map("active_lease_id") @db.Uuid
|
||||||
|
activeLease BottlePickLease? @relation("ActiveBottleLease", fields: [activeLeaseId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@unique([authorId, clientRequestId])
|
@@unique([authorId, clientRequestId])
|
||||||
@@index([poolStatus, createdAt])
|
@@index([poolStatus, createdAt])
|
||||||
@@ -227,18 +238,35 @@ model BottlePickLease {
|
|||||||
bottleId String @map("bottle_id") @db.Uuid
|
bottleId String @map("bottle_id") @db.Uuid
|
||||||
pickerId String @map("picker_id") @db.Uuid
|
pickerId String @map("picker_id") @db.Uuid
|
||||||
leaseTokenHash String @unique @map("lease_token_hash") @db.VarChar(255)
|
leaseTokenHash String @unique @map("lease_token_hash") @db.VarChar(255)
|
||||||
|
leaseTokenCiphertext Bytes @map("lease_token_ciphertext")
|
||||||
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
|
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
|
||||||
status BottlePickLeaseStatus @default(ACTIVE)
|
status BottlePickLeaseStatus @default(ACTIVE)
|
||||||
endedAt DateTime? @map("ended_at") @db.Timestamptz(3)
|
endedAt DateTime? @map("ended_at") @db.Timestamptz(3)
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
bottle Bottle @relation(fields: [bottleId], references: [id], onDelete: Cascade)
|
bottle Bottle @relation(fields: [bottleId], references: [id], onDelete: Cascade)
|
||||||
|
activeForBottle Bottle? @relation("ActiveBottleLease")
|
||||||
picker Account @relation("PickerLeases", fields: [pickerId], references: [id], onDelete: Cascade)
|
picker Account @relation("PickerLeases", fields: [pickerId], references: [id], onDelete: Cascade)
|
||||||
|
pickRequest BottlePickRequest?
|
||||||
|
|
||||||
@@index([pickerId, expiresAt])
|
@@index([pickerId, expiresAt])
|
||||||
@@index([bottleId, expiresAt])
|
@@index([bottleId, expiresAt])
|
||||||
@@map("bottle_pick_leases")
|
@@map("bottle_pick_leases")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model BottlePickRequest {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
pickerId String @map("picker_id") @db.Uuid
|
||||||
|
requestId String @map("request_id") @db.VarChar(128)
|
||||||
|
leaseId String? @unique @map("lease_id") @db.Uuid
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||||
|
picker Account @relation(fields: [pickerId], references: [id], onDelete: Cascade)
|
||||||
|
lease BottlePickLease? @relation(fields: [leaseId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@unique([pickerId, requestId])
|
||||||
|
@@index([createdAt])
|
||||||
|
@@map("bottle_pick_requests")
|
||||||
|
}
|
||||||
|
|
||||||
model BottlePickHistory {
|
model BottlePickHistory {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
bottleId String @map("bottle_id") @db.Uuid
|
bottleId String @map("bottle_id") @db.Uuid
|
||||||
@@ -283,10 +311,12 @@ model Conversation {
|
|||||||
reports Report[]
|
reports Report[]
|
||||||
|
|
||||||
@@index([status, updatedAt])
|
@@index([status, updatedAt])
|
||||||
|
@@index([lastMessageAt(sort: Desc), id(sort: Desc)], map: "conversations_last_message_cursor_idx")
|
||||||
@@map("conversations")
|
@@map("conversations")
|
||||||
}
|
}
|
||||||
|
|
||||||
model ConversationMember {
|
model ConversationMember {
|
||||||
|
/// Active member inbox index conversation_members_account_conversation_idx is managed in 0010 SQL.
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
conversationId String @map("conversation_id") @db.Uuid
|
conversationId String @map("conversation_id") @db.Uuid
|
||||||
accountId String @map("account_id") @db.Uuid
|
accountId String @map("account_id") @db.Uuid
|
||||||
@@ -307,6 +337,7 @@ model Message {
|
|||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
conversationId String @map("conversation_id") @db.Uuid
|
conversationId String @map("conversation_id") @db.Uuid
|
||||||
senderId String @map("sender_id") @db.Uuid
|
senderId String @map("sender_id") @db.Uuid
|
||||||
|
senderPublicId String @map("sender_public_id") @db.Uuid
|
||||||
clientMsgId String @map("client_msg_id") @db.VarChar(128)
|
clientMsgId String @map("client_msg_id") @db.VarChar(128)
|
||||||
seq BigInt
|
seq BigInt
|
||||||
contentText String @map("content_text") @db.Text
|
contentText String @map("content_text") @db.Text
|
||||||
@@ -322,6 +353,7 @@ model Message {
|
|||||||
|
|
||||||
@@unique([conversationId, clientMsgId])
|
@@unique([conversationId, clientMsgId])
|
||||||
@@unique([conversationId, seq])
|
@@unique([conversationId, seq])
|
||||||
|
@@index([conversationId, seq], map: "messages_conversation_seq_inbox_idx")
|
||||||
@@index([senderId, createdAt])
|
@@index([senderId, createdAt])
|
||||||
@@map("messages")
|
@@map("messages")
|
||||||
}
|
}
|
||||||
@@ -343,6 +375,7 @@ model Report {
|
|||||||
/// Exactly one nullable target foreign key must be set; enforced in 0001_init SQL.
|
/// Exactly one nullable target foreign key must be set; enforced in 0001_init SQL.
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
reporterId String @map("reporter_id") @db.Uuid
|
reporterId String @map("reporter_id") @db.Uuid
|
||||||
|
idempotencyKey String @default(uuid()) @map("idempotency_key") @db.VarChar(128)
|
||||||
reportedAccountId String? @map("reported_account_id") @db.Uuid
|
reportedAccountId String? @map("reported_account_id") @db.Uuid
|
||||||
bottleId String? @map("bottle_id") @db.Uuid
|
bottleId String? @map("bottle_id") @db.Uuid
|
||||||
conversationId String? @map("conversation_id") @db.Uuid
|
conversationId String? @map("conversation_id") @db.Uuid
|
||||||
@@ -363,6 +396,7 @@ model Report {
|
|||||||
|
|
||||||
@@index([status, createdAt])
|
@@index([status, createdAt])
|
||||||
@@index([reportedAccountId, createdAt])
|
@@index([reportedAccountId, createdAt])
|
||||||
|
@@unique([reporterId, idempotencyKey])
|
||||||
@@map("reports")
|
@@map("reports")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,6 +448,7 @@ model Sanction {
|
|||||||
model Notification {
|
model Notification {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
accountId String @map("account_id") @db.Uuid
|
accountId String @map("account_id") @db.Uuid
|
||||||
|
dedupeKey String @map("dedupe_key") @db.VarChar(255)
|
||||||
type String @db.VarChar(100)
|
type String @db.VarChar(100)
|
||||||
payload Json
|
payload Json
|
||||||
status NotificationStatus @default(PENDING)
|
status NotificationStatus @default(PENDING)
|
||||||
@@ -424,9 +459,19 @@ model Notification {
|
|||||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([accountId, status, createdAt])
|
@@index([accountId, status, createdAt])
|
||||||
|
@@unique([accountId, dedupeKey])
|
||||||
@@map("notifications")
|
@@map("notifications")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model PushPreference {
|
||||||
|
accountId String @id @map("account_id") @db.Uuid
|
||||||
|
inAppEnabled Boolean @default(true) @map("in_app_enabled")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||||
|
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("push_preferences")
|
||||||
|
}
|
||||||
|
|
||||||
model OutboxEvent {
|
model OutboxEvent {
|
||||||
/// Processing lease index outbox_events_processing_lease_idx is managed in 0007 SQL.
|
/// Processing lease index outbox_events_processing_lease_idx is managed in 0007 SQL.
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
|||||||
+8
-1
@@ -23,10 +23,16 @@ async function seed(): Promise<void> {
|
|||||||
nickname: "星河",
|
nickname: "星河",
|
||||||
publicId: "00000000-0000-4000-8000-000000000002",
|
publicId: "00000000-0000-4000-8000-000000000002",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "demo-admin",
|
||||||
|
nickname: "管理员",
|
||||||
|
publicId: "00000000-0000-4000-8000-000000000003",
|
||||||
|
},
|
||||||
].map(({ key, nickname, publicId }) =>
|
].map(({ key, nickname, publicId }) =>
|
||||||
prisma.account.upsert({
|
prisma.account.upsert({
|
||||||
where: { phoneHmac: id(`drift-bottle:${key}:phone-hmac`) },
|
where: { phoneHmac: id(`drift-bottle:${key}:phone-hmac`) },
|
||||||
update: {
|
update: {
|
||||||
|
role: key === "demo-admin" ? "ADMIN" : "USER",
|
||||||
anonymousProfile: {
|
anonymousProfile: {
|
||||||
upsert: {
|
upsert: {
|
||||||
create: {
|
create: {
|
||||||
@@ -44,6 +50,7 @@ async function seed(): Promise<void> {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
|
role: key === "demo-admin" ? "ADMIN" : "USER",
|
||||||
phoneCiphertext: Buffer.from(
|
phoneCiphertext: Buffer.from(
|
||||||
id(`drift-bottle:${key}:ciphertext`),
|
id(`drift-bottle:${key}:ciphertext`),
|
||||||
"hex",
|
"hex",
|
||||||
@@ -78,7 +85,7 @@ async function seed(): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.info(
|
console.info(
|
||||||
"Seed complete: 2 demo accounts, 2 anonymous profiles, 1 in-pool bottle.",
|
"Seed complete: 3 demo accounts (including admin), 3 anonymous profiles, 1 in-pool bottle.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,72 @@ describe("database authority constraints", () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses the database-only bottle pick candidate partial index", async () => {
|
||||||
|
const author = await createAccount("candidate-index-author");
|
||||||
|
await prisma.bottle.createMany({
|
||||||
|
data: [
|
||||||
|
...Array.from({ length: 2_000 }, (_, index) => ({
|
||||||
|
authorId: author.id,
|
||||||
|
clientRequestId: `ineligible-${index}`,
|
||||||
|
contentText: `ineligible-${index}`,
|
||||||
|
reviewStatus: "REVIEWING" as const,
|
||||||
|
poolStatus: "CLOSED" as const,
|
||||||
|
})),
|
||||||
|
...Array.from({ length: 3 }, (_, index) => ({
|
||||||
|
authorId: author.id,
|
||||||
|
clientRequestId: `eligible-${index}`,
|
||||||
|
contentText: `eligible-${index}`,
|
||||||
|
reviewStatus: "APPROVED" as const,
|
||||||
|
poolStatus: "IN_POOL" as const,
|
||||||
|
approvedAt: new Date(),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [index] = await prisma.$queryRaw<Array<{ indexdef: string }>>`
|
||||||
|
SELECT indexdef FROM pg_indexes
|
||||||
|
WHERE schemaname = 'public' AND tablename = 'bottles'
|
||||||
|
AND indexname = 'bottles_pick_candidates_id_idx'`;
|
||||||
|
expect(index?.indexdef).toMatch(
|
||||||
|
/ON public\.bottles USING btree \(id\) WHERE .*review_status = 'APPROVED'.*pool_status = 'IN_POOL'.*active_lease_id IS NULL/,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { plan, rows } = await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.$executeRawUnsafe("SET LOCAL enable_seqscan = off");
|
||||||
|
const explained = await tx.$queryRawUnsafe<
|
||||||
|
Array<{ "QUERY PLAN": Array<{ Plan: { "Index Name"?: string } }> }>
|
||||||
|
>(`EXPLAIN (FORMAT JSON)
|
||||||
|
SELECT id FROM bottles
|
||||||
|
WHERE review_status = 'APPROVED'
|
||||||
|
AND pool_status = 'IN_POOL'
|
||||||
|
AND active_lease_id IS NULL
|
||||||
|
ORDER BY id LIMIT 32`);
|
||||||
|
const selected = await tx.$queryRaw<Array<{ content_text: string }>>`
|
||||||
|
SELECT content_text FROM bottles
|
||||||
|
WHERE review_status = 'APPROVED'
|
||||||
|
AND pool_status = 'IN_POOL'
|
||||||
|
AND active_lease_id IS NULL
|
||||||
|
ORDER BY id LIMIT 32`;
|
||||||
|
return { plan: explained[0]!["QUERY PLAN"][0]!.Plan, rows: selected };
|
||||||
|
});
|
||||||
|
const indexNames = (node: unknown): string[] => {
|
||||||
|
if (!node || typeof node !== "object") return [];
|
||||||
|
const record = node as Record<string, unknown>;
|
||||||
|
return [
|
||||||
|
...(typeof record["Index Name"] === "string"
|
||||||
|
? [record["Index Name"]]
|
||||||
|
: []),
|
||||||
|
...Object.values(record).flatMap(indexNames),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
expect(indexNames(plan)).toContain("bottles_pick_candidates_id_idx");
|
||||||
|
expect(rows.map(({ content_text }) => content_text).sort()).toEqual([
|
||||||
|
"eligible-0",
|
||||||
|
"eligible-1",
|
||||||
|
"eligible-2",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("backfills and requires the session token version snapshot", async () => {
|
it("backfills and requires the session token version snapshot", async () => {
|
||||||
const columns = await prisma.$queryRaw<
|
const columns = await prisma.$queryRaw<
|
||||||
Array<{ is_nullable: string; column_default: string | null }>
|
Array<{ is_nullable: string; column_default: string | null }>
|
||||||
@@ -165,6 +231,7 @@ describe("database authority constraints", () => {
|
|||||||
bottleId: bottle.id,
|
bottleId: bottle.id,
|
||||||
pickerId: firstPicker.id,
|
pickerId: firstPicker.id,
|
||||||
leaseTokenHash: "lease-a",
|
leaseTokenHash: "lease-a",
|
||||||
|
leaseTokenCiphertext: Buffer.from("ciphertext-a"),
|
||||||
expiresAt: new Date(Date.now() + 60_000),
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -174,6 +241,7 @@ describe("database authority constraints", () => {
|
|||||||
bottleId: bottle.id,
|
bottleId: bottle.id,
|
||||||
pickerId: secondPicker.id,
|
pickerId: secondPicker.id,
|
||||||
leaseTokenHash: "lease-b",
|
leaseTokenHash: "lease-b",
|
||||||
|
leaseTokenCiphertext: Buffer.from("ciphertext-b"),
|
||||||
expiresAt: new Date(Date.now() + 60_000),
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -188,6 +256,7 @@ describe("database authority constraints", () => {
|
|||||||
bottleId: bottle.id,
|
bottleId: bottle.id,
|
||||||
pickerId: secondPicker.id,
|
pickerId: secondPicker.id,
|
||||||
leaseTokenHash: "lease-c",
|
leaseTokenHash: "lease-c",
|
||||||
|
leaseTokenCiphertext: Buffer.from("ciphertext-c"),
|
||||||
expiresAt: new Date(Date.now() + 60_000),
|
expiresAt: new Date(Date.now() + 60_000),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -229,6 +298,7 @@ describe("database authority constraints", () => {
|
|||||||
data: {
|
data: {
|
||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
senderId: author.id,
|
senderId: author.id,
|
||||||
|
senderPublicId: author.id,
|
||||||
clientMsgId: "msg",
|
clientMsgId: "msg",
|
||||||
seq: 1n,
|
seq: 1n,
|
||||||
contentText: "hello",
|
contentText: "hello",
|
||||||
@@ -257,6 +327,7 @@ describe("database authority constraints", () => {
|
|||||||
data: {
|
data: {
|
||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
senderId: author.id,
|
senderId: author.id,
|
||||||
|
senderPublicId: author.id,
|
||||||
clientMsgId: "duplicate-client-id",
|
clientMsgId: "duplicate-client-id",
|
||||||
seq: 1n,
|
seq: 1n,
|
||||||
contentText: "first",
|
contentText: "first",
|
||||||
@@ -269,6 +340,7 @@ describe("database authority constraints", () => {
|
|||||||
data: {
|
data: {
|
||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
senderId: author.id,
|
senderId: author.id,
|
||||||
|
senderPublicId: author.id,
|
||||||
clientMsgId: "duplicate-client-id",
|
clientMsgId: "duplicate-client-id",
|
||||||
seq: 2n,
|
seq: 2n,
|
||||||
contentText: "second",
|
contentText: "second",
|
||||||
@@ -289,6 +361,7 @@ describe("database authority constraints", () => {
|
|||||||
data: {
|
data: {
|
||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
senderId: author.id,
|
senderId: author.id,
|
||||||
|
senderPublicId: author.id,
|
||||||
clientMsgId: "first-client-id",
|
clientMsgId: "first-client-id",
|
||||||
seq: 1n,
|
seq: 1n,
|
||||||
contentText: "first",
|
contentText: "first",
|
||||||
@@ -301,6 +374,7 @@ describe("database authority constraints", () => {
|
|||||||
data: {
|
data: {
|
||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
senderId: author.id,
|
senderId: author.id,
|
||||||
|
senderPublicId: author.id,
|
||||||
clientMsgId: "second-client-id",
|
clientMsgId: "second-client-id",
|
||||||
seq: 1n,
|
seq: 1n,
|
||||||
contentText: "second",
|
contentText: "second",
|
||||||
@@ -357,6 +431,7 @@ describe("database authority constraints", () => {
|
|||||||
data: {
|
data: {
|
||||||
conversationId: conversation.id,
|
conversationId: conversation.id,
|
||||||
senderId: author.id,
|
senderId: author.id,
|
||||||
|
senderPublicId: author.id,
|
||||||
clientMsgId: "zero",
|
clientMsgId: "zero",
|
||||||
seq: 0n,
|
seq: 0n,
|
||||||
contentText: "x",
|
contentText: "x",
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
assertSafeTestDatabaseUrl,
|
||||||
|
resolveTestDatabaseUrl,
|
||||||
|
} from "../../prisma/database-safety";
|
||||||
|
|
||||||
|
const databaseUrl = resolveTestDatabaseUrl();
|
||||||
|
const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } } });
|
||||||
|
|
||||||
|
async function executeStatements(
|
||||||
|
tx: Parameters<Parameters<typeof prisma.$transaction>[0]>[0],
|
||||||
|
sql: string,
|
||||||
|
) {
|
||||||
|
for (const statement of sql
|
||||||
|
.split(";")
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean))
|
||||||
|
await tx.$executeRawUnsafe(statement);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("0008 legacy lease upgrade", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
assertSafeTestDatabaseUrl(databaseUrl);
|
||||||
|
await prisma.$connect();
|
||||||
|
});
|
||||||
|
afterAll(async () => prisma.$disconnect());
|
||||||
|
|
||||||
|
it("expires legacy ACTIVE leases, releases their bottles, and preserves terminal rows", async () => {
|
||||||
|
const schema = `migration_0008_${randomUUID().replaceAll("-", "")}`;
|
||||||
|
const authorId = randomUUID();
|
||||||
|
const pickerId = randomUUID();
|
||||||
|
const activeBottleId = randomUUID();
|
||||||
|
const returnedBottleId = randomUUID();
|
||||||
|
const activeLeaseId = randomUUID();
|
||||||
|
const returnedLeaseId = randomUUID();
|
||||||
|
const migration = await readFile(
|
||||||
|
new URL(
|
||||||
|
"../../prisma/migrations/0008_bottle_pick_leases/migration.sql",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.$executeRawUnsafe(`CREATE SCHEMA "${schema}"`);
|
||||||
|
await tx.$executeRawUnsafe(`SET LOCAL search_path TO "${schema}"`);
|
||||||
|
await executeStatements(
|
||||||
|
tx,
|
||||||
|
`
|
||||||
|
CREATE TYPE "BottlePoolStatus" AS ENUM ('IN_POOL', 'LEASED', 'CONSUMED', 'REMOVED', 'CLOSED');
|
||||||
|
CREATE TYPE "BottlePickLeaseStatus" AS ENUM ('ACTIVE', 'RETURNED', 'EXPIRED', 'CONSUMED');
|
||||||
|
CREATE TABLE "accounts" ("id" UUID PRIMARY KEY);
|
||||||
|
CREATE TABLE "bottles" (
|
||||||
|
"id" UUID PRIMARY KEY,
|
||||||
|
"pool_status" "BottlePoolStatus" NOT NULL,
|
||||||
|
"version" INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE "bottle_pick_leases" (
|
||||||
|
"id" UUID PRIMARY KEY,
|
||||||
|
"bottle_id" UUID NOT NULL REFERENCES "bottles"("id"),
|
||||||
|
"picker_id" UUID NOT NULL REFERENCES "accounts"("id"),
|
||||||
|
"lease_token_hash" VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
"status" "BottlePickLeaseStatus" NOT NULL,
|
||||||
|
"ended_at" TIMESTAMPTZ(3),
|
||||||
|
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
INSERT INTO "accounts" ("id") VALUES ('${authorId}'), ('${pickerId}');
|
||||||
|
INSERT INTO "bottles" ("id", "pool_status", "version") VALUES
|
||||||
|
('${activeBottleId}', 'LEASED', 4),
|
||||||
|
('${returnedBottleId}', 'CLOSED', 7);
|
||||||
|
INSERT INTO "bottle_pick_leases"
|
||||||
|
("id", "bottle_id", "picker_id", "lease_token_hash", "status", "ended_at") VALUES
|
||||||
|
('${activeLeaseId}', '${activeBottleId}', '${pickerId}', 'active-hash', 'ACTIVE', NULL),
|
||||||
|
('${returnedLeaseId}', '${returnedBottleId}', '${pickerId}', 'returned-hash', 'RETURNED', CURRENT_TIMESTAMP);
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
await executeStatements(tx, migration);
|
||||||
|
|
||||||
|
const leases = await tx.$queryRawUnsafe<
|
||||||
|
Array<{
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
endedAt: Date | null;
|
||||||
|
ciphertextHex: string;
|
||||||
|
}>
|
||||||
|
>(`
|
||||||
|
SELECT "id", "status", "ended_at" AS "endedAt",
|
||||||
|
encode("lease_token_ciphertext", 'hex') AS "ciphertextHex"
|
||||||
|
FROM "bottle_pick_leases" ORDER BY "id"
|
||||||
|
`);
|
||||||
|
const bottles = await tx.$queryRawUnsafe<
|
||||||
|
Array<{ id: string; poolStatus: string; version: number }>
|
||||||
|
>(`
|
||||||
|
SELECT "id", "pool_status" AS "poolStatus", "version"
|
||||||
|
FROM "bottles" ORDER BY "id"
|
||||||
|
`);
|
||||||
|
const ciphertextColumn = await tx.$queryRawUnsafe<
|
||||||
|
Array<{ isNullable: string }>
|
||||||
|
>(`
|
||||||
|
SELECT is_nullable AS "isNullable" FROM information_schema.columns
|
||||||
|
WHERE table_schema = '${schema}' AND table_name = 'bottle_pick_leases'
|
||||||
|
AND column_name = 'lease_token_ciphertext'
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(leases.find(({ id }) => id === activeLeaseId)).toMatchObject({
|
||||||
|
status: "EXPIRED",
|
||||||
|
endedAt: expect.any(Date) as Date,
|
||||||
|
ciphertextHex: "00".repeat(48),
|
||||||
|
});
|
||||||
|
expect(leases.find(({ id }) => id === returnedLeaseId)).toMatchObject({
|
||||||
|
status: "RETURNED",
|
||||||
|
endedAt: expect.any(Date) as Date,
|
||||||
|
ciphertextHex: "00".repeat(48),
|
||||||
|
});
|
||||||
|
expect(bottles.find(({ id }) => id === activeBottleId)).toMatchObject({
|
||||||
|
poolStatus: "IN_POOL",
|
||||||
|
version: 5,
|
||||||
|
});
|
||||||
|
expect(bottles.find(({ id }) => id === returnedBottleId)).toMatchObject(
|
||||||
|
{
|
||||||
|
poolStatus: "CLOSED",
|
||||||
|
version: 7,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(ciphertextColumn).toEqual([{ isNullable: "NO" }]);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
`DROP SCHEMA IF EXISTS "${schema}" CASCADE`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,6 +28,6 @@ describe("seed", () => {
|
|||||||
prisma.anonymousProfile.count(),
|
prisma.anonymousProfile.count(),
|
||||||
prisma.bottle.count({ where: { poolStatus: "IN_POOL" } }),
|
prisma.bottle.count({ where: { poolStatus: "IN_POOL" } }),
|
||||||
]),
|
]),
|
||||||
).resolves.toEqual([2, 2, 1]);
|
).resolves.toEqual([3, 3, 1]);
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user