Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| afab4e7cba | |||
| 7ca855f19c | |||
| 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>
|
||||
VERIFICATION_CODE_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_ISSUER=drift-api
|
||||
JWT_AUDIENCE=drift-web
|
||||
@@ -19,4 +21,10 @@ REFRESH_ATTEMPT_IP_WINDOW_SECONDS=60
|
||||
REFRESH_RATE_LIMIT=60
|
||||
SMS_CODE_TTL_SECONDS=300
|
||||
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
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"start": "node dist/main.js",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -15,6 +15,8 @@
|
||||
"@nestjs/common": "^11.1.6",
|
||||
"@nestjs/core": "^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",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
@@ -22,12 +24,14 @@
|
||||
"helmet": "^8.1.0",
|
||||
"redis": "^5.8.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2"
|
||||
"rxjs": "^7.8.2",
|
||||
"socket.io": "^4.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/testing": "^11.1.6",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"supertest": "^7.1.4",
|
||||
"typescript": "^5.6.3",
|
||||
"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 { ProfileModule } from "./profile/profile.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 {
|
||||
configure(consumer: MiddlewareConsumer): void {
|
||||
consumer.apply(RequestIdMiddleware).forRoutes("{*path}");
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
codeHmac,
|
||||
decryptLeaseToken,
|
||||
demoSmsCodeEnabled,
|
||||
encryptLeaseToken,
|
||||
phoneHmac,
|
||||
resetAuthEnvironmentForTests,
|
||||
validateAuthEnvironment,
|
||||
@@ -13,6 +15,8 @@ const valid = {
|
||||
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!",
|
||||
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",
|
||||
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", () => {
|
||||
validateAuthEnvironment();
|
||||
const phoneDigest = phoneHmac("+8613800138000");
|
||||
|
||||
@@ -9,8 +9,7 @@ import {
|
||||
function required(name: string, min = 32): Buffer {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`${name} is required`);
|
||||
const decoded =
|
||||
name === "PHONE_ENCRYPTION_KEY"
|
||||
const decoded = name.endsWith("_ENCRYPTION_KEY")
|
||||
? Buffer.from(value, "base64")
|
||||
: Buffer.from(value);
|
||||
if (decoded.length < min)
|
||||
@@ -22,6 +21,8 @@ const REQUIRED_KEYS = [
|
||||
"PHONE_HMAC_KEY",
|
||||
"VERIFICATION_CODE_HMAC_KEY",
|
||||
"REFRESH_TOKEN_HMAC_KEY",
|
||||
"LEASE_TOKEN_HMAC_KEY",
|
||||
"LEASE_TOKEN_ENCRYPTION_KEY",
|
||||
"JWT_SECRET",
|
||||
] as const;
|
||||
type AuthSecrets = Record<(typeof REQUIRED_KEYS)[number], Buffer>;
|
||||
@@ -56,8 +57,13 @@ export function validateAuthEnvironment(): void {
|
||||
const loaded = Object.fromEntries(
|
||||
REQUIRED_KEYS.map((name) => [name, required(name)]),
|
||||
) as unknown as AuthSecrets;
|
||||
if (loaded.PHONE_ENCRYPTION_KEY.length !== 32)
|
||||
throw new Error("PHONE_ENCRYPTION_KEY must decode to exactly 32 bytes");
|
||||
for (const name of [
|
||||
"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) =>
|
||||
loaded[name].toString("hex"),
|
||||
);
|
||||
@@ -103,6 +109,43 @@ export const refreshHmac = (token: string): string =>
|
||||
createHmac("sha256", authSecrets().REFRESH_TOKEN_HMAC_KEY)
|
||||
.update(token)
|
||||
.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 => {
|
||||
const x = Buffer.from(a);
|
||||
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.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";
|
||||
@@ -777,6 +780,36 @@ describe("auth real PostgreSQL/Redis", () => {
|
||||
.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 () => {
|
||||
const sockets = new Set<Socket>();
|
||||
const server = createServer((socket) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ErrorCode } from "@drift/contracts";
|
||||
import type { Request } from "express";
|
||||
import { DomainException } from "../common/domain.exception.js";
|
||||
import { PrismaService } from "../database/prisma.service.js";
|
||||
import type { AccessClaims } from "./token.service.js";
|
||||
import { TokenService } from "./token.service.js";
|
||||
import type { AuthenticatedRequest } from "./current-user.decorator.js";
|
||||
@Injectable()
|
||||
@@ -17,11 +18,8 @@ export class AuthGuard implements CanActivate {
|
||||
@Inject(PrismaService) private readonly prisma: PrismaService,
|
||||
@Inject(TokenService) private readonly tokens: TokenService,
|
||||
) {}
|
||||
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();
|
||||
const claims = this.tokens.verifyAccess(auth.slice(7));
|
||||
async validateAccess(token: string): Promise<AccessClaims> {
|
||||
const claims = this.tokens.verifyAccess(token);
|
||||
const session = await this.prisma.session.findUnique({
|
||||
where: { id: claims.session_id },
|
||||
include: { account: true },
|
||||
@@ -37,7 +35,26 @@ export class AuthGuard implements CanActivate {
|
||||
session.account.tokenVersion !== claims.token_version
|
||||
)
|
||||
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;
|
||||
}
|
||||
private denied() {
|
||||
|
||||
@@ -148,6 +148,18 @@ export class AuthService {
|
||||
});
|
||||
if (!account) account = await this.createAccount(phone, digest);
|
||||
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);
|
||||
}
|
||||
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!";
|
||||
process.env.REFRESH_TOKEN_HMAC_KEY =
|
||||
"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";
|
||||
|
||||
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.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";
|
||||
@@ -252,8 +255,8 @@ describe("bottles with real PostgreSQL", () => {
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
},
|
||||
});
|
||||
const denied = await create(randomUUID()).expect(403);
|
||||
expect(denied.body.code).toBe("ACCOUNT_SANCTIONED");
|
||||
const denied = await create(randomUUID()).expect(401);
|
||||
expect(denied.body.code).toBe("AUTH_UNAUTHORIZED");
|
||||
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 { BottleController } from "./bottle.controller.js";
|
||||
import { BottleService } from "./bottle.service.js";
|
||||
import { SafetyModule } from "../safety/safety.module.js";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, DatabaseModule],
|
||||
imports: [AuthModule, DatabaseModule, SafetyModule],
|
||||
controllers: [BottleController],
|
||||
providers: [BottleService],
|
||||
})
|
||||
|
||||
@@ -5,20 +5,19 @@ import { Prisma } from "@prisma/client";
|
||||
import { DomainException } from "../common/domain.exception.js";
|
||||
import { PrismaService } from "../database/prisma.service.js";
|
||||
import { utc8UsageDate } from "./usage-date.js";
|
||||
import { SafetyLockService } from "../safety/safety-lock.service.js";
|
||||
|
||||
@Injectable()
|
||||
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) {
|
||||
const existing = await this.prisma.bottle.findUnique({
|
||||
where: {
|
||||
authorId_clientRequestId: { authorId, clientRequestId: requestId },
|
||||
},
|
||||
});
|
||||
if (existing) return this.resolveIdempotent(existing, contentText);
|
||||
try {
|
||||
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))`;
|
||||
const duplicate = await tx.bottle.findUnique({
|
||||
where: {
|
||||
@@ -27,6 +26,10 @@ export class BottleService {
|
||||
});
|
||||
if (duplicate) return this.resolveIdempotent(duplicate, contentText);
|
||||
const now = new Date();
|
||||
const account = await tx.account.findUnique({
|
||||
where: { id: authorId },
|
||||
select: { status: true },
|
||||
});
|
||||
const sanctioned = await tx.sanction.findFirst({
|
||||
where: {
|
||||
accountId: authorId,
|
||||
@@ -37,7 +40,7 @@ export class BottleService {
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (sanctioned)
|
||||
if (account?.status !== "ACTIVE" || sanctioned)
|
||||
throw new DomainException(
|
||||
ErrorCode.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 { 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 & {
|
||||
isOpen: boolean;
|
||||
connect: ReturnType<typeof vi.fn>;
|
||||
@@ -375,7 +390,9 @@ describe("API infrastructure", () => {
|
||||
const previousUrl = process.env.REDIS_URL;
|
||||
const previousTimeout = process.env.REDIS_PROBE_TIMEOUT_MS;
|
||||
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
|
||||
.spyOn(Logger.prototype, "warn")
|
||||
.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.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";
|
||||
|
||||
@@ -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> {
|
||||
/* 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,17 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,viewport-fit=cover"
|
||||
/>
|
||||
<meta name="theme-color" content="#061b2b" />
|
||||
<meta name="description" content="匿名漂流瓶深海社交" />
|
||||
<title>漂流瓶 · 深海回声</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@drift/web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"test": "vitest run",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"build": "vue-tsc --noEmit && vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@drift/contracts": "workspace:*",
|
||||
"pinia": "^3.0.3",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/vue": "^8.1.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"jsdom": "^27.0.0",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^7.1.7",
|
||||
"vite-plugin-pwa": "^1.0.3",
|
||||
"vitest": "^4.1.11",
|
||||
"vue-tsc": "^3.0.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" rx="112" fill="#061b2b"/><circle cx="256" cy="210" r="128" fill="#0b4050"/><path d="M180 130h152l-20 196c-5 48-107 48-112 0z" fill="#76e4d5" opacity=".85"/><path d="M160 350q96-70 192 0" fill="none" stroke="#f1c27d" stroke-width="22" stroke-linecap="round"/></svg>
|
||||
|
After Width: | Height: | Size: 358 B |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "漂流瓶 · 深海回声",
|
||||
"short_name": "漂流瓶",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#04111d",
|
||||
"theme_color": "#061b2b",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import OfflineBanner from "./components/OfflineBanner.vue";
|
||||
import TabBar from "./components/TabBar.vue";
|
||||
const route = useRoute();
|
||||
const shell = computed(
|
||||
() => route.name !== "login" && route.name !== "admin-login",
|
||||
);
|
||||
</script>
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<OfflineBanner /><RouterView /><TabBar v-if="shell" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createApiClient } from "./client";
|
||||
|
||||
const response = (status: number, data: unknown) =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
code: status < 400 ? "OK" : "AUTH_TOKEN_EXPIRED",
|
||||
message: status < 400 ? "success" : "expired",
|
||||
data,
|
||||
requestId: crypto.randomUUID(),
|
||||
}),
|
||||
{ status, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
|
||||
describe("API client authentication", () => {
|
||||
it("coalesces concurrent 401 refreshes and retries both requests", async () => {
|
||||
let token = "expired";
|
||||
let refreshes = 0;
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const path =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
if (path.endsWith("/auth/token/refresh")) {
|
||||
refreshes += 1;
|
||||
await Promise.resolve();
|
||||
token = "fresh";
|
||||
return response(200, {
|
||||
accessToken: token,
|
||||
expiresIn: 900,
|
||||
tokenType: "Bearer",
|
||||
});
|
||||
}
|
||||
return token === "expired"
|
||||
? response(401, null)
|
||||
: response(200, { path });
|
||||
});
|
||||
const client = createApiClient({
|
||||
fetcher,
|
||||
getToken: () => token,
|
||||
setToken: (next) => {
|
||||
token = next ?? "";
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([client.get("/me"), client.get("/conversations")]);
|
||||
|
||||
expect(refreshes).toBe(1);
|
||||
expect(fetcher).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it("coalesces concurrent refresh failures and logs out only once", async () => {
|
||||
let token: string | null = "expired";
|
||||
const setToken = vi.fn((next: string | null) => {
|
||||
token = next;
|
||||
});
|
||||
const onUnauthorized = vi.fn();
|
||||
let refreshes = 0;
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const path =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
if (path.endsWith("/auth/token/refresh")) {
|
||||
refreshes += 1;
|
||||
return response(401, null);
|
||||
}
|
||||
if (path.endsWith("/conversations"))
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
return response(401, null);
|
||||
});
|
||||
const client = createApiClient({
|
||||
fetcher,
|
||||
getToken: () => token,
|
||||
setToken,
|
||||
onUnauthorized,
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
client.get("/me"),
|
||||
client.get("/conversations"),
|
||||
]);
|
||||
|
||||
expect(results.every((result) => result.status === "rejected")).toBe(true);
|
||||
expect(refreshes).toBe(1);
|
||||
expect(setToken).toHaveBeenCalledTimes(1);
|
||||
expect(setToken).toHaveBeenCalledWith(null);
|
||||
expect(onUnauthorized).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not claim an offline write succeeded", async () => {
|
||||
const client = createApiClient({
|
||||
fetcher: vi.fn().mockRejectedValue(new TypeError("Failed to fetch")),
|
||||
getToken: () => "token",
|
||||
setToken: vi.fn(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.post("/bottles", { contentText: "hello" }),
|
||||
).rejects.toMatchObject({ code: "NETWORK_ERROR" });
|
||||
});
|
||||
|
||||
it("does not repeat refresh or logout after a failed refresh", async () => {
|
||||
let token: string | null = "expired";
|
||||
let refreshes = 0;
|
||||
const setToken = vi.fn((next: string | null) => {
|
||||
token = next;
|
||||
});
|
||||
const onUnauthorized = vi.fn();
|
||||
const fetcher = vi.fn((input: RequestInfo | URL): Promise<Response> => {
|
||||
const path =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
if (path.endsWith("/auth/token/refresh")) {
|
||||
refreshes += 1;
|
||||
return Promise.resolve(response(401, null));
|
||||
}
|
||||
return Promise.resolve(response(401, null));
|
||||
});
|
||||
const client = createApiClient({
|
||||
fetcher,
|
||||
getToken: () => token,
|
||||
setToken,
|
||||
onUnauthorized,
|
||||
});
|
||||
|
||||
await expect(client.get("/me")).rejects.toMatchObject({
|
||||
code: "AUTH_TOKEN_EXPIRED",
|
||||
});
|
||||
await expect(client.get("/conversations")).rejects.toMatchObject({
|
||||
code: "AUTH_TOKEN_EXPIRED",
|
||||
});
|
||||
|
||||
expect(refreshes).toBe(1);
|
||||
expect(onUnauthorized).toHaveBeenCalledOnce();
|
||||
expect(token).toBeNull();
|
||||
});
|
||||
|
||||
it("recovers refresh after an explicit new login", async () => {
|
||||
let token: string | null = "expired";
|
||||
let refreshSucceeds = false;
|
||||
let refreshes = 0;
|
||||
const onUnauthorized = vi.fn();
|
||||
const fetcher = vi.fn((input: RequestInfo | URL): Promise<Response> => {
|
||||
const path =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
if (path.endsWith("/auth/token/refresh")) {
|
||||
refreshes += 1;
|
||||
if (!refreshSucceeds) return Promise.resolve(response(401, null));
|
||||
return Promise.resolve(
|
||||
response(200, {
|
||||
accessToken: "fresh2",
|
||||
expiresIn: 900,
|
||||
tokenType: "Bearer",
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(response(401, null));
|
||||
});
|
||||
const client = createApiClient({
|
||||
fetcher,
|
||||
getToken: () => token,
|
||||
setToken: (next) => {
|
||||
token = next;
|
||||
},
|
||||
onUnauthorized,
|
||||
});
|
||||
|
||||
await expect(client.get("/me")).rejects.toMatchObject({
|
||||
code: "AUTH_TOKEN_EXPIRED",
|
||||
});
|
||||
expect(refreshes).toBe(1);
|
||||
|
||||
// 显式重新登录产生新会话
|
||||
token = "fresh";
|
||||
refreshSucceeds = true;
|
||||
|
||||
await expect(client.get("/me")).rejects.toMatchObject({
|
||||
code: "AUTH_TOKEN_EXPIRED",
|
||||
});
|
||||
expect(refreshes).toBe(2);
|
||||
expect(onUnauthorized).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
export interface ApiEnvelope<T> {
|
||||
code: string;
|
||||
message: string;
|
||||
data: T;
|
||||
requestId: string;
|
||||
}
|
||||
export interface ApiFailure {
|
||||
code: string;
|
||||
message: string;
|
||||
status: number | undefined;
|
||||
requestId: string | undefined;
|
||||
}
|
||||
export interface ApiClientOptions {
|
||||
fetcher?: typeof fetch;
|
||||
baseUrl?: string;
|
||||
getToken: () => string | null;
|
||||
setToken: (token: string | null) => void;
|
||||
onUnauthorized?: () => void;
|
||||
}
|
||||
|
||||
export class ApiError extends Error implements ApiFailure {
|
||||
public status: number | undefined;
|
||||
public requestId: string | undefined;
|
||||
constructor(
|
||||
public code: string,
|
||||
message: string,
|
||||
status?: number,
|
||||
requestId?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.requestId = requestId;
|
||||
}
|
||||
}
|
||||
|
||||
export function createApiClient(options: ApiClientOptions) {
|
||||
const fetcher = options.fetcher ?? fetch;
|
||||
const baseUrl = options.baseUrl ?? "/api/v1";
|
||||
let refreshing: Promise<string> | null = null;
|
||||
let failedRefresh: { token: string | null; error: unknown } | null = null;
|
||||
|
||||
async function refresh(
|
||||
staleToken: string | null = options.getToken(),
|
||||
): Promise<string> {
|
||||
// 同一认证失败周期内:只要令牌仍是失败时的令牌(或已被清空),
|
||||
// 就直接抛出已记录的失败,不再请求 refresh 接口或重复触发登出。
|
||||
if (
|
||||
failedRefresh !== null &&
|
||||
(staleToken === failedRefresh.token || staleToken === null)
|
||||
)
|
||||
throw failedRefresh.error;
|
||||
if (!refreshing) {
|
||||
refreshing = (async () => {
|
||||
try {
|
||||
const response = await fetcher(`${baseUrl}/auth/token/refresh`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
const body = (await response.json()) as ApiEnvelope<{
|
||||
accessToken: string;
|
||||
}>;
|
||||
if (!response.ok)
|
||||
throw new ApiError(
|
||||
body.code,
|
||||
body.message,
|
||||
response.status,
|
||||
body.requestId,
|
||||
);
|
||||
options.setToken(body.data.accessToken);
|
||||
failedRefresh = null;
|
||||
return body.data.accessToken;
|
||||
} catch (error) {
|
||||
const failure = networkError(error);
|
||||
failedRefresh = { token: staleToken, error: failure };
|
||||
options.setToken(null);
|
||||
options.onUnauthorized?.();
|
||||
throw failure;
|
||||
} finally {
|
||||
refreshing = null;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return refreshing;
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
retry = true,
|
||||
): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
const token = options.getToken();
|
||||
if (token) headers.set("authorization", `Bearer ${token}`);
|
||||
if (init.body && !headers.has("content-type"))
|
||||
headers.set("content-type", "application/json");
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetcher(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: "include",
|
||||
});
|
||||
} catch (error) {
|
||||
throw networkError(error);
|
||||
}
|
||||
const body = (await response.json()) as ApiEnvelope<T>;
|
||||
if (response.status === 401 && retry && path !== "/auth/token/refresh") {
|
||||
await refresh(token);
|
||||
return request<T>(path, init, false);
|
||||
}
|
||||
if (!response.ok)
|
||||
throw new ApiError(
|
||||
body.code,
|
||||
body.message,
|
||||
response.status,
|
||||
body.requestId,
|
||||
);
|
||||
return body.data;
|
||||
}
|
||||
return {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body?: unknown, headers?: HeadersInit) =>
|
||||
request<T>(path, {
|
||||
method: "POST",
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
...(headers === undefined ? {} : { headers }),
|
||||
}),
|
||||
put: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "PUT", body: JSON.stringify(body) }),
|
||||
patch: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
function networkError(error: unknown): ApiError {
|
||||
if (error instanceof ApiError) return error;
|
||||
return new ApiError(
|
||||
"NETWORK_ERROR",
|
||||
error instanceof Error ? error.message : "网络连接失败",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { bindSocketAuthentication, sendSocketMessage } from "./socket";
|
||||
|
||||
type Ack = (error: Error | null, response?: unknown) => void;
|
||||
|
||||
class FakeSocket {
|
||||
connected = false;
|
||||
handlers = new Map<string, (value?: unknown) => void>();
|
||||
emits: Array<{ event: string; payload: unknown; ack: Ack | undefined }> = [];
|
||||
connect = vi.fn(() => {
|
||||
this.connected = true;
|
||||
return this;
|
||||
});
|
||||
disconnect = vi.fn(() => {
|
||||
this.connected = false;
|
||||
return this;
|
||||
});
|
||||
timeout = vi.fn(() => this);
|
||||
emit = vi.fn((event: string, payload: unknown, ack?: Ack) => {
|
||||
this.emits.push({ event, payload, ack });
|
||||
return this;
|
||||
});
|
||||
on(event: string, handler: (value?: unknown) => void) {
|
||||
this.handlers.set(event, handler);
|
||||
return this;
|
||||
}
|
||||
emitEvent(event: string, value?: unknown) {
|
||||
this.handlers.get(event)?.(value);
|
||||
}
|
||||
ack(index: number, response: unknown) {
|
||||
this.emits[index]?.ack?.(null, response);
|
||||
}
|
||||
}
|
||||
|
||||
const okResponse = (id = "real-1") => ({
|
||||
ok: true,
|
||||
data: {
|
||||
message: {
|
||||
id,
|
||||
conversationId: "c-1",
|
||||
sender: { publicId: "p" },
|
||||
clientMsgId: "m-1",
|
||||
seq: "1",
|
||||
text: "hi",
|
||||
status: "sent",
|
||||
sentAt: new Date(0).toISOString(),
|
||||
},
|
||||
deduplicated: false,
|
||||
},
|
||||
});
|
||||
|
||||
const errorResponse = (code: string, message: string) => ({
|
||||
ok: false,
|
||||
error: { code, message },
|
||||
});
|
||||
|
||||
const payload = { conversationId: "c-1", clientMsgId: "m-1", text: "hi" };
|
||||
|
||||
describe("socket authentication recovery", () => {
|
||||
it("refreshes once after concurrent auth errors and reconnects with current token", async () => {
|
||||
let token = "expired";
|
||||
let release!: () => void;
|
||||
const refresh = vi.fn(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
release = () => {
|
||||
token = "fresh";
|
||||
resolve(token);
|
||||
};
|
||||
}),
|
||||
);
|
||||
const socket = new FakeSocket();
|
||||
bindSocketAuthentication(socket, refresh);
|
||||
|
||||
socket.emitEvent(
|
||||
"connect_error",
|
||||
Object.assign(new Error("Unauthorized"), {
|
||||
data: { code: "AUTH_TOKEN_EXPIRED" },
|
||||
}),
|
||||
);
|
||||
socket.emitEvent(
|
||||
"connect_error",
|
||||
Object.assign(new Error("Unauthorized"), {
|
||||
data: { code: "AUTH_TOKEN_EXPIRED" },
|
||||
}),
|
||||
);
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
release();
|
||||
await vi.waitFor(() => expect(socket.connect).toHaveBeenCalledOnce());
|
||||
expect(token).toBe("fresh");
|
||||
});
|
||||
|
||||
it("does not refresh unrelated connection failures", () => {
|
||||
const refresh = vi.fn();
|
||||
const socket = new FakeSocket();
|
||||
bindSocketAuthentication(socket, refresh);
|
||||
socket.emitEvent("connect_error", new Error("transport closed"));
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disconnects and stops recovering when refresh fails", async () => {
|
||||
const socket = new FakeSocket();
|
||||
let refreshCalls = 0;
|
||||
const refresh = vi.fn().mockImplementation(() => {
|
||||
refreshCalls += 1;
|
||||
return Promise.reject(new Error("refresh failed"));
|
||||
});
|
||||
bindSocketAuthentication(socket, refresh);
|
||||
|
||||
socket.emitEvent(
|
||||
"connect_error",
|
||||
Object.assign(new Error("Unauthorized"), {
|
||||
data: { code: "AUTH_TOKEN_EXPIRED" },
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => expect(socket.disconnect).toHaveBeenCalledOnce());
|
||||
|
||||
// 后续重连错误不会再次触发 refresh(锁保持,避免无限循环)
|
||||
socket.emitEvent(
|
||||
"connect_error",
|
||||
Object.assign(new Error("Unauthorized"), {
|
||||
data: { code: "AUTH_TOKEN_EXPIRED" },
|
||||
}),
|
||||
);
|
||||
expect(refreshCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("refreshes, reconnects and retries once with the same clientMsgId after an ACK auth error", async () => {
|
||||
const socket = new FakeSocket();
|
||||
const refresh = vi.fn().mockResolvedValue("fresh");
|
||||
|
||||
const sending = sendSocketMessage(socket as never, payload, refresh);
|
||||
|
||||
expect(socket.emits).toHaveLength(1);
|
||||
socket.ack(0, errorResponse("AUTH_TOKEN_EXPIRED", "expired"));
|
||||
await vi.waitFor(() => expect(refresh).toHaveBeenCalledOnce());
|
||||
|
||||
expect(socket.disconnect).toHaveBeenCalledOnce();
|
||||
expect(socket.connect).toHaveBeenCalledOnce();
|
||||
expect(socket.emits).toHaveLength(2);
|
||||
expect(socket.emits[1]!.payload).toMatchObject({ clientMsgId: "m-1" });
|
||||
socket.ack(1, okResponse());
|
||||
|
||||
await expect(sending).resolves.toMatchObject({
|
||||
message: { id: "real-1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not retry non-auth ACK failures", async () => {
|
||||
const socket = new FakeSocket();
|
||||
const refresh = vi.fn();
|
||||
|
||||
const sending = sendSocketMessage(socket as never, payload, refresh);
|
||||
socket.ack(0, errorResponse("RATE_LIMITED", "too many"));
|
||||
|
||||
await expect(sending).rejects.toMatchObject({ code: "RATE_LIMITED" });
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
expect(socket.emits).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not loop when the retried attempt also fails auth", async () => {
|
||||
const socket = new FakeSocket();
|
||||
const refresh = vi.fn().mockResolvedValue("fresh");
|
||||
|
||||
const sending = sendSocketMessage(socket as never, payload, refresh);
|
||||
socket.ack(0, errorResponse("AUTH_TOKEN_EXPIRED", "expired"));
|
||||
await vi.waitFor(() => expect(refresh).toHaveBeenCalledOnce());
|
||||
socket.ack(1, errorResponse("AUTH_TOKEN_EXPIRED", "expired"));
|
||||
|
||||
await expect(sending).rejects.toMatchObject({ code: "AUTH_TOKEN_EXPIRED" });
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
expect(socket.emits).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("rejects without refreshing when no refresh handler is provided", async () => {
|
||||
const socket = new FakeSocket();
|
||||
const sending = sendSocketMessage(socket as never, payload);
|
||||
socket.ack(0, errorResponse("AUTH_TOKEN_EXPIRED", "expired"));
|
||||
|
||||
await expect(sending).rejects.toMatchObject({
|
||||
code: "AUTH_TOKEN_EXPIRED",
|
||||
});
|
||||
expect(socket.emits).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { io, type Socket } from "socket.io-client";
|
||||
import type { ChatMessage } from "../stores/messages";
|
||||
|
||||
export interface SocketResponse<T = unknown> {
|
||||
ok: boolean;
|
||||
data?: T;
|
||||
error?: { code: string; message: string };
|
||||
}
|
||||
export interface AuthenticationSocket {
|
||||
connected: boolean;
|
||||
connect(): unknown;
|
||||
disconnect(): unknown;
|
||||
on(event: "connect_error", handler: (error: unknown) => void): unknown;
|
||||
}
|
||||
|
||||
export type SocketError = Error & { code?: string };
|
||||
|
||||
export function bindSocketAuthentication(
|
||||
socket: AuthenticationSocket,
|
||||
refresh: () => Promise<string>,
|
||||
) {
|
||||
let recovering: Promise<void> | null = null;
|
||||
socket.on("connect_error", (error: unknown) => {
|
||||
const candidate = error as { message?: string; data?: { code?: string } };
|
||||
const authenticationError =
|
||||
candidate.data?.code?.startsWith("AUTH_") ||
|
||||
/unauthorized|authentication|token/i.test(candidate.message ?? "");
|
||||
if (!authenticationError || recovering) return;
|
||||
recovering = refresh()
|
||||
.then(() => {
|
||||
socket.disconnect();
|
||||
socket.connect();
|
||||
})
|
||||
.catch(() => {
|
||||
// 刷新失败:断开连接并“锁死”恢复路径,避免无限重连循环。
|
||||
// 只有显式重新登录(新会话)重建 socket 后才会再次武装恢复。
|
||||
socket.disconnect();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function createChatSocket(
|
||||
token: () => string | null,
|
||||
refresh?: () => Promise<string>,
|
||||
): Socket {
|
||||
const socket = io("/chat", {
|
||||
autoConnect: false,
|
||||
withCredentials: true,
|
||||
auth: (callback) => callback({ token: token() }),
|
||||
});
|
||||
if (refresh) bindSocketAuthentication(socket, refresh);
|
||||
return socket;
|
||||
}
|
||||
|
||||
function responseError(response: SocketResponse): SocketError {
|
||||
const error = new Error(response.error?.message ?? "发送失败") as SocketError;
|
||||
if (response.error?.code) error.code = response.error.code;
|
||||
return error;
|
||||
}
|
||||
|
||||
function emitOnce(
|
||||
socket: Socket,
|
||||
payload: { conversationId: string; clientMsgId: string; text: string },
|
||||
): Promise<{ message: ChatMessage; deduplicated: boolean }> {
|
||||
return new Promise((resolve, reject) =>
|
||||
socket.timeout(10000).emit(
|
||||
"message:send",
|
||||
payload,
|
||||
(
|
||||
error: Error | null,
|
||||
response: SocketResponse<{
|
||||
message: ChatMessage;
|
||||
deduplicated: boolean;
|
||||
}>,
|
||||
) => {
|
||||
if (error) return reject(error);
|
||||
if (!response.ok || !response.data)
|
||||
return reject(responseError(response));
|
||||
resolve(response.data);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function sendSocketMessage(
|
||||
socket: Socket,
|
||||
payload: { conversationId: string; clientMsgId: string; text: string },
|
||||
refresh?: () => Promise<string>,
|
||||
): Promise<{ message: ChatMessage; deduplicated: boolean }> {
|
||||
return emitOnce(socket, payload).catch(async (error: SocketError) => {
|
||||
if (!refresh || !error.code?.startsWith("AUTH_")) throw error;
|
||||
await refresh();
|
||||
socket.disconnect();
|
||||
socket.connect();
|
||||
return emitOnce(socket, payload);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ title: string; back?: boolean }>();
|
||||
</script>
|
||||
<template>
|
||||
<header class="topbar">
|
||||
<RouterLink v-if="back" to="/" class="icon-link" aria-label="返回首页"
|
||||
>‹</RouterLink
|
||||
>
|
||||
<div>
|
||||
<small>DRIFT / 深海</small>
|
||||
<h1>{{ title }}</h1>
|
||||
</div>
|
||||
<slot />
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from "vue";
|
||||
const online = ref(navigator.onLine);
|
||||
const update = () => (online.value = navigator.onLine);
|
||||
onMounted(() => {
|
||||
addEventListener("online", update);
|
||||
addEventListener("offline", update);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
removeEventListener("online", update);
|
||||
removeEventListener("offline", update);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="!online" class="offline" role="status">
|
||||
离线模式 · 写操作将明确失败
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
import { cleanup, render, screen } from "@testing-library/vue";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
afterEach(cleanup);
|
||||
import StatusPanel from "./StatusPanel.vue";
|
||||
|
||||
describe("StatusPanel", () => {
|
||||
it.each([
|
||||
["loading", "正在潜入深海"],
|
||||
["empty", "这里还没有内容"],
|
||||
["error", "海浪暂时阻断了连接"],
|
||||
["offline", "当前离线"],
|
||||
] as const)("renders an accessible %s state", (state, label) => {
|
||||
render(StatusPanel, { props: { state } });
|
||||
expect(screen.getByRole("status")).toHaveTextContent(label);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
state: "loading" | "empty" | "error" | "offline";
|
||||
detail?: string;
|
||||
}>();
|
||||
const labels = {
|
||||
loading: "正在潜入深海…",
|
||||
empty: "这里还没有内容",
|
||||
error: "海浪暂时阻断了连接",
|
||||
offline: "当前离线,写操作不会被保存",
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div class="status" role="status" aria-live="polite">
|
||||
<span aria-hidden="true">{{
|
||||
props.state === "loading" ? "◌" : props.state === "empty" ? "○" : "!"
|
||||
}}</span>
|
||||
<p>{{ labels[props.state] }}</p>
|
||||
<small v-if="detail">{{ detail }}</small>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.status {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
.status span {
|
||||
font-size: 2rem;
|
||||
color: var(--aqua);
|
||||
}
|
||||
.status p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.status small {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from "vue-router";
|
||||
const route = useRoute();
|
||||
const links: [string, string][] = [
|
||||
["/", "海面"],
|
||||
["/conversations", "回声"],
|
||||
["/my-bottles", "瓶子"],
|
||||
["/settings", "设置"],
|
||||
];
|
||||
</script>
|
||||
<template>
|
||||
<nav class="tabs" aria-label="主导航">
|
||||
<RouterLink
|
||||
v-for="link in links"
|
||||
:key="link[0]"
|
||||
:to="link[0]"
|
||||
:aria-current="route.path === link[0] ? 'page' : undefined"
|
||||
>{{ link[1] }}</RouterLink
|
||||
>
|
||||
</nav>
|
||||
</template>
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<
|
||||
Record<string, unknown>,
|
||||
Record<string, unknown>,
|
||||
unknown
|
||||
>;
|
||||
export default component;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import App from "./App.vue";
|
||||
import { createAppRouter } from "./router";
|
||||
import { useAuthStore } from "./stores/auth";
|
||||
import "./styles/main.css";
|
||||
const app = createApp(App),
|
||||
pinia = createPinia();
|
||||
app.use(pinia);
|
||||
const auth = useAuthStore(pinia);
|
||||
app.use(createAppRouter(auth));
|
||||
app.mount("#app");
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createMemoryHistory } from "vue-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createAppRouter } from "./index";
|
||||
|
||||
describe("router guards", () => {
|
||||
function auth(
|
||||
overrides: Partial<{
|
||||
isAuthenticated: boolean;
|
||||
bootstrap: () => Promise<unknown>;
|
||||
ensureAdmin: () => Promise<unknown>;
|
||||
}> = {},
|
||||
) {
|
||||
return {
|
||||
isAuthenticated: false,
|
||||
bootstrap: vi.fn().mockResolvedValue(undefined),
|
||||
ensureAdmin: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
it("redirects guests to login and preserves the destination", async () => {
|
||||
const session = auth();
|
||||
const router = createAppRouter(session, createMemoryHistory());
|
||||
|
||||
await router.push("/conversations");
|
||||
await router.isReady();
|
||||
|
||||
expect(router.currentRoute.value.name).toBe("login");
|
||||
expect(router.currentRoute.value.query.redirect).toBe("/conversations");
|
||||
});
|
||||
|
||||
it("uses an independent admin login and role preflight", async () => {
|
||||
const session = auth({ isAuthenticated: true });
|
||||
const router = createAppRouter(session, createMemoryHistory());
|
||||
await router.push("/admin/login");
|
||||
await router.isReady();
|
||||
expect(router.currentRoute.value.name).toBe("admin-login");
|
||||
|
||||
await router.push("/admin");
|
||||
expect(session.ensureAdmin).toHaveBeenCalledOnce();
|
||||
expect(router.currentRoute.value.name).toBe("admin");
|
||||
});
|
||||
|
||||
it("redirects failed admin preflight with clear forbidden feedback", async () => {
|
||||
const session = auth({
|
||||
isAuthenticated: true,
|
||||
ensureAdmin: vi.fn().mockRejectedValue({ status: 403 }),
|
||||
});
|
||||
const router = createAppRouter(session, createMemoryHistory());
|
||||
await router.push("/admin");
|
||||
await router.isReady();
|
||||
expect(router.currentRoute.value.name).toBe("admin-login");
|
||||
expect(router.currentRoute.value.query.error).toBe("forbidden");
|
||||
});
|
||||
|
||||
it("sends anonymous visitors to the independent admin login", async () => {
|
||||
const session = auth();
|
||||
const router = createAppRouter(session, createMemoryHistory());
|
||||
await router.push("/admin");
|
||||
await router.isReady();
|
||||
expect(router.currentRoute.value.name).toBe("admin-login");
|
||||
expect(session.ensureAdmin).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createRouter, createWebHistory, type RouterHistory } from "vue-router";
|
||||
import LoginView from "../views/LoginView.vue";
|
||||
import HomeView from "../views/HomeView.vue";
|
||||
import BottleComposeView from "../views/BottleComposeView.vue";
|
||||
import PickView from "../views/PickView.vue";
|
||||
import ConversationsView from "../views/ConversationsView.vue";
|
||||
import ChatView from "../views/ChatView.vue";
|
||||
import MyBottlesView from "../views/MyBottlesView.vue";
|
||||
import SettingsView from "../views/SettingsView.vue";
|
||||
import AdminView from "../views/AdminView.vue";
|
||||
import AdminLoginView from "../views/AdminLoginView.vue";
|
||||
|
||||
export interface RouterAuth {
|
||||
isAuthenticated: boolean;
|
||||
bootstrap(): Promise<unknown>;
|
||||
ensureAdmin(): Promise<unknown>;
|
||||
}
|
||||
export function createAppRouter(
|
||||
auth: RouterAuth,
|
||||
history: RouterHistory = createWebHistory(),
|
||||
) {
|
||||
const router = createRouter({
|
||||
history,
|
||||
routes: [
|
||||
{
|
||||
path: "/login",
|
||||
name: "login",
|
||||
component: LoginView,
|
||||
meta: { guest: true },
|
||||
},
|
||||
{ path: "/", name: "home", component: HomeView },
|
||||
{ path: "/throw", name: "throw", component: BottleComposeView },
|
||||
{ path: "/pick", name: "pick", component: PickView },
|
||||
{
|
||||
path: "/conversations",
|
||||
name: "conversations",
|
||||
component: ConversationsView,
|
||||
},
|
||||
{ path: "/conversations/:id", name: "chat", component: ChatView },
|
||||
{ path: "/my-bottles", name: "my-bottles", component: MyBottlesView },
|
||||
{ path: "/settings", name: "settings", component: SettingsView },
|
||||
{
|
||||
path: "/admin/login",
|
||||
name: "admin-login",
|
||||
component: AdminLoginView,
|
||||
meta: { adminLogin: true },
|
||||
},
|
||||
{
|
||||
path: "/admin",
|
||||
name: "admin",
|
||||
component: AdminView,
|
||||
meta: { admin: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
let bootstrapped = false;
|
||||
router.beforeEach(async (to) => {
|
||||
if (!bootstrapped) {
|
||||
bootstrapped = true;
|
||||
await auth.bootstrap();
|
||||
}
|
||||
if (to.meta.adminLogin) return true;
|
||||
if (to.meta.guest && auth.isAuthenticated) return { name: "home" };
|
||||
if (!to.meta.guest && !auth.isAuthenticated) {
|
||||
if (to.meta.admin)
|
||||
return { name: "admin-login", query: { redirect: to.fullPath } };
|
||||
return { name: "login", query: { redirect: to.fullPath } };
|
||||
}
|
||||
if (to.meta.admin) {
|
||||
try {
|
||||
await auth.ensureAdmin();
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
name: "admin-login",
|
||||
query:
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"status" in error &&
|
||||
error.status === 403
|
||||
? { error: "forbidden" }
|
||||
: { error: "unavailable" },
|
||||
};
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { api, useAuthStore } from "./auth";
|
||||
|
||||
const me = {
|
||||
accountId: "a",
|
||||
publicId: "p",
|
||||
nickname: null,
|
||||
avatarColor: null,
|
||||
bio: null,
|
||||
profileReviewStatus: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("auth store", () => {
|
||||
it("becomes authenticated reactively after login", async () => {
|
||||
vi.spyOn(api, "post").mockResolvedValue({ accessToken: "fresh" });
|
||||
vi.spyOn(api, "get").mockResolvedValue(me);
|
||||
const auth = useAuthStore();
|
||||
expect(auth.isAuthenticated).toBe(false);
|
||||
await auth.login("13800000000", "123456", "device");
|
||||
expect(auth.isAuthenticated).toBe(true);
|
||||
});
|
||||
|
||||
it("restores authentication during bootstrap refresh", async () => {
|
||||
vi.spyOn(api, "refresh").mockResolvedValue("restored");
|
||||
vi.spyOn(api, "get").mockResolvedValue(me);
|
||||
const auth = useAuthStore();
|
||||
await auth.bootstrap();
|
||||
expect(auth.ready).toBe(true);
|
||||
expect(auth.isAuthenticated).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the local session even when the server logout fails", async () => {
|
||||
vi.spyOn(api, "post").mockImplementation((path: string) => {
|
||||
if (path.includes("/auth/logout")) throw new Error("network down");
|
||||
return Promise.resolve({ accessToken: "fresh" });
|
||||
});
|
||||
vi.spyOn(api, "get").mockResolvedValue(me);
|
||||
const auth = useAuthStore();
|
||||
await auth.login("13800000000", "123456", "device");
|
||||
expect(auth.isAuthenticated).toBe(true);
|
||||
|
||||
await auth.logout();
|
||||
|
||||
expect(auth.me).toBeNull();
|
||||
expect(auth.isAuthenticated).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
import { createApiClient } from "../api/client";
|
||||
|
||||
export interface Me {
|
||||
accountId: string;
|
||||
publicId: string | null;
|
||||
nickname: string | null;
|
||||
avatarColor: string | null;
|
||||
bio: string | null;
|
||||
profileReviewStatus: string | null;
|
||||
}
|
||||
const accessToken = ref<string | null>(null);
|
||||
export const api = createApiClient({
|
||||
getToken: () => accessToken.value,
|
||||
setToken: (value) => {
|
||||
accessToken.value = value;
|
||||
},
|
||||
});
|
||||
export const useAuthStore = defineStore("auth", () => {
|
||||
const me = ref<Me | null>(null);
|
||||
const ready = ref(false);
|
||||
const isAuthenticated = computed(() =>
|
||||
Boolean(accessToken.value && me.value),
|
||||
);
|
||||
async function bootstrap() {
|
||||
if (ready.value) return;
|
||||
try {
|
||||
await api.refresh();
|
||||
me.value = await api.get<Me>("/me");
|
||||
} catch {
|
||||
accessToken.value = null;
|
||||
me.value = null;
|
||||
} finally {
|
||||
ready.value = true;
|
||||
}
|
||||
}
|
||||
async function sendCode(phone: string, deviceId: string) {
|
||||
return api.post<{ sent: boolean; debugCode?: string }>("/auth/sms/send", {
|
||||
phone,
|
||||
deviceId,
|
||||
});
|
||||
}
|
||||
async function login(phone: string, code: string, deviceId: string) {
|
||||
const pair = await api.post<{ accessToken: string }>("/auth/sms/login", {
|
||||
phone,
|
||||
code,
|
||||
deviceId,
|
||||
});
|
||||
accessToken.value = pair.accessToken;
|
||||
me.value = await api.get<Me>("/me");
|
||||
}
|
||||
async function ensureAdmin() {
|
||||
await api.get("/admin/reports?limit=1");
|
||||
}
|
||||
async function logout() {
|
||||
try {
|
||||
await api.post("/auth/logout");
|
||||
} catch {
|
||||
// 本地始终清理;服务端注销失败也不阻塞退出
|
||||
}
|
||||
accessToken.value = null;
|
||||
me.value = null;
|
||||
}
|
||||
return {
|
||||
me,
|
||||
ready,
|
||||
isAuthenticated,
|
||||
bootstrap,
|
||||
sendCode,
|
||||
login,
|
||||
ensureAdmin,
|
||||
logout,
|
||||
};
|
||||
});
|
||||
export function currentAccessToken() {
|
||||
return accessToken.value;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { flushPromises } from "@vue/test-utils";
|
||||
import { api, useAuthStore } from "./auth";
|
||||
import { useChatStore } from "./chat";
|
||||
import type { Conversation } from "./chat";
|
||||
import type { ChatMessage } from "./messages";
|
||||
|
||||
const socket = vi.hoisted(() => ({
|
||||
connected: false,
|
||||
on: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../api/socket", () => ({
|
||||
createChatSocket: () => socket,
|
||||
sendSocketMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
const me = {
|
||||
accountId: "a",
|
||||
publicId: "p",
|
||||
nickname: null,
|
||||
avatarColor: null,
|
||||
bio: null,
|
||||
profileReviewStatus: null,
|
||||
};
|
||||
|
||||
function message(seq: number): ChatMessage {
|
||||
return {
|
||||
id: `m-${seq}`,
|
||||
conversationId: "c-1",
|
||||
sender: { publicId: "p" },
|
||||
clientMsgId: `client-${seq}`,
|
||||
seq: String(seq),
|
||||
text: String(seq),
|
||||
status: "sent",
|
||||
sentAt: new Date(seq).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const conversation = (): Conversation => ({
|
||||
id: "c-1",
|
||||
status: "ACTIVE",
|
||||
lastMessageAt: new Date(0).toISOString(),
|
||||
lastMessage: null,
|
||||
unread: "0",
|
||||
lastReadSeq: "0",
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.restoreAllMocks();
|
||||
socket.connected = false;
|
||||
socket.disconnect.mockClear();
|
||||
});
|
||||
|
||||
describe("chat history synchronization", () => {
|
||||
it("loads every page beyond 100 messages and reliably deduplicates overlap", async () => {
|
||||
const pages = [
|
||||
Array.from({ length: 100 }, (_, index) => message(index + 1)),
|
||||
Array.from({ length: 100 }, (_, index) => message(index + 100)),
|
||||
Array.from({ length: 51 }, (_, index) => message(index + 199)),
|
||||
[],
|
||||
];
|
||||
const get = vi
|
||||
.spyOn(api, "get")
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve({ items: pages.shift() ?? [] }),
|
||||
);
|
||||
const chat = useChatStore();
|
||||
|
||||
const result = await chat.loadMessages("c-1");
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result ?? []).toHaveLength(249);
|
||||
expect(result?.at(-1)?.seq).toBe("249");
|
||||
expect(get).toHaveBeenCalledTimes(3);
|
||||
expect(get.mock.calls.map(([path]) => path)).toEqual([
|
||||
"/conversations/c-1/messages?afterSeq=0&limit=100",
|
||||
"/conversations/c-1/messages?afterSeq=100&limit=100",
|
||||
"/conversations/c-1/messages?afterSeq=199&limit=100",
|
||||
]);
|
||||
});
|
||||
|
||||
it("stops paging after the page cap to bound waterfall requests", async () => {
|
||||
const pages = [
|
||||
Array.from({ length: 100 }, (_, index) => message(index + 1)),
|
||||
Array.from({ length: 100 }, (_, index) => message(index + 101)),
|
||||
Array.from({ length: 100 }, (_, index) => message(index + 201)),
|
||||
Array.from({ length: 100 }, (_, index) => message(index + 301)),
|
||||
Array.from({ length: 100 }, (_, index) => message(index + 401)),
|
||||
[],
|
||||
];
|
||||
const get = vi
|
||||
.spyOn(api, "get")
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve({ items: pages.shift() ?? [] }),
|
||||
);
|
||||
const chat = useChatStore();
|
||||
|
||||
const result = await chat.loadMessages("c-1");
|
||||
|
||||
expect(result ?? []).toHaveLength(500);
|
||||
expect(get.mock.calls.length).toBeLessThanOrEqual(5);
|
||||
expect(get.mock.calls.at(-1)?.[0]).toBe(
|
||||
"/conversations/c-1/messages?afterSeq=400&limit=100",
|
||||
);
|
||||
});
|
||||
|
||||
it("syncs only the most recent active conversations when reconnecting", async () => {
|
||||
const get = vi.spyOn(api, "get").mockResolvedValue({ items: [] });
|
||||
const chat = useChatStore();
|
||||
chat.conversations = Array.from({ length: 30 }, (_, index) => {
|
||||
const id = `c-${index}`;
|
||||
return {
|
||||
id,
|
||||
status: "ACTIVE",
|
||||
lastMessageAt: new Date(1700000000000 + index).toISOString(),
|
||||
lastMessage: null,
|
||||
unread: "0",
|
||||
lastReadSeq: "0",
|
||||
};
|
||||
});
|
||||
|
||||
await chat.syncAll();
|
||||
|
||||
expect(
|
||||
get.mock.calls.filter(([path]) => path.includes("/messages")),
|
||||
).toHaveLength(5);
|
||||
expect(
|
||||
get.mock.calls.filter(([path]) => path.includes("/messages"))[0]![0],
|
||||
).toContain("c-29");
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat session lifecycle", () => {
|
||||
it("disconnects and clears account state when authentication ends", async () => {
|
||||
vi.spyOn(api, "post").mockResolvedValue({ accessToken: "fresh" });
|
||||
vi.spyOn(api, "get").mockResolvedValue(me);
|
||||
const auth = useAuthStore();
|
||||
await auth.login("13800000000", "123456", "device");
|
||||
expect(auth.isAuthenticated).toBe(true);
|
||||
const chat = useChatStore();
|
||||
chat.conversations = [conversation()];
|
||||
chat.messages = { "c-1": [message(1)] };
|
||||
expect(chat.conversations).toHaveLength(1);
|
||||
|
||||
auth.me = null;
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(auth.isAuthenticated).toBe(false);
|
||||
expect(socket.disconnect).toHaveBeenCalled();
|
||||
expect(chat.conversations).toEqual([]);
|
||||
expect(chat.messages).toEqual({});
|
||||
expect(chat.connected).toBe(false);
|
||||
});
|
||||
|
||||
it("reset clears conversations and disconnects even when called directly", () => {
|
||||
const auth = useAuthStore();
|
||||
auth.me = me;
|
||||
const chat = useChatStore();
|
||||
chat.conversations = [conversation()];
|
||||
chat.messages = { "c-1": [message(1)] };
|
||||
|
||||
chat.reset();
|
||||
|
||||
expect(socket.disconnect).toHaveBeenCalled();
|
||||
expect(chat.conversations).toEqual([]);
|
||||
expect(chat.messages).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { api, currentAccessToken, useAuthStore } from "./auth";
|
||||
import { createChatSocket, sendSocketMessage } from "../api/socket";
|
||||
import { mergeMessages, type ChatMessage } from "./messages";
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
status: string;
|
||||
lastMessageAt: string;
|
||||
lastMessage: ChatMessage | null;
|
||||
unread: string;
|
||||
lastReadSeq: string;
|
||||
}
|
||||
const MAX_MESSAGE_PAGES = 5; // 单个会话重连同步上限(500 条),避免瀑布请求
|
||||
const MAX_SYNC_CONVERSATIONS = 5; // 重连时仅同步最近活跃会话
|
||||
export const useChatStore = defineStore("chat", () => {
|
||||
const auth = useAuthStore();
|
||||
const conversations = ref<Conversation[]>([]);
|
||||
const messages = ref<Record<string, ChatMessage[]>>({});
|
||||
const socket = createChatSocket(currentAccessToken, api.refresh);
|
||||
const connected = ref(false);
|
||||
socket.on("connect", () => {
|
||||
connected.value = true;
|
||||
void syncAll();
|
||||
});
|
||||
socket.on("disconnect", () => {
|
||||
connected.value = false;
|
||||
});
|
||||
socket.on("message:new", (message: ChatMessage) => {
|
||||
messages.value[message.conversationId] = mergeMessages(
|
||||
messages.value[message.conversationId] ?? [],
|
||||
[message],
|
||||
);
|
||||
});
|
||||
async function loadConversations() {
|
||||
const page = await api.get<{ items: Conversation[] }>("/conversations");
|
||||
conversations.value = page.items;
|
||||
return page.items;
|
||||
}
|
||||
async function loadMessages(id: string) {
|
||||
let afterSeq = messages.value[id]?.at(-1)?.seq ?? "0";
|
||||
let hasMore = true;
|
||||
let pages = 0;
|
||||
while (hasMore && pages < MAX_MESSAGE_PAGES) {
|
||||
pages += 1;
|
||||
const page = await api.get<{ items: ChatMessage[] }>(
|
||||
`/conversations/${id}/messages?afterSeq=${afterSeq}&limit=100`,
|
||||
);
|
||||
messages.value[id] = mergeMessages(messages.value[id] ?? [], page.items);
|
||||
hasMore = page.items.length === 100;
|
||||
if (!hasMore) break;
|
||||
const nextAfterSeq = page.items.at(-1)?.seq;
|
||||
if (!nextAfterSeq || BigInt(nextAfterSeq) <= BigInt(afterSeq)) break;
|
||||
afterSeq = nextAfterSeq;
|
||||
}
|
||||
return messages.value[id];
|
||||
}
|
||||
async function syncAll() {
|
||||
const recent = [...conversations.value]
|
||||
.sort((a, b) => Date.parse(b.lastMessageAt) - Date.parse(a.lastMessageAt))
|
||||
.slice(0, MAX_SYNC_CONVERSATIONS);
|
||||
for (const conversation of recent) await loadMessages(conversation.id);
|
||||
}
|
||||
function reset() {
|
||||
socket.disconnect();
|
||||
connected.value = false;
|
||||
conversations.value = [];
|
||||
messages.value = {};
|
||||
}
|
||||
let wasAuthenticated = auth.isAuthenticated;
|
||||
watch(
|
||||
() => auth.isAuthenticated,
|
||||
(authenticated) => {
|
||||
if (wasAuthenticated && !authenticated) reset();
|
||||
wasAuthenticated = authenticated;
|
||||
},
|
||||
);
|
||||
function connect() {
|
||||
if (!socket.connected) socket.connect();
|
||||
}
|
||||
function disconnect() {
|
||||
socket.disconnect();
|
||||
}
|
||||
async function send(id: string, text: string) {
|
||||
const clientMsgId = crypto.randomUUID();
|
||||
const optimistic: ChatMessage = {
|
||||
id: `local:${clientMsgId}`,
|
||||
conversationId: id,
|
||||
clientMsgId,
|
||||
sender: { publicId: "" },
|
||||
seq: "0",
|
||||
text,
|
||||
sentAt: new Date().toISOString(),
|
||||
status: "sending",
|
||||
};
|
||||
messages.value[id] = mergeMessages(messages.value[id] ?? [], [optimistic]);
|
||||
try {
|
||||
const result = connected.value
|
||||
? await sendSocketMessage(
|
||||
socket,
|
||||
{
|
||||
conversationId: id,
|
||||
clientMsgId,
|
||||
text,
|
||||
},
|
||||
api.refresh,
|
||||
)
|
||||
: await api.post<{ message: ChatMessage }>(
|
||||
`/conversations/${id}/messages/prepare`,
|
||||
{ conversationId: id, clientMsgId, text },
|
||||
);
|
||||
messages.value[id] = mergeMessages(messages.value[id], [
|
||||
{ ...result.message, status: "sent" },
|
||||
]);
|
||||
} catch (error) {
|
||||
optimistic.status = "failed";
|
||||
messages.value[id] = [...messages.value[id]];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const unread = computed(() =>
|
||||
conversations.value.reduce((sum, item) => sum + Number(item.unread), 0),
|
||||
);
|
||||
return {
|
||||
conversations,
|
||||
messages,
|
||||
connected,
|
||||
unread,
|
||||
loadConversations,
|
||||
loadMessages,
|
||||
syncAll,
|
||||
connect,
|
||||
disconnect,
|
||||
reset,
|
||||
send,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mergeMessages, type ChatMessage } from "./messages";
|
||||
|
||||
const message = (id: string, seq: string): ChatMessage => ({
|
||||
id,
|
||||
conversationId: "conversation",
|
||||
sender: { publicId: "sender" },
|
||||
clientMsgId: `client-${id}`,
|
||||
seq,
|
||||
text: id,
|
||||
sentAt: "2026-09-17T00:00:00.000Z",
|
||||
status: "sent",
|
||||
});
|
||||
|
||||
describe("message synchronization", () => {
|
||||
it("deduplicates socket and reconnect history messages and sorts by sequence", () => {
|
||||
const result = mergeMessages(
|
||||
[message("two", "2"), message("three", "3")],
|
||||
[message("one", "1"), message("two", "2")],
|
||||
);
|
||||
expect(result.map(({ id }) => id)).toEqual(["one", "two", "three"]);
|
||||
});
|
||||
|
||||
it("reconciles an optimistic message by client message id", () => {
|
||||
const optimistic = {
|
||||
...message("temporary", "0"),
|
||||
clientMsgId: "same",
|
||||
status: "sending" as const,
|
||||
};
|
||||
const confirmed = { ...message("server", "4"), clientMsgId: "same" };
|
||||
expect(mergeMessages([optimistic], [confirmed])).toEqual([confirmed]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
sender: { publicId: string };
|
||||
clientMsgId: string;
|
||||
seq: string;
|
||||
text: string;
|
||||
status: string;
|
||||
reviewStatus?: string;
|
||||
sentAt: string;
|
||||
}
|
||||
|
||||
export function mergeMessages(
|
||||
current: ChatMessage[],
|
||||
incoming: ChatMessage[],
|
||||
): ChatMessage[] {
|
||||
// 双 Map:按 id 去重 + 按 clientMsgId 调和乐观消息,整体 O(n)
|
||||
const byId = new Map<string, ChatMessage>();
|
||||
const byClientMsgId = new Map<string, string>();
|
||||
const consider = (item: ChatMessage) => {
|
||||
const existingId = byClientMsgId.get(item.clientMsgId);
|
||||
if (existingId) byId.delete(existingId);
|
||||
byClientMsgId.set(item.clientMsgId, item.id);
|
||||
byId.set(item.id, item);
|
||||
};
|
||||
for (const item of current) consider(item);
|
||||
for (const item of incoming) consider(item);
|
||||
return [...byId.values()].sort((a, b) =>
|
||||
BigInt(a.seq) < BigInt(b.seq) ? -1 : BigInt(a.seq) > BigInt(b.seq) ? 1 : 0,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600;700&family=ZCOOL+XiaoWei&display=swap");
|
||||
:root {
|
||||
font-family: "Noto Sans SC", system-ui, sans-serif;
|
||||
color: #effcff;
|
||||
background: #020b13;
|
||||
--deep: #061b2b;
|
||||
--panel: rgba(13, 48, 63, 0.72);
|
||||
--line: rgba(139, 224, 218, 0.18);
|
||||
--aqua: #76e4d5;
|
||||
--sand: #f1c27d;
|
||||
--muted: #91abb7;
|
||||
--danger: #ff8f91;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html {
|
||||
background: #020b13;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(
|
||||
circle at 50% 0,
|
||||
#0b4050 0,
|
||||
#061b2b 38%,
|
||||
#020b13 100%
|
||||
);
|
||||
line-height: 1.55;
|
||||
}
|
||||
body:before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
rgba(118, 228, 213, 0.22) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
background-size: 42px 42px;
|
||||
mask-image: linear-gradient(#000, transparent 80%);
|
||||
}
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
a {
|
||||
font: inherit;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
.primary,
|
||||
.secondary,
|
||||
.danger,
|
||||
.icon-link,
|
||||
.avatar {
|
||||
min-height: 44px;
|
||||
}
|
||||
button,
|
||||
a {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible {
|
||||
outline: 3px solid var(--sand);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.app-shell {
|
||||
position: relative;
|
||||
width: min(100%, 430px);
|
||||
min-height: 100dvh;
|
||||
margin: auto;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(6, 27, 43, 0.18),
|
||||
rgba(2, 11, 19, 0.88)
|
||||
);
|
||||
box-shadow: 0 0 70px #000;
|
||||
padding-bottom: 84px;
|
||||
overflow: hidden;
|
||||
}
|
||||
main {
|
||||
min-height: calc(100dvh - 84px);
|
||||
}
|
||||
.page,
|
||||
.list,
|
||||
.settings {
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
padding: max(1rem, env(safe-area-inset-top)) 1.1rem 1rem;
|
||||
}
|
||||
.topbar h1 {
|
||||
font:
|
||||
500 1.4rem "ZCOOL XiaoWei",
|
||||
serif;
|
||||
margin: 0;
|
||||
}
|
||||
.topbar small,
|
||||
.eyebrow {
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--aqua);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
.icon-link,
|
||||
.avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 44px;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.avatar {
|
||||
font-weight: 700;
|
||||
}
|
||||
.glass {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
backdrop-filter: blur(18px);
|
||||
border-radius: 22px;
|
||||
padding: 1.1rem;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.login {
|
||||
padding: 8vh 1.1rem 2rem;
|
||||
}
|
||||
.hero {
|
||||
padding: 2rem 0.3rem;
|
||||
}
|
||||
.hero h1,
|
||||
.page h2,
|
||||
.sea h2 {
|
||||
font:
|
||||
500 2.1rem/1.25 "ZCOOL XiaoWei",
|
||||
serif;
|
||||
}
|
||||
.hero h1 em {
|
||||
color: var(--sand);
|
||||
font-style: normal;
|
||||
}
|
||||
.hero > p:last-child,
|
||||
.muted,
|
||||
.quota-note {
|
||||
color: var(--muted);
|
||||
}
|
||||
.orb {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background: rgba(118, 228, 213, 0.12);
|
||||
color: var(--aqua);
|
||||
box-shadow: 0 0 40px rgba(118, 228, 213, 0.25);
|
||||
}
|
||||
label {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
color: #cce0e6;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
color: #fff;
|
||||
background: rgba(0, 10, 18, 0.56);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 13px;
|
||||
padding: 0.72rem 0.85rem;
|
||||
resize: vertical;
|
||||
}
|
||||
textarea::placeholder,
|
||||
input::placeholder {
|
||||
color: #77919d;
|
||||
}
|
||||
.primary,
|
||||
.secondary,
|
||||
.danger {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
padding: 0.7rem 1rem;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.primary {
|
||||
color: #032128;
|
||||
background: linear-gradient(135deg, var(--aqua), #b8f3db);
|
||||
}
|
||||
.secondary {
|
||||
color: var(--aqua);
|
||||
background: rgba(118, 228, 213, 0.1);
|
||||
border: 1px solid rgba(118, 228, 213, 0.3);
|
||||
}
|
||||
.danger {
|
||||
color: #311016;
|
||||
background: var(--danger);
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.48;
|
||||
}
|
||||
.text {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--aqua);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.error {
|
||||
color: #ffd0d0;
|
||||
background: rgba(255, 80, 90, 0.12);
|
||||
border-radius: 12px;
|
||||
padding: 0.7rem;
|
||||
}
|
||||
.demo {
|
||||
color: var(--sand);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.sea {
|
||||
position: relative;
|
||||
padding: 1.5rem 1.1rem 2rem;
|
||||
}
|
||||
.moon {
|
||||
position: absolute;
|
||||
right: -35px;
|
||||
top: -20px;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle at 35% 35%,
|
||||
#b8f3db,
|
||||
#178998 58%,
|
||||
transparent 61%
|
||||
);
|
||||
opacity: 0.22;
|
||||
}
|
||||
.actions {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
.action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
padding: 1.25rem;
|
||||
border-radius: 22px;
|
||||
border: 1px solid var(--line);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(118, 228, 213, 0.18),
|
||||
rgba(5, 24, 38, 0.7)
|
||||
);
|
||||
}
|
||||
.action b {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 18px;
|
||||
background: rgba(118, 228, 213, 0.16);
|
||||
font-size: 1.7rem;
|
||||
color: var(--aqua);
|
||||
}
|
||||
.action span {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.action small {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
.quote {
|
||||
margin: 0 1.1rem;
|
||||
text-align: center;
|
||||
color: #c9e0e6;
|
||||
}
|
||||
.quota-note {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.between,
|
||||
.button-row,
|
||||
.author,
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.button-row > * {
|
||||
width: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.success,
|
||||
.empty-ocean {
|
||||
margin: 4rem 1.1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.success > b,
|
||||
.bottle {
|
||||
font-size: 3rem;
|
||||
color: var(--aqua);
|
||||
}
|
||||
.bottle-card blockquote {
|
||||
font:
|
||||
500 1.5rem/1.6 "ZCOOL XiaoWei",
|
||||
serif;
|
||||
margin: 2rem 0.2rem;
|
||||
}
|
||||
.author {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.author i {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.author small {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
.conversation {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.3rem;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.conversation p {
|
||||
margin: 0.3rem 0;
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 260px;
|
||||
}
|
||||
.conversation time {
|
||||
grid-column: 1;
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.badge,
|
||||
.pill {
|
||||
border-radius: 99px;
|
||||
background: rgba(118, 228, 213, 0.15);
|
||||
color: var(--aqua);
|
||||
padding: 0.22rem 0.55rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100dvh - 10px);
|
||||
}
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.bubble {
|
||||
max-width: 82%;
|
||||
margin: 0.7rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 18px 18px 18px 5px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.bubble p {
|
||||
margin: 0;
|
||||
}
|
||||
.bubble small {
|
||||
color: var(--muted);
|
||||
}
|
||||
.bubble.sending {
|
||||
opacity: 0.65;
|
||||
}
|
||||
.bubble.failed {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
.composer {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
padding: 0.8rem 1rem calc(0.8rem + env(safe-area-inset-bottom));
|
||||
background: #061b2b;
|
||||
}
|
||||
.composer textarea {
|
||||
min-height: 48px;
|
||||
}
|
||||
.composer button {
|
||||
min-width: 48px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--aqua);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.dot {
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.dot.on {
|
||||
color: var(--aqua);
|
||||
}
|
||||
.chat-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.chat-actions .text {
|
||||
min-width: 44px;
|
||||
}
|
||||
.report-dialog {
|
||||
position: absolute;
|
||||
z-index: 12;
|
||||
top: 18%;
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
width: auto;
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
background: #0b2d3b;
|
||||
}
|
||||
.report-dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
.bottle-row p {
|
||||
font-family: "ZCOOL XiaoWei", serif;
|
||||
font-size: 1.12rem;
|
||||
}
|
||||
.bottle-row time,
|
||||
.bottle-row small {
|
||||
color: var(--muted);
|
||||
}
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
background: #020b13;
|
||||
padding: 0.6rem;
|
||||
border-radius: 10px;
|
||||
color: #9fc3cc;
|
||||
}
|
||||
.segmented {
|
||||
display: flex;
|
||||
padding: 0 1rem 1rem;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.segmented button {
|
||||
flex: 1;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
color: #fff;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.offline {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
text-align: center;
|
||||
background: #f1c27d;
|
||||
color: #2b1b05;
|
||||
padding: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.tabs {
|
||||
position: fixed;
|
||||
z-index: 8;
|
||||
bottom: 0;
|
||||
width: min(100%, 430px);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
padding: 0.55rem 0.5rem calc(0.55rem + env(safe-area-inset-bottom));
|
||||
background: rgba(3, 17, 28, 0.94);
|
||||
backdrop-filter: blur(18px);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.tabs a {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.tabs a[aria-current="page"] {
|
||||
color: var(--aqua);
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||
code {
|
||||
color: var(--sand);
|
||||
}
|
||||
@media (min-width: 700px) {
|
||||
body {
|
||||
padding: 2rem 0;
|
||||
}
|
||||
.app-shell {
|
||||
min-height: calc(100dvh - 4rem);
|
||||
border-radius: 32px;
|
||||
}
|
||||
.tabs {
|
||||
bottom: 2rem;
|
||||
border-radius: 0 0 32px 32px;
|
||||
}
|
||||
.chat {
|
||||
height: calc(100dvh - 4rem);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.orb,
|
||||
.bottle {
|
||||
animation: float 4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes float {
|
||||
50% {
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const phone = ref("");
|
||||
const code = ref("");
|
||||
const debugCode = ref("");
|
||||
const sent = ref(false);
|
||||
const busy = ref(false);
|
||||
const error = ref(
|
||||
route.query.error === "forbidden" ? "当前账号没有管理员权限" : "",
|
||||
);
|
||||
const deviceId = `admin-web-${crypto.randomUUID()}`;
|
||||
const validPhone = computed(() =>
|
||||
/^(?:\+?86)?1\d{10}$/.test(phone.value.replace(/\s/g, "")),
|
||||
);
|
||||
|
||||
async function send() {
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const result = await auth.sendCode(phone.value, deviceId);
|
||||
sent.value = true;
|
||||
debugCode.value = result.debugCode ?? "";
|
||||
} catch (reason) {
|
||||
error.value = reason instanceof Error ? reason.message : "发送失败";
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function login() {
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await auth.login(phone.value, code.value, deviceId);
|
||||
await auth.ensureAdmin();
|
||||
await router.replace("/admin");
|
||||
} catch (reason: unknown) {
|
||||
error.value =
|
||||
typeof reason === "object" &&
|
||||
reason !== null &&
|
||||
"status" in reason &&
|
||||
reason.status === 403
|
||||
? "当前账号没有管理员权限"
|
||||
: reason instanceof Error
|
||||
? reason.message
|
||||
: "管理员登录失败";
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="login admin-login">
|
||||
<section class="hero">
|
||||
<p class="eyebrow">ADMIN / 治理后台</p>
|
||||
<h1>管理员登录</h1>
|
||||
<p>独立审核与举报处置入口。仅已授权管理员账号可进入。</p>
|
||||
</section>
|
||||
<form class="glass" @submit.prevent="sent ? login() : send()">
|
||||
<label
|
||||
>管理员手机号<input
|
||||
v-model="phone"
|
||||
autocomplete="tel"
|
||||
inputmode="tel"
|
||||
required
|
||||
/></label>
|
||||
<label v-if="sent"
|
||||
>演示验证码<input
|
||||
v-model="code"
|
||||
autocomplete="one-time-code"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
required
|
||||
/></label>
|
||||
<p v-if="debugCode" class="demo">
|
||||
开发环境演示验证码:<strong>{{ debugCode }}</strong>
|
||||
</p>
|
||||
<p v-if="error" class="error" role="alert">{{ error }}</p>
|
||||
<button class="primary" :disabled="busy || !validPhone">
|
||||
{{ busy ? "请稍候…" : sent ? "进入治理后台" : "获取演示验证码" }}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import AppHeader from "../components/AppHeader.vue";
|
||||
import { api } from "../stores/auth";
|
||||
import { createOperationState } from "./operation";
|
||||
const operations = createOperationState();
|
||||
const reports = ref<any[]>([]),
|
||||
tasks = ref<any[]>([]),
|
||||
error = operations.error,
|
||||
tab = ref<"reports" | "moderation" | "sanction">("reports"),
|
||||
publicId = ref(""),
|
||||
reason = ref(""),
|
||||
type = ref("WARNING");
|
||||
async function load() {
|
||||
error.value = "";
|
||||
try {
|
||||
const [r, m] = await Promise.all([
|
||||
api.get<any>("/admin/reports?limit=100"),
|
||||
api.get<any>("/admin/moderation?limit=100"),
|
||||
]);
|
||||
reports.value = r.items;
|
||||
tasks.value = m.items;
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "无管理员权限";
|
||||
}
|
||||
}
|
||||
onMounted(load);
|
||||
async function resolve(id: string, decision: "UPHELD" | "DISMISSED") {
|
||||
await operations.run(`report:${id}`, async () => {
|
||||
await api.post(`/admin/reports/${id}/resolve`, {
|
||||
decision,
|
||||
resolution: decision === "UPHELD" ? "管理员确认违规" : "管理员驳回举报",
|
||||
});
|
||||
await load();
|
||||
});
|
||||
}
|
||||
async function moderate(id: string, decision: "APPROVED" | "REJECTED") {
|
||||
await operations.run(`moderation:${id}`, async () => {
|
||||
await api.post(`/admin/moderation/${id}/resolve`, {
|
||||
decision,
|
||||
reason: "管理员人工审核",
|
||||
});
|
||||
await load();
|
||||
});
|
||||
}
|
||||
async function sanction() {
|
||||
await operations.run("sanction", async () => {
|
||||
await api.post(`/admin/accounts/${publicId.value}/sanctions`, {
|
||||
type: type.value,
|
||||
reason: reason.value,
|
||||
});
|
||||
reason.value = "";
|
||||
publicId.value = "";
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<main class="admin">
|
||||
<AppHeader title="管理员工作台" back />
|
||||
<p v-if="error" class="error page" role="alert">{{ error }}</p>
|
||||
<nav class="segmented">
|
||||
<button @click="tab = 'reports'">举报 {{ reports.length }}</button
|
||||
><button @click="tab = 'moderation'">复审 {{ tasks.length }}</button
|
||||
><button @click="tab = 'sanction'">处罚</button>
|
||||
</nav>
|
||||
<section v-if="tab === 'reports'" class="list">
|
||||
<article v-for="item in reports" :key="item.id" class="glass">
|
||||
<div class="between">
|
||||
<b>{{ item.reason }}</b
|
||||
><span class="pill">{{ item.status }}</span>
|
||||
</div>
|
||||
<p>{{ item.details || "无补充说明" }}</p>
|
||||
<pre>{{ JSON.stringify(item.targetSnapshot, null, 2) }}</pre>
|
||||
<div class="button-row">
|
||||
<button
|
||||
class="secondary"
|
||||
:disabled="operations.pending(`report:${item.id}`)"
|
||||
@click="resolve(item.id, 'DISMISSED')"
|
||||
>
|
||||
驳回</button
|
||||
><button
|
||||
class="danger"
|
||||
:disabled="operations.pending(`report:${item.id}`)"
|
||||
@click="resolve(item.id, 'UPHELD')"
|
||||
>
|
||||
确认违规
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<section v-else-if="tab === 'moderation'" class="list">
|
||||
<article v-for="item in tasks" :key="item.id" class="glass">
|
||||
<b>{{ item.targetType }} · {{ item.targetId }}</b>
|
||||
<p>风险标签:{{ item.riskLabels.join("、") || "无" }}</p>
|
||||
<div class="button-row">
|
||||
<button
|
||||
class="secondary"
|
||||
:disabled="operations.pending(`moderation:${item.id}`)"
|
||||
@click="moderate(item.id, 'APPROVED')"
|
||||
>
|
||||
通过</button
|
||||
><button
|
||||
class="danger"
|
||||
:disabled="operations.pending(`moderation:${item.id}`)"
|
||||
@click="moderate(item.id, 'REJECTED')"
|
||||
>
|
||||
拒绝
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<form v-else class="glass page" @submit.prevent="sanction">
|
||||
<h2>独立账号处罚</h2>
|
||||
<label>公开用户 ID<input v-model="publicId" required /></label
|
||||
><label
|
||||
>处罚类型<select v-model="type">
|
||||
<option>WARNING</option>
|
||||
<option>MUTE</option>
|
||||
<option>SUSPENSION</option>
|
||||
<option>BAN</option>
|
||||
</select></label
|
||||
><label
|
||||
>原因<textarea
|
||||
v-model="reason"
|
||||
maxlength="500"
|
||||
required
|
||||
></textarea></label
|
||||
><button class="danger" :disabled="operations.pending('sanction')">
|
||||
{{ operations.pending("sanction") ? "处理中…" : "实施处罚" }}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import AppHeader from "../components/AppHeader.vue";
|
||||
import { api } from "../stores/auth";
|
||||
const text = ref(""),
|
||||
busy = ref(false),
|
||||
result = ref<any>(null),
|
||||
error = ref("");
|
||||
async function submit() {
|
||||
if (!navigator.onLine) {
|
||||
error.value = "当前离线,瓶子没有投递。";
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
result.value = await api.post(
|
||||
"/bottles",
|
||||
{ contentText: text.value },
|
||||
{ "Idempotency-Key": crypto.randomUUID() },
|
||||
);
|
||||
text.value = "";
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "投递失败";
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<main>
|
||||
<AppHeader title="写一只瓶子" back />
|
||||
<section v-if="!result" class="page">
|
||||
<p class="eyebrow">THROW / 投递</p>
|
||||
<h2>把一句话交给洋流</h2>
|
||||
<form class="glass" @submit.prevent="submit">
|
||||
<label
|
||||
>瓶中内容<textarea
|
||||
v-model="text"
|
||||
maxlength="1000"
|
||||
rows="9"
|
||||
placeholder="写下你想让陌生人看到的话…"
|
||||
required
|
||||
></textarea>
|
||||
</label>
|
||||
<div class="between">
|
||||
<small>{{ text.length }} / 1000</small><span>匿名投递</span>
|
||||
</div>
|
||||
<p v-if="error" class="error" role="alert">{{ error }}</p>
|
||||
<button class="primary" :disabled="busy || !text.trim()">
|
||||
{{ busy ? "正在投递…" : "扔进海里" }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
<section v-else class="success glass">
|
||||
<b>✓</b>
|
||||
<h2>瓶子已进入审核</h2>
|
||||
<p>审核通过后才会进入海面。你可以在“我的瓶子”查看真实状态。</p>
|
||||
<RouterLink class="primary" to="/my-bottles">查看我的瓶子</RouterLink>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import AppHeader from "../components/AppHeader.vue";
|
||||
import StatusPanel from "../components/StatusPanel.vue";
|
||||
import { api } from "../stores/auth";
|
||||
import { useChatStore } from "../stores/chat";
|
||||
const route = useRoute(),
|
||||
chat = useChatStore(),
|
||||
id = String(route.params.id),
|
||||
text = ref(""),
|
||||
loading = ref(true),
|
||||
error = ref("");
|
||||
const items = computed(() => chat.messages[id] ?? []);
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await chat.loadMessages(id);
|
||||
chat.connect();
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "加载失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
const reportOpen = ref(false);
|
||||
const reportReason = ref("HARASSMENT");
|
||||
const reportDetails = ref("");
|
||||
async function blockPeer() {
|
||||
if (!confirm("拉黑后双方将不能继续聊天,确认吗?")) return;
|
||||
try {
|
||||
await api.post(`/conversations/${id}/block`);
|
||||
error.value = "已拉黑对方,本会话不能继续发送消息。";
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "拉黑失败";
|
||||
}
|
||||
}
|
||||
async function reportConversation() {
|
||||
try {
|
||||
await api.post(
|
||||
"/reports",
|
||||
{
|
||||
targetType: "CONVERSATION",
|
||||
targetId: id,
|
||||
reason: reportReason.value,
|
||||
...(reportDetails.value.trim()
|
||||
? { details: reportDetails.value.trim() }
|
||||
: {}),
|
||||
},
|
||||
{ "Idempotency-Key": crypto.randomUUID() },
|
||||
);
|
||||
reportOpen.value = false;
|
||||
error.value = "举报已提交审核。";
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "举报失败";
|
||||
}
|
||||
}
|
||||
async function send() {
|
||||
const value = text.value.trim();
|
||||
if (!value) return;
|
||||
text.value = "";
|
||||
try {
|
||||
await chat.send(id, value);
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "发送失败";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<main class="chat">
|
||||
<AppHeader title="深海回声" back>
|
||||
<div class="chat-actions">
|
||||
<span class="dot" :class="{ on: chat.connected }">{{
|
||||
chat.connected ? "实时" : "同步中"
|
||||
}}</span>
|
||||
<button class="text" aria-label="举报会话" @click="reportOpen = true">
|
||||
举报
|
||||
</button>
|
||||
<button class="text" aria-label="拉黑对方" @click="blockPeer">
|
||||
拉黑
|
||||
</button>
|
||||
</div>
|
||||
</AppHeader>
|
||||
<dialog :open="reportOpen" class="glass report-dialog">
|
||||
<form @submit.prevent="reportConversation">
|
||||
<h2>举报会话</h2>
|
||||
<label
|
||||
>原因<select v-model="reportReason">
|
||||
<option>HARASSMENT</option>
|
||||
<option>SPAM</option>
|
||||
<option>SEXUAL</option>
|
||||
<option>VIOLENCE</option>
|
||||
<option>FRAUD</option>
|
||||
<option>OTHER</option>
|
||||
</select></label
|
||||
>
|
||||
<label
|
||||
>补充说明<textarea
|
||||
v-model="reportDetails"
|
||||
maxlength="1000"
|
||||
rows="3"
|
||||
/>
|
||||
</label>
|
||||
<div class="button-row">
|
||||
<button type="button" class="secondary" @click="reportOpen = false">
|
||||
取消</button
|
||||
><button class="danger">提交举报</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<StatusPanel v-if="loading" state="loading" />
|
||||
<section v-else class="messages">
|
||||
<p v-if="error" class="error" role="alert">{{ error }}</p>
|
||||
<article v-for="m in items" :key="m.id" class="bubble" :class="m.status">
|
||||
<p>{{ m.text }}</p>
|
||||
<small
|
||||
>#{{ m.seq }} ·
|
||||
{{
|
||||
m.status === "sending"
|
||||
? "发送中"
|
||||
: m.status === "failed"
|
||||
? "发送失败"
|
||||
: "已送达"
|
||||
}}</small
|
||||
>
|
||||
</article>
|
||||
</section>
|
||||
<form class="composer" @submit.prevent="send">
|
||||
<label class="sr-only">消息</label
|
||||
><textarea
|
||||
v-model="text"
|
||||
maxlength="1000"
|
||||
rows="1"
|
||||
placeholder="写下回声…"
|
||||
required
|
||||
></textarea
|
||||
><button aria-label="发送消息">↑</button>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import AppHeader from "../components/AppHeader.vue";
|
||||
import StatusPanel from "../components/StatusPanel.vue";
|
||||
import { useChatStore } from "../stores/chat";
|
||||
const chat = useChatStore(),
|
||||
loading = ref(true),
|
||||
error = ref("");
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await chat.loadConversations();
|
||||
chat.connect();
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "加载失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<main>
|
||||
<AppHeader title="回声"
|
||||
><span class="pill">{{ chat.unread }} 未读</span></AppHeader
|
||||
><StatusPanel v-if="loading" state="loading" /><StatusPanel
|
||||
v-else-if="error"
|
||||
state="error"
|
||||
:detail="error"
|
||||
/><StatusPanel v-else-if="!chat.conversations.length" state="empty" />
|
||||
<section v-else class="list">
|
||||
<RouterLink
|
||||
v-for="item in chat.conversations"
|
||||
:key="item.id"
|
||||
:to="`/conversations/${item.id}`"
|
||||
class="conversation glass"
|
||||
><div>
|
||||
<b>匿名会话</b>
|
||||
<p>
|
||||
{{ item.lastMessage?.text ?? "新的相遇" }}
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="Number(item.unread)" class="badge">{{ item.unread }}</span
|
||||
><time>{{
|
||||
new Date(item.lastMessageAt).toLocaleDateString()
|
||||
}}</time></RouterLink
|
||||
>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import AppHeader from "../components/AppHeader.vue";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
const auth = useAuthStore();
|
||||
</script>
|
||||
<template>
|
||||
<main>
|
||||
<AppHeader title="海面"
|
||||
><RouterLink
|
||||
class="avatar"
|
||||
to="/settings"
|
||||
:style="{ background: auth.me?.avatarColor ?? '#4fd1c5' }"
|
||||
aria-label="打开设置"
|
||||
>{{ auth.me?.nickname?.slice(0, 1) ?? "○" }}</RouterLink
|
||||
></AppHeader
|
||||
>
|
||||
<section class="sea">
|
||||
<div class="moon"></div>
|
||||
<p class="eyebrow">今晚的洋流很安静</p>
|
||||
<h2>{{ auth.me?.nickname ?? "匿名旅人" }},想留下些什么?</h2>
|
||||
<div class="actions">
|
||||
<RouterLink to="/throw" class="action throw"
|
||||
><b>↗</b
|
||||
><span>扔一只瓶子<small>每日上限 10 次</small></span></RouterLink
|
||||
><RouterLink to="/pick" class="action pick"
|
||||
><b>⌁</b
|
||||
><span>捞一只瓶子<small>每日上限 20 次</small></span></RouterLink
|
||||
>
|
||||
</div>
|
||||
<p class="quota-note">
|
||||
服务端尚未提供当日用量查询;此处只展示规则上限,不伪造剩余次数。
|
||||
</p>
|
||||
</section>
|
||||
<section class="glass quote">
|
||||
<p>“有些话不需要目的地,<br />只需要被海浪接住。”</p>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
const auth = useAuthStore(),
|
||||
route = useRoute(),
|
||||
router = useRouter();
|
||||
const phone = ref(""),
|
||||
code = ref(""),
|
||||
debugCode = ref(""),
|
||||
sent = ref(false),
|
||||
busy = ref(false),
|
||||
error = ref("");
|
||||
const deviceId = (() => {
|
||||
const key = "drift-device-id";
|
||||
let id = localStorage.getItem(key);
|
||||
if (!id) {
|
||||
id = `web-${crypto.randomUUID()}`;
|
||||
localStorage.setItem(key, id);
|
||||
}
|
||||
return id;
|
||||
})();
|
||||
const validPhone = computed(() =>
|
||||
/^(?:\+?86)?1\d{10}$/.test(phone.value.replace(/\s/g, "")),
|
||||
);
|
||||
async function send() {
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const r = await auth.sendCode(phone.value, deviceId);
|
||||
sent.value = true;
|
||||
debugCode.value = r.debugCode ?? "";
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "发送失败";
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
async function login() {
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await auth.login(phone.value, code.value, deviceId);
|
||||
await router.replace(
|
||||
typeof route.query.redirect === "string" ? route.query.redirect : "/",
|
||||
);
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "登录失败";
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<main class="login">
|
||||
<section class="hero">
|
||||
<span class="orb">◉</span>
|
||||
<p class="eyebrow">DRIFT / 漂流瓶</p>
|
||||
<h1>听见深海里的<br /><em>一束回声</em></h1>
|
||||
<p>匿名投递,偶然相遇。每次打开都是真实连接。</p>
|
||||
</section>
|
||||
<form class="glass" @submit.prevent="sent ? login() : send()">
|
||||
<label
|
||||
>手机号<input
|
||||
v-model="phone"
|
||||
autocomplete="tel"
|
||||
inputmode="tel"
|
||||
placeholder="+86 138 0000 0000"
|
||||
required /></label
|
||||
><label v-if="sent"
|
||||
>演示验证码<input
|
||||
v-model="code"
|
||||
autocomplete="one-time-code"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
placeholder="6 位验证码"
|
||||
required
|
||||
/></label>
|
||||
<p v-if="debugCode" class="demo">
|
||||
开发环境演示验证码:<strong>{{ debugCode }}</strong>
|
||||
</p>
|
||||
<p v-if="error" class="error" role="alert">{{ error }}</p>
|
||||
<button class="primary" :disabled="busy || !validPhone">
|
||||
{{ busy ? "请稍候…" : sent ? "进入海面" : "获取演示验证码" }}</button
|
||||
><button v-if="sent" type="button" class="text" @click="sent = false">
|
||||
更换手机号
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import AppHeader from "../components/AppHeader.vue";
|
||||
import StatusPanel from "../components/StatusPanel.vue";
|
||||
import { api } from "../stores/auth";
|
||||
const items = ref<any[]>([]),
|
||||
loading = ref(true),
|
||||
error = ref("");
|
||||
onMounted(async () => {
|
||||
try {
|
||||
items.value = (await api.get<any>("/me/bottles?limit=50")).items;
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "加载失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
const labels: Record<string, string> = {
|
||||
REVIEWING: "审核中",
|
||||
MANUAL_REVIEW: "人工复审",
|
||||
APPROVED: "已通过",
|
||||
REJECTED: "未通过",
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<main>
|
||||
<AppHeader title="我的瓶子" /><StatusPanel
|
||||
v-if="loading"
|
||||
state="loading"
|
||||
/><StatusPanel
|
||||
v-else-if="error"
|
||||
state="error"
|
||||
:detail="error"
|
||||
/><StatusPanel v-else-if="!items.length" state="empty" />
|
||||
<section v-else class="list">
|
||||
<article v-for="item in items" :key="item.id" class="glass bottle-row">
|
||||
<div class="between">
|
||||
<span class="pill">{{
|
||||
labels[item.reviewStatus] ?? item.reviewStatus
|
||||
}}</span
|
||||
><time>{{ new Date(item.createdAt).toLocaleDateString() }}</time>
|
||||
</div>
|
||||
<p>{{ item.contentText }}</p>
|
||||
<small>池状态:{{ item.poolStatus }}</small>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import AppHeader from "../components/AppHeader.vue";
|
||||
import { api } from "../stores/auth";
|
||||
const router = useRouter();
|
||||
const busy = ref(false),
|
||||
error = ref(""),
|
||||
picked = ref<any>(null),
|
||||
reply = ref("");
|
||||
async function pick() {
|
||||
if (!navigator.onLine) {
|
||||
error.value = "当前离线,无法捞瓶。";
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
picked.value = await api.post("/bottles/pick", undefined, {
|
||||
"Idempotency-Key": crypto.randomUUID(),
|
||||
});
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : "海面暂时没有瓶子";
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
async function back() {
|
||||
await api.post(`/bottles/${picked.value.bottle.id}/return`, {
|
||||
leaseId: picked.value.lease.id,
|
||||
token: picked.value.lease.token,
|
||||
});
|
||||
picked.value = null;
|
||||
}
|
||||
async function answer() {
|
||||
const r = await api.post<any>(`/bottles/${picked.value.bottle.id}/reply`, {
|
||||
leaseId: picked.value.lease.id,
|
||||
leaseToken: picked.value.lease.token,
|
||||
clientMsgId: crypto.randomUUID(),
|
||||
text: reply.value,
|
||||
});
|
||||
await router.push(`/conversations/${r.conversationId}`);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<main>
|
||||
<AppHeader title="捞一只瓶子" back />
|
||||
<section class="page">
|
||||
<div v-if="!picked" class="empty-ocean">
|
||||
<div class="bottle">⌁</div>
|
||||
<h2>让洋流替你选择</h2>
|
||||
<p>不会捞到自己、拉黑对象或已经见过的瓶子。</p>
|
||||
<p v-if="error" class="error" role="alert">{{ error }}</p>
|
||||
<button class="primary" :disabled="busy" @click="pick">
|
||||
{{ busy ? "正在寻找…" : "伸手捞取" }}
|
||||
</button>
|
||||
</div>
|
||||
<article v-else class="glass bottle-card">
|
||||
<div class="author">
|
||||
<i :style="{ background: picked.author.avatarColor }"></i>
|
||||
<div>
|
||||
<b>{{ picked.author.nickname }}</b
|
||||
><small>{{ picked.author.bio || "匿名海客" }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<blockquote>{{ picked.bottle.contentText }}</blockquote>
|
||||
<form @submit.prevent="answer">
|
||||
<label
|
||||
>第一句回复<textarea
|
||||
v-model="reply"
|
||||
maxlength="1000"
|
||||
rows="4"
|
||||
required
|
||||
></textarea></label
|
||||
><button class="primary" :disabled="!reply.trim()">
|
||||
回复并建立会话
|
||||
</button>
|
||||
</form>
|
||||
<button class="secondary" @click="back">放回海里</button>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import AppHeader from "../components/AppHeader.vue";
|
||||
import { api, useAuthStore } from "../stores/auth";
|
||||
const auth = useAuthStore(),
|
||||
router = useRouter(),
|
||||
blocks = ref<any[]>([]),
|
||||
enabled = ref(true),
|
||||
nickname = ref(""),
|
||||
bio = ref(""),
|
||||
color = ref("#4fd1c5"),
|
||||
message = ref("");
|
||||
onMounted(async () => {
|
||||
nickname.value = auth.me?.nickname ?? "";
|
||||
bio.value = auth.me?.bio ?? "";
|
||||
color.value = auth.me?.avatarColor ?? "#4fd1c5";
|
||||
const [b, p] = await Promise.all([
|
||||
api.get<any>("/me/blocks"),
|
||||
api.get<any>("/settings/push"),
|
||||
]);
|
||||
blocks.value = b.items;
|
||||
enabled.value = p.inAppEnabled;
|
||||
});
|
||||
async function saveProfile() {
|
||||
await api.patch("/me/anonymous-profile", {
|
||||
nickname: nickname.value,
|
||||
avatarColor: color.value,
|
||||
bio: bio.value || null,
|
||||
});
|
||||
message.value = "资料已提交审核";
|
||||
}
|
||||
async function preference() {
|
||||
await api.put("/settings/push", { inAppEnabled: enabled.value });
|
||||
message.value = "通知偏好已保存";
|
||||
}
|
||||
async function unblock(id: string) {
|
||||
await api.delete(`/me/blocks/${id}`);
|
||||
blocks.value = blocks.value.filter((x) => x.publicId !== id);
|
||||
}
|
||||
async function logout() {
|
||||
await auth.logout();
|
||||
await router.replace("/login");
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<main>
|
||||
<AppHeader title="设置" />
|
||||
<section class="page settings">
|
||||
<form class="glass" @submit.prevent="saveProfile">
|
||||
<h2>匿名资料</h2>
|
||||
<label>昵称<input v-model="nickname" maxlength="64" required /></label
|
||||
><label>头像色<input v-model="color" type="color" required /></label
|
||||
><label
|
||||
>简介<textarea
|
||||
v-model="bio"
|
||||
maxlength="500"
|
||||
rows="3"
|
||||
></textarea></label
|
||||
><button class="secondary">保存资料</button>
|
||||
</form>
|
||||
<section class="glass">
|
||||
<h2>通知偏好</h2>
|
||||
<label class="toggle"
|
||||
><span>站内通知<small>浏览器内提醒,不等同系统 Push</small></span
|
||||
><input v-model="enabled" type="checkbox" @change="preference"
|
||||
/></label>
|
||||
</section>
|
||||
<section class="glass">
|
||||
<h2>黑名单</h2>
|
||||
<p v-if="!blocks.length" class="muted">没有已拉黑用户</p>
|
||||
<div v-for="item in blocks" :key="item.publicId" class="between">
|
||||
<code>{{ item.publicId.slice(0, 8) }}…</code
|
||||
><button class="text" @click="unblock(item.publicId)">解除</button>
|
||||
</div>
|
||||
</section>
|
||||
<p v-if="message" role="status">{{ message }}</p>
|
||||
<button class="danger" @click="logout">退出登录</button>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createOperationState } from "./operation";
|
||||
|
||||
describe("operation state", () => {
|
||||
it("blocks duplicate submissions and exposes 4xx feedback", async () => {
|
||||
let reject!: (reason: unknown) => void;
|
||||
const action = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((_resolve, nextReject) => {
|
||||
reject = nextReject;
|
||||
}),
|
||||
);
|
||||
const state = createOperationState();
|
||||
const first = state.run("report:r-1", action);
|
||||
const duplicate = state.run("report:r-1", action);
|
||||
expect(action).toHaveBeenCalledOnce();
|
||||
expect(state.pending("report:r-1")).toBe(true);
|
||||
reject(
|
||||
Object.assign(new Error("Report already resolved"), { status: 409 }),
|
||||
);
|
||||
await expect(first).rejects.toThrow("Report already resolved");
|
||||
await expect(duplicate).rejects.toThrow("Report already resolved");
|
||||
expect(state.pending("report:r-1")).toBe(false);
|
||||
expect(state.error.value).toBe(
|
||||
"操作冲突:该项目可能已被处理,请刷新后重试",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ref } from "vue";
|
||||
|
||||
export function createOperationState() {
|
||||
const active = new Map<string, Promise<unknown>>();
|
||||
const error = ref("");
|
||||
|
||||
function pending(key: string) {
|
||||
return active.has(key);
|
||||
}
|
||||
|
||||
function run<T>(key: string, action: () => Promise<T>): Promise<T> {
|
||||
const existing = active.get(key) as Promise<T> | undefined;
|
||||
if (existing) return existing;
|
||||
error.value = "";
|
||||
const operation = action()
|
||||
.catch((reason: unknown) => {
|
||||
const status =
|
||||
typeof reason === "object" && reason !== null && "status" in reason
|
||||
? reason.status
|
||||
: undefined;
|
||||
error.value =
|
||||
status === 409
|
||||
? "操作冲突:该项目可能已被处理,请刷新后重试"
|
||||
: status === 403
|
||||
? "无权执行此管理操作"
|
||||
: reason instanceof Error
|
||||
? reason.message
|
||||
: "管理操作失败";
|
||||
throw reason;
|
||||
})
|
||||
.finally(() => active.delete(key));
|
||||
active.set(key, operation);
|
||||
return operation;
|
||||
}
|
||||
|
||||
return { error, pending, run };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.vue",
|
||||
"vite.config.ts",
|
||||
"vitest.config.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig } from "vite";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
VitePWA({
|
||||
// E2E 跑产物时关闭 Service Worker,避免缓存干扰断言
|
||||
disable: process.env.DISABLE_PWA === "1",
|
||||
registerType: "autoUpdate",
|
||||
manifest: {
|
||||
name: "漂流瓶 · 深海回声",
|
||||
short_name: "漂流瓶",
|
||||
description: "匿名漂流瓶社交 PWA",
|
||||
theme_color: "#061b2b",
|
||||
background_color: "#04111d",
|
||||
display: "standalone",
|
||||
start_url: "/",
|
||||
icons: [
|
||||
{
|
||||
src: "/icon.svg",
|
||||
sizes: "any",
|
||||
type: "image/svg+xml",
|
||||
purpose: "any maskable",
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: { navigateFallback: "/index.html", runtimeCaching: [] },
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:3000",
|
||||
changeOrigin: true,
|
||||
headers: { Origin: "http://127.0.0.1:5173" },
|
||||
},
|
||||
"/socket.io": {
|
||||
target: "http://127.0.0.1:3000",
|
||||
ws: true,
|
||||
headers: { Origin: "http://127.0.0.1:5173" },
|
||||
},
|
||||
},
|
||||
},
|
||||
// E2E 跑产物预览时同样需要把 /api 与 /socket.io 代理到 API,
|
||||
// 并由代理注入 Origin,满足 StateChangingOriginGuard 的校验。
|
||||
preview: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:3000",
|
||||
changeOrigin: true,
|
||||
headers: { Origin: "http://127.0.0.1:5173" },
|
||||
},
|
||||
"/socket.io": {
|
||||
target: "http://127.0.0.1:3000",
|
||||
ws: true,
|
||||
headers: { Origin: "http://127.0.0.1:5173" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: ["./src/test/setup.ts"],
|
||||
restoreMocks: true,
|
||||
},
|
||||
});
|
||||
@@ -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 { handleTopLevelError, logTopLevelError, runWorker } from "./main.js";
|
||||
import {
|
||||
createCombinedWorker,
|
||||
handleTopLevelError,
|
||||
logTopLevelError,
|
||||
runWorker,
|
||||
} from "./main.js";
|
||||
|
||||
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 () => {
|
||||
let finish!: () => void;
|
||||
const runOnce = vi.fn(
|
||||
|
||||
+23
-1
@@ -1,6 +1,8 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { ModerationWorker } from "./moderation-worker.js";
|
||||
import { LeaseReaper } from "./lease-reaper.processor.js";
|
||||
import { NotificationProcessor } from "./notification.processor.js";
|
||||
|
||||
type SignalSource = {
|
||||
once(signal: "SIGTERM" | "SIGINT", listener: () => void): unknown;
|
||||
@@ -16,6 +18,23 @@ type RunWorkerOptions = {
|
||||
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) {
|
||||
const signals = options.signals ?? process;
|
||||
const sleep =
|
||||
@@ -47,7 +66,10 @@ export async function runWorker(options: RunWorkerOptions) {
|
||||
|
||||
export async function main() {
|
||||
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({
|
||||
worker,
|
||||
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 () => {
|
||||
const { bottle, event } = await fixture("ordinary");
|
||||
const worker = new ModerationWorker(prisma, () => {
|
||||
@@ -282,7 +318,12 @@ describe("moderation worker with real PostgreSQL", () => {
|
||||
expect(decide).not.toHaveBeenCalled();
|
||||
expect(
|
||||
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([
|
||||
@@ -349,7 +390,11 @@ describe("moderation worker with real PostgreSQL", () => {
|
||||
expect(decide).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }),
|
||||
).toMatchObject({ status: "PUBLISHED" });
|
||||
).toMatchObject({
|
||||
status: "PUBLISHED",
|
||||
lockToken: null,
|
||||
lockedAt: null,
|
||||
});
|
||||
expect(
|
||||
await prisma.anonymousProfile.findUniqueOrThrow({
|
||||
where: { id: profile.id },
|
||||
@@ -360,6 +405,32 @@ describe("moderation worker with real PostgreSQL", () => {
|
||||
).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 () => {
|
||||
const { profile, event, task } = await profileFixture("ordinary", "first");
|
||||
await prisma.moderationTask.update({
|
||||
@@ -387,6 +458,37 @@ describe("moderation worker with real PostgreSQL", () => {
|
||||
).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 () => {
|
||||
const { event } = await fixture("secret正文");
|
||||
const entries: unknown[] = [];
|
||||
|
||||
@@ -269,10 +269,12 @@ export class ModerationWorker {
|
||||
profile.bio,
|
||||
]);
|
||||
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));
|
||||
return;
|
||||
}
|
||||
if (task.payloadHash !== hash)
|
||||
throw new Error("profile moderation payload drift");
|
||||
if (task.status === "COMPLETED") {
|
||||
if (!this.completedProfileMatches(profile, task, hash, version))
|
||||
throw new Error("completed profile moderation state drift");
|
||||
@@ -294,12 +296,8 @@ export class ModerationWorker {
|
||||
const currentHash = createHash("sha256")
|
||||
.update(currentText)
|
||||
.digest("hex");
|
||||
if (
|
||||
currentProfile.version !== version ||
|
||||
currentTask.payloadHash !== hash
|
||||
)
|
||||
return;
|
||||
if (currentHash !== hash)
|
||||
if (currentProfile.version !== version) return;
|
||||
if (currentHash !== hash || currentTask.payloadHash !== currentHash)
|
||||
throw new Error("profile moderation payload drift");
|
||||
if (currentTask.status === "COMPLETED") {
|
||||
if (
|
||||
@@ -333,12 +331,15 @@ export class ModerationWorker {
|
||||
hash: string,
|
||||
version: number,
|
||||
) {
|
||||
const decision = task.decision;
|
||||
return (
|
||||
profile.version === version &&
|
||||
task.payloadHash === hash &&
|
||||
task.decision !== null &&
|
||||
resultDecision(task.result) === task.decision &&
|
||||
profile.reviewStatus === task.decision
|
||||
(decision === "APPROVED" ||
|
||||
decision === "REJECTED" ||
|
||||
decision === "MANUAL_REVIEW") &&
|
||||
resultDecision(task.result) === decision &&
|
||||
profile.reviewStatus === decision
|
||||
);
|
||||
}
|
||||
|
||||
@@ -381,6 +382,9 @@ export class ModerationWorker {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.outboxEvent.updateMany({
|
||||
where: {
|
||||
eventType: {
|
||||
in: ["BOTTLE_MODERATION_REQUESTED", "PROFILE_MODERATION_REQUESTED"],
|
||||
},
|
||||
status: "PROCESSING",
|
||||
lockedAt: { lt: staleBefore },
|
||||
attempts: { gte: this.maxAttempts },
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -6,7 +6,9 @@
|
||||
"scripts": {
|
||||
"test": "vitest run --no-file-parallelism",
|
||||
"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",
|
||||
"test:e2e": "playwright test",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.base.json",
|
||||
"lint": "eslint . --ext .ts --max-warnings 0 && prettier --check .",
|
||||
"build": "corepack pnpm prisma generate && corepack pnpm --recursive run build",
|
||||
@@ -25,6 +27,7 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@types/node": "22.19.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.0",
|
||||
"@typescript-eslint/parser": "^8.18.0",
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
// 浏览器用例跑构建产物(vite preview),不再用按需转译的 dev server:
|
||||
// dev server 每次整页加载要拉数百个模块,Chromium 会以
|
||||
// ERR_INSUFFICIENT_RESOURCES 放弃加载,页面根本挂载不起来。
|
||||
const PORT = Number(process.env.WEB_PORT ?? 4173);
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
globalSetup: "./tests/e2e/global-setup.ts",
|
||||
// 双用户用例需要等 Worker 审核资料/瓶子(每个 1-2s)+ 两次登录,
|
||||
// 60s 不够,会在用例中途被拆除 context 而误报失败。
|
||||
timeout: 150_000,
|
||||
expect: { timeout: 15_000 },
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: [["list"]],
|
||||
use: {
|
||||
baseURL: `http://127.0.0.1:${PORT}`,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
video: "off",
|
||||
},
|
||||
// 由 Playwright 保证被测前端可用:先构建(关闭 Service Worker,避免缓存
|
||||
// 干扰断言),再起 preview。服务中途消失会让整轮报 ERR_CONNECTION_REFUSED,
|
||||
// 交由 Playwright 管理可避免这类假失败。
|
||||
webServer: {
|
||||
command: `DISABLE_PWA=1 corepack pnpm --filter @drift/web exec vite build && corepack pnpm --filter @drift/web exec vite preview --host 127.0.0.1 --port ${PORT} --strictPort`,
|
||||
url: `http://127.0.0.1:${PORT}`,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 240_000,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
// 该机器内存较小,避免 /dev/shm 与多余缓存把渲染进程拖垮
|
||||
launchOptions: {
|
||||
args: ["--disable-dev-shm-usage", "--disable-gpu"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
Generated
+5469
-2585
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user