Compare commits

..

12 Commits

Author SHA1 Message Date
root afab4e7cba test(web): 补充 Web MVP 端到端测试与本地测试编排
- 新增 Playwright E2E(治理、登录、投瓶捞瓶回复与实时聊天、举报拉黑与断线恢复)共 9 条用例
- 每个用例前重置数据库、用例后关闭全部 browser context,消除跨用例污染与资源泄漏
- 前端改由 Playwright 托管构建与 preview(关闭 SW);Vite 代理注入 Origin 头以通过接口 Origin 校验
- 根级 vitest 排除 Playwright 用例与需专用配置的 web 用例,避免误收集导致的假失败
- 测试手机号按实测校验结果收敛白名单,避免用例传入会被后端拒绝的号段

验证结果:
- Web E2E 9/9 连续两轮通过(含运行前后服务健康检查)
- API E2E 146/146 连续两轮通过
- 单测:根级 14 文件/119 用例、web 包 8 文件/31 用例通过
- typecheck、lint、build、依赖审计(audit)均通过
2026-09-18 11:23:20 +08:00
root 7ca855f19c feat(web): 构建漂流瓶移动端 PWA
- 演示登录、内存令牌、单 Promise 刷新与路由守卫\n- 投瓶、捞瓶、首次回复、会话与实时聊天\n- 独立管理员登录与审核/举报工作台\n- Socket 认证续期、断线补齐与账号切换清理\n- PWA 离线壳、深海主题与移动端无障碍
2026-09-17 22:19:24 +08:00
root fa0fa78312 fix(治理): 完成任务 8 安全与通知闭环修复
- 串行化拉黑、处罚、投瓶和匹配策略检查\n- 完成异步站内通知、未读统计和偏好并发语义\n- 补齐后台查询审计、处罚恢复和隐私测试\n- 稳定 Redis 恢复、匹配锁序及超时测试
2026-09-17 13:08:55 +08:00
root ce8e1db279 feat: 完成举报拉黑和审核处置闭环 2026-09-16 20:41:01 +08:00
root 02189ebb2e fix: 加固聊天限流与安全策略串行化 2026-09-16 14:20:23 +08:00
root 8a12c04104 fix: 完成消息 Outbox 投递与会话状态保护 2026-09-16 13:41:59 +08:00
root 13b8b9ded8 feat: 实现实时匿名会话 2026-09-16 09:55:03 +08:00
root 11fcdb5307 chore: 完善捞瓶重试配置与超时 2026-09-15 21:44:39 +08:00
root c94d7b1af0 fix: 限定捞瓶查询与重试成本 2026-09-15 21:13:28 +08:00
root 0c5cbd75d9 fix: 改进候选随机性与并发配额稳定性 2026-09-15 15:14:01 +08:00
root 69fc8d51ce feat: 实现公平捞瓶和领取租约 2026-09-15 13:56:56 +08:00
root 969a10d664 fix: 严格校验资料审核终态 2026-09-15 11:19:45 +08:00
118 changed files with 16817 additions and 2640 deletions
+8
View File
@@ -9,6 +9,8 @@ PHONE_ENCRYPTION_KEY=<base64-encoded-exactly-32-byte-key>
PHONE_HMAC_KEY=<independent-at-least-32-byte-secret> PHONE_HMAC_KEY=<independent-at-least-32-byte-secret>
VERIFICATION_CODE_HMAC_KEY=<independent-at-least-32-byte-secret> VERIFICATION_CODE_HMAC_KEY=<independent-at-least-32-byte-secret>
REFRESH_TOKEN_HMAC_KEY=<independent-at-least-32-byte-secret> REFRESH_TOKEN_HMAC_KEY=<independent-at-least-32-byte-secret>
LEASE_TOKEN_HMAC_KEY=<independent-at-least-32-byte-secret>
LEASE_TOKEN_ENCRYPTION_KEY=<base64-encoded-exactly-32-byte-key>
JWT_SECRET=<at-least-32-byte-secret> JWT_SECRET=<at-least-32-byte-secret>
JWT_ISSUER=drift-api JWT_ISSUER=drift-api
JWT_AUDIENCE=drift-web JWT_AUDIENCE=drift-web
@@ -19,4 +21,10 @@ REFRESH_ATTEMPT_IP_WINDOW_SECONDS=60
REFRESH_RATE_LIMIT=60 REFRESH_RATE_LIMIT=60
SMS_CODE_TTL_SECONDS=300 SMS_CODE_TTL_SECONDS=300
REDIS_KEY_PREFIX=drift:auth: REDIS_KEY_PREFIX=drift:auth:
# Overall pick retry budget and attempt cap; keep bounded to protect API latency/DB load.
MATCH_PICK_RETRY_BUDGET_MS=5000
MATCH_PICK_MAX_ATTEMPTS=64
# Each pick/winner-read transaction is capped and further reduced to its remaining budget.
MATCH_PICK_TRANSACTION_MAX_WAIT_MS=1000
MATCH_PICK_TRANSACTION_TIMEOUT_MS=2000
TRUST_PROXY=false TRUST_PROXY=false
+6 -2
View File
@@ -7,7 +7,7 @@
"build": "tsc -p tsconfig.build.json", "build": "tsc -p tsconfig.build.json",
"start": "node dist/main.js", "start": "node dist/main.js",
"test": "vitest run --config vitest.config.ts --no-file-parallelism", "test": "vitest run --config vitest.config.ts --no-file-parallelism",
"test:e2e": "vitest run --config vitest.config.ts src/health/health.e2e-spec.ts src/auth/auth.e2e-spec.ts src/profile/profile.e2e-spec.ts src/bottle/bottle.e2e-spec.ts --no-file-parallelism", "test:e2e": "vitest run --config vitest.config.ts src/health/health.e2e-spec.ts src/auth/auth.e2e-spec.ts src/profile/profile.e2e-spec.ts src/bottle/bottle.e2e-spec.ts src/match/match.e2e-spec.ts src/conversation/conversation.e2e-spec.ts src/safety/safety-admin.e2e-spec.ts --no-file-parallelism",
"typecheck": "tsc --noEmit -p tsconfig.json" "typecheck": "tsc --noEmit -p tsconfig.json"
}, },
"dependencies": { "dependencies": {
@@ -15,6 +15,8 @@
"@nestjs/common": "^11.1.6", "@nestjs/common": "^11.1.6",
"@nestjs/core": "^11.1.6", "@nestjs/core": "^11.1.6",
"@nestjs/platform-express": "^11.1.6", "@nestjs/platform-express": "^11.1.6",
"@nestjs/platform-socket.io": "^11.1.6",
"@nestjs/websockets": "^11.1.6",
"@prisma/client": "6.19.0", "@prisma/client": "6.19.0",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.2", "class-validator": "^0.14.2",
@@ -22,12 +24,14 @@
"helmet": "^8.1.0", "helmet": "^8.1.0",
"redis": "^5.8.2", "redis": "^5.8.2",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2" "rxjs": "^7.8.2",
"socket.io": "^4.8.1"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/testing": "^11.1.6", "@nestjs/testing": "^11.1.6",
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/supertest": "^6.0.3", "@types/supertest": "^6.0.3",
"socket.io-client": "^4.8.1",
"supertest": "^7.1.4", "supertest": "^7.1.4",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vitest": "^4.1.11" "vitest": "^4.1.11"
+94
View File
@@ -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",
);
+36
View File
@@ -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;
}
}
+15
View File
@@ -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 {}
+412
View File
@@ -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;
}
}
+43
View File
@@ -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;
}
+18 -1
View File
@@ -4,8 +4,25 @@ import { HealthModule } from "./health/health.module.js";
import { AuthModule } from "./auth/auth.module.js"; import { AuthModule } from "./auth/auth.module.js";
import { ProfileModule } from "./profile/profile.module.js"; import { ProfileModule } from "./profile/profile.module.js";
import { BottleModule } from "./bottle/bottle.module.js"; import { BottleModule } from "./bottle/bottle.module.js";
import { MatchModule } from "./match/match.module.js";
import { ConversationModule } from "./conversation/conversation.module.js";
import { SafetyModule } from "./safety/safety.module.js";
import { NotificationModule } from "./notification/notification.module.js";
import { AdminModule } from "./admin/admin.module.js";
@Module({ imports: [HealthModule, AuthModule, ProfileModule, BottleModule] }) @Module({
imports: [
HealthModule,
AuthModule,
ProfileModule,
BottleModule,
MatchModule,
ConversationModule,
SafetyModule,
NotificationModule,
AdminModule,
],
})
export class AppModule implements NestModule { export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void { configure(consumer: MiddlewareConsumer): void {
consumer.apply(RequestIdMiddleware).forRoutes("{*path}"); consumer.apply(RequestIdMiddleware).forRoutes("{*path}");
+31
View File
@@ -1,7 +1,9 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { import {
codeHmac, codeHmac,
decryptLeaseToken,
demoSmsCodeEnabled, demoSmsCodeEnabled,
encryptLeaseToken,
phoneHmac, phoneHmac,
resetAuthEnvironmentForTests, resetAuthEnvironmentForTests,
validateAuthEnvironment, validateAuthEnvironment,
@@ -13,6 +15,8 @@ const valid = {
PHONE_HMAC_KEY: "phone-hmac-key-that-is-at-least-32-bytes", PHONE_HMAC_KEY: "phone-hmac-key-that-is-at-least-32-bytes",
VERIFICATION_CODE_HMAC_KEY: "code-hmac-key-that-is-at-least-32-bytes!", VERIFICATION_CODE_HMAC_KEY: "code-hmac-key-that-is-at-least-32-bytes!",
REFRESH_TOKEN_HMAC_KEY: "refresh-key-that-is-at-least-thirty-two-bytes", REFRESH_TOKEN_HMAC_KEY: "refresh-key-that-is-at-least-thirty-two-bytes",
LEASE_TOKEN_HMAC_KEY: "lease-hmac-key-that-is-at-least-thirty-two-bytes",
LEASE_TOKEN_ENCRYPTION_KEY: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
JWT_SECRET: "jwt-secret-that-is-at-least-thirty-two-bytes", JWT_SECRET: "jwt-secret-that-is-at-least-thirty-two-bytes",
WEB_ORIGIN: "http://localhost:3000", WEB_ORIGIN: "http://localhost:3000",
}; };
@@ -46,6 +50,33 @@ describe("auth environment", () => {
); );
}); });
it("rejects lease keys reused from any authentication secret", () => {
process.env.LEASE_TOKEN_HMAC_KEY = process.env.REFRESH_TOKEN_HMAC_KEY;
expect(() => validateAuthEnvironment()).toThrow(
/secrets must be independent/i,
);
});
it("binds canonical lease token ciphertext to its lease id", () => {
validateAuthEnvironment();
const leaseId = "00000000-0000-4000-8000-000000000001";
const token = "x".repeat(43);
const ciphertext = encryptLeaseToken(token, leaseId);
expect(ciphertext).toHaveLength(71);
expect(decryptLeaseToken(ciphertext, leaseId)).toBe(token);
expect(() =>
decryptLeaseToken(ciphertext, "00000000-0000-4000-8000-000000000002"),
).toThrow();
});
it("rejects encrypted lease plaintext with a non-canonical format", () => {
validateAuthEnvironment();
const leaseId = "00000000-0000-4000-8000-000000000001";
expect(() =>
decryptLeaseToken(encryptLeaseToken("short", leaseId), leaseId),
).toThrow(/Invalid lease token ciphertext/);
});
it("uses a verification-code key independent from the phone key", () => { it("uses a verification-code key independent from the phone key", () => {
validateAuthEnvironment(); validateAuthEnvironment();
const phoneDigest = phoneHmac("+8613800138000"); const phoneDigest = phoneHmac("+8613800138000");
+49 -6
View File
@@ -9,10 +9,9 @@ import {
function required(name: string, min = 32): Buffer { function required(name: string, min = 32): Buffer {
const value = process.env[name]; const value = process.env[name];
if (!value) throw new Error(`${name} is required`); if (!value) throw new Error(`${name} is required`);
const decoded = const decoded = name.endsWith("_ENCRYPTION_KEY")
name === "PHONE_ENCRYPTION_KEY" ? Buffer.from(value, "base64")
? Buffer.from(value, "base64") : Buffer.from(value);
: Buffer.from(value);
if (decoded.length < min) if (decoded.length < min)
throw new Error(`${name} must be at least ${min} bytes`); throw new Error(`${name} must be at least ${min} bytes`);
return decoded; return decoded;
@@ -22,6 +21,8 @@ const REQUIRED_KEYS = [
"PHONE_HMAC_KEY", "PHONE_HMAC_KEY",
"VERIFICATION_CODE_HMAC_KEY", "VERIFICATION_CODE_HMAC_KEY",
"REFRESH_TOKEN_HMAC_KEY", "REFRESH_TOKEN_HMAC_KEY",
"LEASE_TOKEN_HMAC_KEY",
"LEASE_TOKEN_ENCRYPTION_KEY",
"JWT_SECRET", "JWT_SECRET",
] as const; ] as const;
type AuthSecrets = Record<(typeof REQUIRED_KEYS)[number], Buffer>; type AuthSecrets = Record<(typeof REQUIRED_KEYS)[number], Buffer>;
@@ -56,8 +57,13 @@ export function validateAuthEnvironment(): void {
const loaded = Object.fromEntries( const loaded = Object.fromEntries(
REQUIRED_KEYS.map((name) => [name, required(name)]), REQUIRED_KEYS.map((name) => [name, required(name)]),
) as unknown as AuthSecrets; ) as unknown as AuthSecrets;
if (loaded.PHONE_ENCRYPTION_KEY.length !== 32) for (const name of [
throw new Error("PHONE_ENCRYPTION_KEY must decode to exactly 32 bytes"); "PHONE_ENCRYPTION_KEY",
"LEASE_TOKEN_ENCRYPTION_KEY",
] as const) {
if (loaded[name].length !== 32)
throw new Error(`${name} must decode to exactly 32 bytes`);
}
const fingerprints = REQUIRED_KEYS.map((name) => const fingerprints = REQUIRED_KEYS.map((name) =>
loaded[name].toString("hex"), loaded[name].toString("hex"),
); );
@@ -103,6 +109,43 @@ export const refreshHmac = (token: string): string =>
createHmac("sha256", authSecrets().REFRESH_TOKEN_HMAC_KEY) createHmac("sha256", authSecrets().REFRESH_TOKEN_HMAC_KEY)
.update(token) .update(token)
.digest("hex"); .digest("hex");
export const leaseHmac = (token: string): string =>
createHmac("sha256", authSecrets().LEASE_TOKEN_HMAC_KEY)
.update(token)
.digest("hex");
export function encryptLeaseToken(
token: string,
leaseId: string,
): Uint8Array<ArrayBuffer> {
const nonce = randomBytes(12);
const cipher = createCipheriv(
"aes-256-gcm",
authSecrets().LEASE_TOKEN_ENCRYPTION_KEY,
nonce,
);
cipher.setAAD(Buffer.from(leaseId));
const body = Buffer.concat([cipher.update(token, "utf8"), cipher.final()]);
const encrypted = Buffer.concat([nonce, cipher.getAuthTag(), body]);
return new Uint8Array(encrypted).slice();
}
export function decryptLeaseToken(value: Uint8Array, leaseId: string): string {
// nonce (12) + authentication tag (16) + canonical 32-byte token (43 base64url chars)
if (value.length !== 71) throw new Error("Invalid lease token ciphertext");
const decipher = createDecipheriv(
"aes-256-gcm",
authSecrets().LEASE_TOKEN_ENCRYPTION_KEY,
Buffer.from(value.subarray(0, 12)),
);
decipher.setAAD(Buffer.from(leaseId));
decipher.setAuthTag(Buffer.from(value.subarray(12, 28)));
const token = Buffer.concat([
decipher.update(Buffer.from(value.subarray(28))),
decipher.final(),
]).toString();
if (!/^[A-Za-z0-9_-]{43}$/.test(token))
throw new Error("Invalid lease token ciphertext");
return token;
}
export const safeEqual = (a: string, b: string): boolean => { export const safeEqual = (a: string, b: string): boolean => {
const x = Buffer.from(a); const x = Buffer.from(a);
const y = Buffer.from(b); const y = Buffer.from(b);
+33
View File
@@ -30,6 +30,9 @@ process.env.PHONE_ENCRYPTION_KEY =
process.env.PHONE_HMAC_KEY = "test-phone-hmac-key-with-at-least-32-bytes"; process.env.PHONE_HMAC_KEY = "test-phone-hmac-key-with-at-least-32-bytes";
process.env.VERIFICATION_CODE_HMAC_KEY = process.env.VERIFICATION_CODE_HMAC_KEY =
"test-code-hmac-key-with-at-least-32-bytes"; "test-code-hmac-key-with-at-least-32-bytes";
process.env.LEASE_TOKEN_HMAC_KEY = "test-lease-hmac-key-with-at-least-32-bytes";
process.env.LEASE_TOKEN_ENCRYPTION_KEY =
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=";
process.env.JWT_SECRET = "test-jwt-secret-with-at-least-thirty-two-bytes"; process.env.JWT_SECRET = "test-jwt-secret-with-at-least-thirty-two-bytes";
process.env.REFRESH_TOKEN_HMAC_KEY = process.env.REFRESH_TOKEN_HMAC_KEY =
"test-refresh-hmac-key-with-at-least-32-bytes"; "test-refresh-hmac-key-with-at-least-32-bytes";
@@ -777,6 +780,36 @@ describe("auth real PostgreSQL/Redis", () => {
.expect(401); .expect(401);
}); });
it("rejects login while a suspension is active and permits a fresh login after expiry", async () => {
const initial = await login();
expect(initial.status).toBe(201);
const account = await prisma.account.findFirstOrThrow();
const expiresAt = new Date(Date.now() + 60_000);
const sanction = await prisma.sanction.create({
data: {
accountId: account.id,
type: "SUSPENSION",
reason: "test",
startsAt: new Date(Date.now() - 1_000),
expiresAt,
},
});
const sent = await send();
const blocked = await request(app.getHttpServer())
.post("/api/v1/auth/sms/login")
.send({ phone, deviceId, code: sent.body.data.debugCode });
expect(blocked.status).toBe(401);
await prisma.sanction.update({
where: { id: sanction.id },
data: { expiresAt: new Date(Date.now() - 1_000) },
});
const recovered = await login();
expect(recovered.status).toBe(201);
expect(
await prisma.account.findUniqueOrThrow({ where: { id: account.id } }),
).toMatchObject({ status: "ACTIVE" });
});
it("recovers Auth Redis after a connection deadline and closes its sockets", async () => { it("recovers Auth Redis after a connection deadline and closes its sockets", async () => {
const sockets = new Set<Socket>(); const sockets = new Set<Socket>();
const server = createServer((socket) => { const server = createServer((socket) => {
+23 -6
View File
@@ -9,6 +9,7 @@ import { ErrorCode } from "@drift/contracts";
import type { Request } from "express"; import type { Request } from "express";
import { DomainException } from "../common/domain.exception.js"; import { DomainException } from "../common/domain.exception.js";
import { PrismaService } from "../database/prisma.service.js"; import { PrismaService } from "../database/prisma.service.js";
import type { AccessClaims } from "./token.service.js";
import { TokenService } from "./token.service.js"; import { TokenService } from "./token.service.js";
import type { AuthenticatedRequest } from "./current-user.decorator.js"; import type { AuthenticatedRequest } from "./current-user.decorator.js";
@Injectable() @Injectable()
@@ -17,11 +18,8 @@ export class AuthGuard implements CanActivate {
@Inject(PrismaService) private readonly prisma: PrismaService, @Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(TokenService) private readonly tokens: TokenService, @Inject(TokenService) private readonly tokens: TokenService,
) {} ) {}
async canActivate(ctx: ExecutionContext): Promise<boolean> { async validateAccess(token: string): Promise<AccessClaims> {
const req = ctx.switchToHttp().getRequest<Request & AuthenticatedRequest>(); const claims = this.tokens.verifyAccess(token);
const auth = req.headers.authorization;
if (!auth?.startsWith("Bearer ")) throw this.denied();
const claims = this.tokens.verifyAccess(auth.slice(7));
const session = await this.prisma.session.findUnique({ const session = await this.prisma.session.findUnique({
where: { id: claims.session_id }, where: { id: claims.session_id },
include: { account: true }, include: { account: true },
@@ -37,7 +35,26 @@ export class AuthGuard implements CanActivate {
session.account.tokenVersion !== claims.token_version session.account.tokenVersion !== claims.token_version
) )
throw this.denied(); throw this.denied();
req.user = claims; const now = new Date();
const sanction = await this.prisma.sanction.findFirst({
where: {
accountId: claims.sub,
type: { in: ["SUSPENSION", "BAN"] },
revokedAt: null,
startsAt: { lte: now },
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
},
select: { id: true },
});
if (sanction) throw this.denied();
return claims;
}
async canActivate(ctx: ExecutionContext): Promise<boolean> {
const req = ctx.switchToHttp().getRequest<Request & AuthenticatedRequest>();
const auth = req.headers.authorization;
if (!auth?.startsWith("Bearer ")) throw this.denied();
req.user = await this.validateAccess(auth.slice(7));
return true; return true;
} }
private denied() { private denied() {
+12
View File
@@ -148,6 +148,18 @@ export class AuthService {
}); });
if (!account) account = await this.createAccount(phone, digest); if (!account) account = await this.createAccount(phone, digest);
if (account.status !== "ACTIVE") throw this.invalid(); if (account.status !== "ACTIVE") throw this.invalid();
const now = new Date();
const sanction = await this.prisma.sanction.findFirst({
where: {
accountId: account.id,
type: { in: ["SUSPENSION", "BAN"] },
revokedAt: null,
startsAt: { lte: now },
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
},
select: { id: true },
});
if (sanction) throw this.invalid();
return this.createSession(account, deviceId); return this.createSession(account, deviceId);
} }
private async createAccount(phone: string, digest: string): Promise<Account> { private async createAccount(phone: string, digest: string): Promise<Account> {
+3
View File
@@ -11,6 +11,9 @@ process.env.VERIFICATION_CODE_HMAC_KEY =
"token-code-hmac-key-with-at-least-32-bytes!"; "token-code-hmac-key-with-at-least-32-bytes!";
process.env.REFRESH_TOKEN_HMAC_KEY = process.env.REFRESH_TOKEN_HMAC_KEY =
"token-refresh-hmac-key-with-at-least-32-bytes"; "token-refresh-hmac-key-with-at-least-32-bytes";
process.env.LEASE_TOKEN_HMAC_KEY = "test-lease-hmac-key-with-at-least-32-bytes";
process.env.LEASE_TOKEN_ENCRYPTION_KEY =
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=";
process.env.JWT_SECRET = "token-jwt-secret-with-at-least-thirty-two-bytes"; process.env.JWT_SECRET = "token-jwt-secret-with-at-least-thirty-two-bytes";
const secret = process.env.JWT_SECRET; const secret = process.env.JWT_SECRET;
+5 -2
View File
@@ -19,6 +19,9 @@ process.env.PHONE_ENCRYPTION_KEY =
process.env.PHONE_HMAC_KEY = "test-phone-hmac-key-with-at-least-32-bytes"; process.env.PHONE_HMAC_KEY = "test-phone-hmac-key-with-at-least-32-bytes";
process.env.VERIFICATION_CODE_HMAC_KEY = process.env.VERIFICATION_CODE_HMAC_KEY =
"test-code-hmac-key-with-at-least-32-bytes"; "test-code-hmac-key-with-at-least-32-bytes";
process.env.LEASE_TOKEN_HMAC_KEY = "test-lease-hmac-key-with-at-least-32-bytes";
process.env.LEASE_TOKEN_ENCRYPTION_KEY =
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=";
process.env.JWT_SECRET = "test-jwt-secret-with-at-least-thirty-two-bytes"; process.env.JWT_SECRET = "test-jwt-secret-with-at-least-thirty-two-bytes";
process.env.REFRESH_TOKEN_HMAC_KEY = process.env.REFRESH_TOKEN_HMAC_KEY =
"test-refresh-hmac-key-with-at-least-32-bytes"; "test-refresh-hmac-key-with-at-least-32-bytes";
@@ -252,8 +255,8 @@ describe("bottles with real PostgreSQL", () => {
expiresAt: new Date(Date.now() + 60_000), expiresAt: new Date(Date.now() + 60_000),
}, },
}); });
const denied = await create(randomUUID()).expect(403); const denied = await create(randomUUID()).expect(401);
expect(denied.body.code).toBe("ACCOUNT_SANCTIONED"); expect(denied.body.code).toBe("AUTH_UNAUTHORIZED");
expect(await prisma.bottle.count()).toBe(0); expect(await prisma.bottle.count()).toBe(0);
}, },
); );
+2 -1
View File
@@ -3,9 +3,10 @@ import { AuthModule } from "../auth/auth.module.js";
import { DatabaseModule } from "../database/database.module.js"; import { DatabaseModule } from "../database/database.module.js";
import { BottleController } from "./bottle.controller.js"; import { BottleController } from "./bottle.controller.js";
import { BottleService } from "./bottle.service.js"; import { BottleService } from "./bottle.service.js";
import { SafetyModule } from "../safety/safety.module.js";
@Module({ @Module({
imports: [AuthModule, DatabaseModule], imports: [AuthModule, DatabaseModule, SafetyModule],
controllers: [BottleController], controllers: [BottleController],
providers: [BottleService], providers: [BottleService],
}) })
+11 -8
View File
@@ -5,20 +5,19 @@ import { Prisma } from "@prisma/client";
import { DomainException } from "../common/domain.exception.js"; import { DomainException } from "../common/domain.exception.js";
import { PrismaService } from "../database/prisma.service.js"; import { PrismaService } from "../database/prisma.service.js";
import { utc8UsageDate } from "./usage-date.js"; import { utc8UsageDate } from "./usage-date.js";
import { SafetyLockService } from "../safety/safety-lock.service.js";
@Injectable() @Injectable()
export class BottleService { export class BottleService {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(SafetyLockService) private readonly locks: SafetyLockService,
) {}
async create(authorId: string, requestId: string, contentText: string) { async create(authorId: string, requestId: string, contentText: string) {
const existing = await this.prisma.bottle.findUnique({
where: {
authorId_clientRequestId: { authorId, clientRequestId: requestId },
},
});
if (existing) return this.resolveIdempotent(existing, contentText);
try { try {
return await this.prisma.$transaction(async (tx) => { return await this.prisma.$transaction(async (tx) => {
await this.locks.lockAccounts(tx, [authorId]);
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`${authorId}:${requestId}`}, 0))`; await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`${authorId}:${requestId}`}, 0))`;
const duplicate = await tx.bottle.findUnique({ const duplicate = await tx.bottle.findUnique({
where: { where: {
@@ -27,6 +26,10 @@ export class BottleService {
}); });
if (duplicate) return this.resolveIdempotent(duplicate, contentText); if (duplicate) return this.resolveIdempotent(duplicate, contentText);
const now = new Date(); const now = new Date();
const account = await tx.account.findUnique({
where: { id: authorId },
select: { status: true },
});
const sanctioned = await tx.sanction.findFirst({ const sanctioned = await tx.sanction.findFirst({
where: { where: {
accountId: authorId, accountId: authorId,
@@ -37,7 +40,7 @@ export class BottleService {
}, },
select: { id: true }, select: { id: true },
}); });
if (sanctioned) if (account?.status !== "ACTIVE" || sanctioned)
throw new DomainException( throw new DomainException(
ErrorCode.ACCOUNT_SANCTIONED, ErrorCode.ACCOUNT_SANCTIONED,
"Account sanctioned", "Account sanctioned",
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { RedisService } from "../redis/redis.service.js";
import { ChatRateLimiter } from "./chat-rate-limiter.js";
type FakeRedisClient = {
isReady: boolean;
isOpen: boolean;
on: ReturnType<typeof vi.fn>;
connect: ReturnType<typeof vi.fn>;
eval: ReturnType<typeof vi.fn>;
destroy: ReturnType<typeof vi.fn>;
quit: ReturnType<typeof vi.fn>;
};
function fakeClient(evalResult: Promise<unknown>): FakeRedisClient {
return {
isReady: true,
isOpen: true,
on: vi.fn(),
connect: vi.fn(() => Promise.resolve()),
eval: vi.fn(() => evalResult),
destroy: vi.fn(),
quit: vi.fn(() => Promise.resolve()),
};
}
describe("ChatRateLimiter Redis command deadline", () => {
const previousTimeout = process.env.REDIS_OPERATION_TIMEOUT_MS;
afterEach(() => {
if (previousTimeout === undefined)
delete process.env.REDIS_OPERATION_TIMEOUT_MS;
else process.env.REDIS_OPERATION_TIMEOUT_MS = previousTimeout;
});
it("destroys a client whose eval never settles and recovers with a new client", async () => {
process.env.REDIS_OPERATION_TIMEOUT_MS = "20";
const stuck = fakeClient(new Promise(() => undefined));
const healthy = fakeClient(Promise.resolve(1));
const factory = vi
.fn()
.mockReturnValueOnce(stuck)
.mockReturnValueOnce(healthy);
const redis = new RedisService(factory as never);
const limiter = new ChatRateLimiter(redis);
await expect(limiter.consume("account", "session")).rejects.toThrow(
"Auth Redis operation timed out",
);
expect(stuck.destroy).toHaveBeenCalledOnce();
await expect(limiter.consume("account", "session")).resolves.toBe(true);
expect(healthy.eval).toHaveBeenCalledOnce();
expect(factory).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,23 @@
import { Inject, Injectable } from "@nestjs/common";
import { RedisService } from "../redis/redis.service.js";
@Injectable()
export class ChatRateLimiter {
constructor(@Inject(RedisService) private readonly redis: RedisService) {}
async consume(accountId: string, sessionId: string): Promise<boolean> {
const prefix = process.env.REDIS_KEY_PREFIX ?? "drift:auth:";
const max = Number(process.env.CHAT_MESSAGE_RATE_LIMIT ?? 30);
const ttl = Number(process.env.CHAT_MESSAGE_RATE_WINDOW_SECONDS ?? 60);
const keys = [
`${prefix}chat:account:${accountId}`,
`${prefix}chat:session:${sessionId}`,
];
const result = await this.redis.executeWithDeadline((client) =>
client.eval(
`for _,k in ipairs(KEYS) do if tonumber(redis.call('GET',k) or '0')+1>tonumber(ARGV[1]) then return 0 end end; for _,k in ipairs(KEYS) do local n=redis.call('INCR',k); if n==1 then redis.call('EXPIRE',k,ARGV[2]) end end; return 1`,
{ keys, arguments: [String(max), String(ttl)] },
),
);
return Number(result) === 1;
}
}
+248
View File
@@ -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,
);
}
}
+42
View File
@@ -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,
);
}
}
+18 -1
View File
@@ -25,6 +25,21 @@ import { PrismaService } from "../database/prisma.service.js";
import { bootstrap, configureApp } from "../main.js"; import { bootstrap, configureApp } from "../main.js";
import { HealthService } from "./health.service.js"; import { HealthService } from "./health.service.js";
process.env.NODE_ENV = "test";
process.env.WEB_ORIGIN = "http://localhost:3000";
process.env.PHONE_ENCRYPTION_KEY =
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
process.env.PHONE_HMAC_KEY = "health-phone-hmac-key-with-at-least-32-bytes";
process.env.VERIFICATION_CODE_HMAC_KEY =
"health-code-hmac-key-with-at-least-32-bytes";
process.env.REFRESH_TOKEN_HMAC_KEY =
"health-refresh-hmac-key-with-at-least-32-bytes";
process.env.LEASE_TOKEN_HMAC_KEY =
"health-lease-hmac-key-with-at-least-32-bytes";
process.env.LEASE_TOKEN_ENCRYPTION_KEY =
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=";
process.env.JWT_SECRET = "health-jwt-secret-with-at-least-thirty-two-bytes";
type RedisClientDouble = EventEmitter & { type RedisClientDouble = EventEmitter & {
isOpen: boolean; isOpen: boolean;
connect: ReturnType<typeof vi.fn>; connect: ReturnType<typeof vi.fn>;
@@ -375,7 +390,9 @@ describe("API infrastructure", () => {
const previousUrl = process.env.REDIS_URL; const previousUrl = process.env.REDIS_URL;
const previousTimeout = process.env.REDIS_PROBE_TIMEOUT_MS; const previousTimeout = process.env.REDIS_PROBE_TIMEOUT_MS;
process.env.REDIS_URL = "redis://127.0.0.1:1"; process.env.REDIS_URL = "redis://127.0.0.1:1";
process.env.REDIS_PROBE_TIMEOUT_MS = "100"; // A real reconnect plus PING can exceed 100 ms under full-suite load.
// Keep the strict deadline in the dedicated never-answering-connection test.
process.env.REDIS_PROBE_TIMEOUT_MS = "1000";
const warning = vi const warning = vi
.spyOn(Logger.prototype, "warn") .spyOn(Logger.prototype, "warn")
.mockImplementation(() => undefined); .mockImplementation(() => undefined);
@@ -0,0 +1,74 @@
import { randomUUID } from "node:crypto";
import { Injectable } from "@nestjs/common";
import type { Prisma } from "@prisma/client";
export type Candidate = {
id: string;
version: number;
authorId: string;
contentText: string;
publicId: string;
nickname: string;
avatarColor: string;
bio: string | null;
};
@Injectable()
export class CandidateRepository {
async findBatch(
tx: Prisma.TransactionClient,
pickerId: string,
limit = 32,
pivot: string = randomUUID(),
) {
const after = await tx.$queryRaw<Candidate[]>`
SELECT b."id", b."version", b."author_id" AS "authorId",
b."content_text" AS "contentText", p."public_id" AS "publicId",
p."nickname", p."avatar_color" AS "avatarColor", p."bio"
FROM "bottles" b
JOIN "anonymous_profiles" p ON p."account_id" = b."author_id"
WHERE b."id" >= ${pivot}::uuid
AND b."author_id" <> ${pickerId}::uuid
AND b."review_status" = 'APPROVED'
AND b."pool_status" = 'IN_POOL'
AND b."active_lease_id" IS NULL
AND p."review_status" = 'APPROVED'
AND NOT EXISTS (
SELECT 1 FROM "bottle_pick_history" h
WHERE h."bottle_id" = b."id" AND h."picker_id" = ${pickerId}::uuid
)
AND NOT EXISTS (
SELECT 1 FROM "blocks" x
WHERE (x."blocker_id" = ${pickerId}::uuid AND x."blocked_id" = b."author_id")
OR (x."blocker_id" = b."author_id" AND x."blocked_id" = ${pickerId}::uuid)
)
ORDER BY b."id"
LIMIT ${limit}`;
const remaining = limit - after.length;
if (remaining <= 0) return after;
const before = await tx.$queryRaw<Candidate[]>`
SELECT b."id", b."version", b."author_id" AS "authorId",
b."content_text" AS "contentText", p."public_id" AS "publicId",
p."nickname", p."avatar_color" AS "avatarColor", p."bio"
FROM "bottles" b
JOIN "anonymous_profiles" p ON p."account_id" = b."author_id"
WHERE b."id" < ${pivot}::uuid
AND b."author_id" <> ${pickerId}::uuid
AND b."review_status" = 'APPROVED'
AND b."pool_status" = 'IN_POOL'
AND b."active_lease_id" IS NULL
AND p."review_status" = 'APPROVED'
AND NOT EXISTS (
SELECT 1 FROM "bottle_pick_history" h
WHERE h."bottle_id" = b."id" AND h."picker_id" = ${pickerId}::uuid
)
AND NOT EXISTS (
SELECT 1 FROM "blocks" x
WHERE (x."blocker_id" = ${pickerId}::uuid AND x."blocked_id" = b."author_id")
OR (x."blocker_id" = b."author_id" AND x."blocked_id" = ${pickerId}::uuid)
)
ORDER BY b."id"
LIMIT ${remaining}`;
return [...after, ...before];
}
}
+6
View File
@@ -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");
});
});
+116
View File
@@ -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";
}
+58
View File
@@ -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",
);
+611
View File
@@ -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");
});
});
+14
View File
@@ -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 {}
+187
View File
@@ -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]]);
});
});
+479
View File
@@ -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 },
});
});
}
}
+3
View File
@@ -18,6 +18,9 @@ process.env.PHONE_ENCRYPTION_KEY =
process.env.PHONE_HMAC_KEY = "test-phone-hmac-key-with-at-least-32-bytes"; process.env.PHONE_HMAC_KEY = "test-phone-hmac-key-with-at-least-32-bytes";
process.env.VERIFICATION_CODE_HMAC_KEY = process.env.VERIFICATION_CODE_HMAC_KEY =
"test-code-hmac-key-with-at-least-32-bytes"; "test-code-hmac-key-with-at-least-32-bytes";
process.env.LEASE_TOKEN_HMAC_KEY = "test-lease-hmac-key-with-at-least-32-bytes";
process.env.LEASE_TOKEN_ENCRYPTION_KEY =
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=";
process.env.JWT_SECRET = "test-jwt-secret-with-at-least-thirty-two-bytes"; process.env.JWT_SECRET = "test-jwt-secret-with-at-least-thirty-two-bytes";
process.env.REFRESH_TOKEN_HMAC_KEY = process.env.REFRESH_TOKEN_HMAC_KEY =
"test-refresh-hmac-key-with-at-least-32-bytes"; "test-refresh-hmac-key-with-at-least-32-bytes";
+21
View File
@@ -74,6 +74,27 @@ export class RedisService implements OnModuleInit, OnModuleDestroy {
} }
} }
/**
* Runs an already-connected Redis command under the same hard deadline as
* connect/quit. On timeout the exact client generation is destroyed, which
* rejects queued commands and lets the next call create a fresh client.
* Callers must create the command from the provided client: retaining
* `redis.client` across calls would bypass generation-safe replacement.
*/
async executeWithDeadline<T>(
operation: (client: RedisClient) => Promise<T>,
): Promise<T> {
await this.ensureConnected();
const client = this.client;
const generation = this.generation;
try {
return await this.withDeadline(client, generation, operation(client));
} catch (error) {
this.destroyClient(client, generation);
throw error;
}
}
async onModuleInit(): Promise<void> { async onModuleInit(): Promise<void> {
/* Auth Redis connects lazily so unrelated modules remain isolated. */ /* Auth Redis connects lazily so unrelated modules remain isolated. */
} }
+53
View File
@@ -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)
)
`;
}
}
}
+79
View File
@@ -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",
);
+15
View File
@@ -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 {}
+339
View File
@@ -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,
);
}
}
+17
View File
@@ -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>
+31
View File
@@ -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"
}
}
+1
View File
@@ -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

+16
View File
@@ -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"
}
]
}
+15
View File
@@ -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>
+195
View File
@@ -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();
});
});
+142
View File
@@ -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 : "网络连接失败",
);
}
+185
View File
@@ -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);
});
});
+97
View File
@@ -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);
});
}
+15
View File
@@ -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>
+18
View File
@@ -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);
});
});
+38
View File
@@ -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>
+21
View File
@@ -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>
+10
View File
@@ -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;
}
+12
View File
@@ -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");
+63
View File
@@ -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();
});
});
+88
View File
@@ -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;
}
+53
View File
@@ -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);
});
});
+78
View File
@@ -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;
}
+174
View File
@@ -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({});
});
});
+138
View File
@@ -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,
};
});
+33
View File
@@ -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]);
});
});
+31
View File
@@ -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,
);
}
+545
View File
@@ -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);
}
}
}
+1
View File
@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";
+91
View File
@@ -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>
+133
View File
@@ -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>
+62
View File
@@ -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>
+139
View File
@@ -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>
+48
View File
@@ -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>
+38
View File
@@ -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>
+90
View File
@@ -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>
+48
View File
@@ -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>
+83
View File
@@ -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>
+81
View File
@@ -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>
+28
View File
@@ -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(
"操作冲突:该项目可能已被处理,请刷新后重试",
);
});
});
+37
View File
@@ -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 };
}
+16
View File
@@ -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"
]
}
+61
View File
@@ -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" },
},
},
},
});
+11
View File
@@ -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,
},
});
+40
View File
@@ -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;
});
}
}
+125
View File
@@ -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);
});
});
+24 -1
View File
@@ -1,7 +1,30 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { handleTopLevelError, logTopLevelError, runWorker } from "./main.js"; import {
createCombinedWorker,
handleTopLevelError,
logTopLevelError,
runWorker,
} from "./main.js";
describe("worker lifecycle", () => { describe("worker lifecycle", () => {
it("runs both reaping and moderation every iteration without starvation", async () => {
const reaper = { runOnce: vi.fn().mockResolvedValue(true) };
const moderation = { runOnce: vi.fn().mockResolvedValue(false) };
const worker = createCombinedWorker(reaper, moderation);
await expect(worker.runOnce()).resolves.toBe(true);
expect(reaper.runOnce).toHaveBeenCalledTimes(1);
expect(moderation.runOnce).toHaveBeenCalledTimes(1);
});
it("invokes reaping, moderation and notification each iteration", async () => {
const reaper = { runOnce: vi.fn().mockResolvedValue(true) };
const moderation = { runOnce: vi.fn().mockResolvedValue(false) };
const notification = { runOnce: vi.fn().mockResolvedValue(true) };
const worker = createCombinedWorker(reaper, moderation, notification);
await expect(worker.runOnce()).resolves.toBe(true);
expect(notification.runOnce).toHaveBeenCalledTimes(1);
});
it("waits for the current run before disconnecting on SIGTERM", async () => { it("waits for the current run before disconnecting on SIGTERM", async () => {
let finish!: () => void; let finish!: () => void;
const runOnce = vi.fn( const runOnce = vi.fn(
+23 -1
View File
@@ -1,6 +1,8 @@
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import { ModerationWorker } from "./moderation-worker.js"; import { ModerationWorker } from "./moderation-worker.js";
import { LeaseReaper } from "./lease-reaper.processor.js";
import { NotificationProcessor } from "./notification.processor.js";
type SignalSource = { type SignalSource = {
once(signal: "SIGTERM" | "SIGINT", listener: () => void): unknown; once(signal: "SIGTERM" | "SIGINT", listener: () => void): unknown;
@@ -16,6 +18,23 @@ type RunWorkerOptions = {
signals?: SignalSource; signals?: SignalSource;
}; };
type Worker = { runOnce(): Promise<boolean> };
export function createCombinedWorker(
reaper: Worker,
moderation: Worker,
notification?: Worker,
): Worker {
return {
async runOnce() {
const reaped = await reaper.runOnce();
const moderated = await moderation.runOnce();
const notified = notification ? await notification.runOnce() : false;
return reaped || moderated || notified;
},
};
}
export async function runWorker(options: RunWorkerOptions) { export async function runWorker(options: RunWorkerOptions) {
const signals = options.signals ?? process; const signals = options.signals ?? process;
const sleep = const sleep =
@@ -47,7 +66,10 @@ export async function runWorker(options: RunWorkerOptions) {
export async function main() { export async function main() {
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const worker = new ModerationWorker(prisma); const moderation = new ModerationWorker(prisma);
const reaper = new LeaseReaper(prisma);
const notification = new NotificationProcessor(prisma);
const worker = createCombinedWorker(reaper, moderation, notification);
await runWorker({ await runWorker({
worker, worker,
connect: () => prisma.$connect(), connect: () => prisma.$connect(),
+104 -2
View File
@@ -39,6 +39,42 @@ describe("moderation worker with real PostgreSQL", () => {
}, },
); );
it.each([
["PENDING", null, 0],
["PROCESSING", new Date(Date.now() - 60_000), 5],
] as const)(
"leaves %s MESSAGE_CREATED for the API relay",
async (status, lockedAt, attempts) => {
await prisma.account.create({
data: {
phoneCiphertext: Buffer.from("cipher"),
phoneHmac: randomUUID(),
},
});
const event = await prisma.outboxEvent.create({
data: {
aggregateType: "MESSAGE",
aggregateId: randomUUID(),
eventType: "MESSAGE_CREATED",
dedupeKey: randomUUID(),
payload: {
messageId: randomUUID(),
conversationId: randomUUID(),
},
status,
lockedAt,
lockToken: lockedAt ? randomUUID() : null,
attempts,
},
});
expect(await new ModerationWorker(prisma).runOnce()).toBe(false);
expect(
await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }),
).toMatchObject({ status, attempts, lockedAt });
},
);
it("is fail-closed and exponentially reschedules failures", async () => { it("is fail-closed and exponentially reschedules failures", async () => {
const { bottle, event } = await fixture("ordinary"); const { bottle, event } = await fixture("ordinary");
const worker = new ModerationWorker(prisma, () => { const worker = new ModerationWorker(prisma, () => {
@@ -282,7 +318,12 @@ describe("moderation worker with real PostgreSQL", () => {
expect(decide).not.toHaveBeenCalled(); expect(decide).not.toHaveBeenCalled();
expect( expect(
await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }), await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }),
).toMatchObject({ status: "FAILED", attempts: 5, lockedAt: null }); ).toMatchObject({
status: "FAILED",
attempts: 5,
lockToken: null,
lockedAt: null,
});
}); });
it.each([ it.each([
@@ -349,7 +390,11 @@ describe("moderation worker with real PostgreSQL", () => {
expect(decide).toHaveBeenCalledTimes(1); expect(decide).toHaveBeenCalledTimes(1);
expect( expect(
await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }), await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }),
).toMatchObject({ status: "PUBLISHED" }); ).toMatchObject({
status: "PUBLISHED",
lockToken: null,
lockedAt: null,
});
expect( expect(
await prisma.anonymousProfile.findUniqueOrThrow({ await prisma.anonymousProfile.findUniqueOrThrow({
where: { id: profile.id }, where: { id: profile.id },
@@ -360,6 +405,32 @@ describe("moderation worker with real PostgreSQL", () => {
).toMatchObject({ status: "PENDING", decision: null }); ).toMatchObject({ status: "PENDING", decision: null });
}); });
it("fails a same-version profile event whose task hash drifted", async () => {
const { profile, event, task } = await profileFixture("ordinary", "first");
await prisma.moderationTask.update({
where: { id: task.id },
data: { payloadHash: "0".repeat(64) },
});
expect(await new ModerationWorker(prisma).runOnce()).toBe(true);
expect(
await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }),
).toMatchObject({
status: "FAILED",
attempts: 1,
lockToken: null,
lockedAt: null,
});
expect(
await prisma.anonymousProfile.findUniqueOrThrow({
where: { id: profile.id },
}),
).toMatchObject({ version: 1, reviewStatus: "REVIEWING" });
expect(
await prisma.moderationTask.findUniqueOrThrow({ where: { id: task.id } }),
).toMatchObject({ status: "PENDING", decision: null });
});
it("fails a replay when the completed profile task drifted from current state", async () => { it("fails a replay when the completed profile task drifted from current state", async () => {
const { profile, event, task } = await profileFixture("ordinary", "first"); const { profile, event, task } = await profileFixture("ordinary", "first");
await prisma.moderationTask.update({ await prisma.moderationTask.update({
@@ -387,6 +458,37 @@ describe("moderation worker with real PostgreSQL", () => {
).toMatchObject({ status: "COMPLETED", decision: "APPROVED" }); ).toMatchObject({ status: "COMPLETED", decision: "APPROVED" });
}); });
it("fails a completed profile task with a non-terminal reviewing decision", async () => {
const { profile, event, task } = await profileFixture("ordinary", "first");
await prisma.moderationTask.update({
where: { id: task.id },
data: {
status: "COMPLETED",
decision: "REVIEWING",
result: { decision: "REVIEWING" },
reviewedAt: new Date(),
},
});
expect(await new ModerationWorker(prisma).runOnce()).toBe(true);
expect(
await prisma.outboxEvent.findUniqueOrThrow({ where: { id: event.id } }),
).toMatchObject({
status: "FAILED",
attempts: 1,
lockToken: null,
lockedAt: null,
});
expect(
await prisma.anonymousProfile.findUniqueOrThrow({
where: { id: profile.id },
}),
).toMatchObject({ reviewStatus: "REVIEWING" });
expect(
await prisma.moderationTask.findUniqueOrThrow({ where: { id: task.id } }),
).toMatchObject({ status: "COMPLETED", decision: "REVIEWING" });
});
it("logs a safe error category without payload or exception message", async () => { it("logs a safe error category without payload or exception message", async () => {
const { event } = await fixture("secret正文"); const { event } = await fixture("secret正文");
const entries: unknown[] = []; const entries: unknown[] = [];
+14 -10
View File
@@ -269,10 +269,12 @@ export class ModerationWorker {
profile.bio, profile.bio,
]); ]);
const hash = createHash("sha256").update(text).digest("hex"); const hash = createHash("sha256").update(text).digest("hex");
if (profile.version !== version || task.payloadHash !== hash) { if (profile.version !== version) {
await this.prisma.$transaction((tx) => this.publish(tx, event)); await this.prisma.$transaction((tx) => this.publish(tx, event));
return; return;
} }
if (task.payloadHash !== hash)
throw new Error("profile moderation payload drift");
if (task.status === "COMPLETED") { if (task.status === "COMPLETED") {
if (!this.completedProfileMatches(profile, task, hash, version)) if (!this.completedProfileMatches(profile, task, hash, version))
throw new Error("completed profile moderation state drift"); throw new Error("completed profile moderation state drift");
@@ -294,12 +296,8 @@ export class ModerationWorker {
const currentHash = createHash("sha256") const currentHash = createHash("sha256")
.update(currentText) .update(currentText)
.digest("hex"); .digest("hex");
if ( if (currentProfile.version !== version) return;
currentProfile.version !== version || if (currentHash !== hash || currentTask.payloadHash !== currentHash)
currentTask.payloadHash !== hash
)
return;
if (currentHash !== hash)
throw new Error("profile moderation payload drift"); throw new Error("profile moderation payload drift");
if (currentTask.status === "COMPLETED") { if (currentTask.status === "COMPLETED") {
if ( if (
@@ -333,12 +331,15 @@ export class ModerationWorker {
hash: string, hash: string,
version: number, version: number,
) { ) {
const decision = task.decision;
return ( return (
profile.version === version && profile.version === version &&
task.payloadHash === hash && task.payloadHash === hash &&
task.decision !== null && (decision === "APPROVED" ||
resultDecision(task.result) === task.decision && decision === "REJECTED" ||
profile.reviewStatus === task.decision decision === "MANUAL_REVIEW") &&
resultDecision(task.result) === decision &&
profile.reviewStatus === decision
); );
} }
@@ -381,6 +382,9 @@ export class ModerationWorker {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
await tx.outboxEvent.updateMany({ await tx.outboxEvent.updateMany({
where: { where: {
eventType: {
in: ["BOTTLE_MODERATION_REQUESTED", "PROFILE_MODERATION_REQUESTED"],
},
status: "PROCESSING", status: "PROCESSING",
lockedAt: { lt: staleBefore }, lockedAt: { lt: staleBefore },
attempts: { gte: this.maxAttempts }, attempts: { gte: this.maxAttempts },
@@ -0,0 +1,132 @@
import { randomUUID } from "node:crypto";
import { PrismaClient } from "@prisma/client";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { assertSafeTestDatabaseUrl } from "../../../prisma/database-safety.js";
import { NotificationProcessor } from "./notification.processor.js";
const prisma = new PrismaClient();
describe("NotificationProcessor", () => {
beforeAll(async () => {
assertSafeTestDatabaseUrl(process.env.DATABASE_URL ?? "");
await prisma.$connect();
});
afterAll(async () => prisma.$disconnect());
it("lets a preference disable committed before worker delivery win the race", async () => {
const account = await prisma.account.create({
data: { phoneCiphertext: Buffer.from("race"), phoneHmac: randomUUID() },
});
const preference = await prisma.pushPreference.create({
data: { accountId: account.id, inAppEnabled: true },
});
const notification = await prisma.notification.create({
data: {
accountId: account.id,
dedupeKey: randomUUID(),
type: "TEST",
payload: {},
},
});
await prisma.notification.updateMany({
where: { status: "PENDING", id: { not: notification.id } },
data: { status: "FAILED" },
});
let release!: () => void;
let locked!: () => void;
const lockHeld = new Promise<void>((resolve) => {
locked = resolve;
});
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const disabling = prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT "account_id" FROM "push_preferences" WHERE "account_id"=${preference.accountId}::uuid FOR UPDATE`;
locked();
await gate;
await tx.pushPreference.update({
where: { accountId: account.id },
data: { inAppEnabled: false },
});
});
try {
await lockHeld;
const delivery = new NotificationProcessor(prisma).runOnce();
// Wait until the worker holds the notification lock and has reached the
// preference boundary; an unlocked preference would expose stale true.
let claimed = false;
for (let attempt = 0; attempt < 100 && !claimed; attempt += 1) {
try {
await prisma.$transaction(async (probe) => {
await probe.$queryRaw`SELECT "id" FROM "notifications" WHERE "id"=${notification.id}::uuid FOR UPDATE NOWAIT`;
});
} catch {
claimed = true;
}
if (!claimed) await new Promise((resolve) => setTimeout(resolve, 20));
}
expect(claimed).toBe(true);
release();
await disabling;
expect(await delivery).toBe(true);
expect(
(
await prisma.notification.findUniqueOrThrow({
where: { id: notification.id },
})
).status,
).toBe("FAILED");
} finally {
release();
await disabling;
await prisma.account.delete({ where: { id: account.id } });
}
});
it("delivers a pending in-app notification once and skips disabled preferences", async () => {
const account = await prisma.account.create({
data: { phoneCiphertext: Buffer.from("test"), phoneHmac: randomUUID() },
});
try {
const enabled = await prisma.notification.create({
data: {
accountId: account.id,
type: "TEST",
dedupeKey: randomUUID(),
payload: { safe: true },
},
});
await prisma.notification.updateMany({
where: { status: "PENDING", id: { not: enabled.id } },
data: { status: "FAILED" },
});
const worker = new NotificationProcessor(prisma);
expect(await worker.runOnce()).toBe(true);
const delivered = await prisma.notification.findUniqueOrThrow({
where: { id: enabled.id },
});
expect(delivered).toMatchObject({ status: "SENT", readAt: null });
expect(delivered.sentAt).toBeInstanceOf(Date);
expect(await worker.runOnce()).toBe(false);
await prisma.pushPreference.create({
data: { accountId: account.id, inAppEnabled: false },
});
const disabled = await prisma.notification.create({
data: {
accountId: account.id,
type: "TEST",
dedupeKey: randomUUID(),
payload: { safe: true },
},
});
expect(await worker.runOnce()).toBe(true);
expect(
await prisma.notification.findUniqueOrThrow({
where: { id: disabled.id },
}),
).toMatchObject({ status: "FAILED", sentAt: null });
} finally {
await prisma.account.delete({ where: { id: account.id } });
}
});
});
+38
View File
@@ -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
View File
@@ -6,7 +6,9 @@
"scripts": { "scripts": {
"test": "vitest run --no-file-parallelism", "test": "vitest run --no-file-parallelism",
"test:database": "vitest run tests/integration/database.spec.ts", "test:database": "vitest run tests/integration/database.spec.ts",
"test:integration": "vitest run tests/integration --no-file-parallelism", "test:integration": "vitest run tests/integration --no-file-parallelism && corepack pnpm --filter @drift/api exec vitest run --config vitest.config.ts src/safety/safety-admin.e2e-spec.ts --no-file-parallelism",
"test:security": "corepack pnpm --filter @drift/api exec vitest run --config vitest.config.ts src/safety/safety-admin.e2e-spec.ts src/auth/auth.e2e-spec.ts src/conversation/conversation.e2e-spec.ts --no-file-parallelism",
"test:e2e": "playwright test",
"typecheck": "tsc --noEmit -p tsconfig.base.json", "typecheck": "tsc --noEmit -p tsconfig.base.json",
"lint": "eslint . --ext .ts --max-warnings 0 && prettier --check .", "lint": "eslint . --ext .ts --max-warnings 0 && prettier --check .",
"build": "corepack pnpm prisma generate && corepack pnpm --recursive run build", "build": "corepack pnpm prisma generate && corepack pnpm --recursive run build",
@@ -25,6 +27,7 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.63.0",
"@types/node": "22.19.3", "@types/node": "22.19.3",
"@typescript-eslint/eslint-plugin": "^8.18.0", "@typescript-eslint/eslint-plugin": "^8.18.0",
"@typescript-eslint/parser": "^8.18.0", "@typescript-eslint/parser": "^8.18.0",
+46
View File
@@ -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"],
},
},
},
],
});
+5469 -2585
View File
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