feat(s1): add app foundation

This commit is contained in:
zizi 2026-06-02 20:50:36 +08:00
parent 6e363ae351
commit bba5e13a73
99 changed files with 42338 additions and 0 deletions

17
.gitignore vendored Normal file
View File

@ -0,0 +1,17 @@
node_modules/
.pnpm-store/
dist/
build/
coverage/
.next/
.turbo/
.vite/
.env
.env.*
!.env.example
.idea/
*.log
.DS_Store

5
apps/api/.env.example Normal file
View File

@ -0,0 +1,5 @@
DATABASE_URL=postgresql://huijing:huijing@localhost:5432/huijing_dev?schema=public
REDIS_URL=redis://localhost:6379
QUEUE_ADAPTER=bullmq
S1_STORAGE_ROOT=.local/storage
HARNESS_CLI_TIMEOUT_MS=5000

33
apps/api/package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "@huijing/api",
"private": true,
"type": "module",
"scripts": {
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"build": "tsc -p tsconfig.json && node -e \"await import('./dist/main.js')\"",
"dev:smoke": "vitest run",
"prisma": "prisma"
},
"dependencies": {
"@huijing/harness-client": "workspace:*",
"@nestjs/common": "^11.1.24",
"@nestjs/core": "^11.1.24",
"@nestjs/platform-express": "^11.1.24",
"@prisma/adapter-pg": "7.8.0",
"@prisma/client": "7.8.0",
"bullmq": "^5.77.6",
"ioredis": "^5.11.0",
"pg": "^8.21.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2"
},
"devDependencies": {
"@nestjs/testing": "^11.1.24",
"@types/node": "^25.9.1",
"@types/pg": "^8.20.0",
"prisma": "^7.8.0",
"vitest": "^4.1.7"
}
}

11
apps/api/prisma.config.ts Normal file
View File

@ -0,0 +1,11 @@
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations"
},
datasource: {
url: process.env.DATABASE_URL ?? "postgresql://huijing:huijing@localhost:5432/huijing_dev?schema=public"
}
});

View File

@ -0,0 +1,493 @@
-- CreateEnum
CREATE TYPE "UserStatus" AS ENUM ('active', 'disabled');
-- CreateEnum
CREATE TYPE "UserRoleName" AS ENUM ('admin', 'operator', 'creator', 'player');
-- CreateEnum
CREATE TYPE "ProjectStatus" AS ENUM ('active', 'archived');
-- CreateEnum
CREATE TYPE "GameVersionStatus" AS ENUM ('draft', 'candidate', 'active', 'archived', 'failed');
-- CreateEnum
CREATE TYPE "AssetStatus" AS ENUM ('uploaded', 'deleted');
-- CreateEnum
CREATE TYPE "JobStatus" AS ENUM ('queued', 'running', 'succeeded', 'pending_retry', 'failed', 'canceled');
-- CreateEnum
CREATE TYPE "JobTargetType" AS ENUM ('project', 'version');
-- CreateEnum
CREATE TYPE "MainCreationAgentSessionStatus" AS ENUM ('routing_internal_tasks', 'completed', 'failed', 'canceled');
-- CreateEnum
CREATE TYPE "AgentTaskType" AS ENUM ('requirement_clarifier', 'game_design_draft_generator');
-- CreateEnum
CREATE TYPE "AgentTaskStatus" AS ENUM ('queued', 'running', 'succeeded', 'failed', 'canceled', 'timed_out');
-- CreateEnum
CREATE TYPE "ReviewRecordStatus" AS ENUM ('pending_review', 'approved', 'rejected', 'canceled');
-- CreateEnum
CREATE TYPE "ReviewDecision" AS ENUM ('approved', 'rejected');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT,
"displayName" TEXT NOT NULL,
"status" "UserStatus" NOT NULL DEFAULT 'active',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserRole" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"role" "UserRoleName" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UserRole_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AnonymousIdentity" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"deviceKey" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AnonymousIdentity_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GameProject" (
"id" TEXT NOT NULL,
"ownerId" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"title" TEXT NOT NULL,
"status" "ProjectStatus" NOT NULL DEFAULT 'active',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GameProject_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "GameVersion" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"versionNumber" INTEGER NOT NULL,
"status" "GameVersionStatus" NOT NULL,
"configJson" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GameVersion_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Asset" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"storageKey" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"byteSize" INTEGER NOT NULL,
"sha256" TEXT NOT NULL,
"status" "AssetStatus" NOT NULL DEFAULT 'uploaded',
"metadataJson" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Asset_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Job" (
"id" TEXT NOT NULL,
"actorId" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"idempotencyKey" TEXT NOT NULL,
"status" "JobStatus" NOT NULL DEFAULT 'queued',
"attempts" INTEGER NOT NULL DEFAULT 0,
"maxAttempts" INTEGER NOT NULL DEFAULT 3,
"timeoutAt" TIMESTAMP(3),
"nextRetryAt" TIMESTAMP(3),
"errorCode" TEXT,
"targetType" "JobTargetType" NOT NULL,
"targetId" TEXT NOT NULL,
"targetScopeKey" TEXT NOT NULL,
"gameProjectId" TEXT,
"gameVersionId" TEXT,
"payloadJson" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Job_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"actorId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"targetType" TEXT NOT NULL,
"targetId" TEXT NOT NULL,
"eventJson" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MainCreationAgentSession" (
"id" TEXT NOT NULL,
"creatorId" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"versionId" TEXT NOT NULL,
"status" "MainCreationAgentSessionStatus" NOT NULL,
"contextSummary" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MainCreationAgentSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AgentTask" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"taskType" "AgentTaskType" NOT NULL,
"subagentId" TEXT NOT NULL,
"inputRef" TEXT NOT NULL,
"outputRef" TEXT,
"status" "AgentTaskStatus" NOT NULL,
"timeoutAt" TIMESTAMP(3) NOT NULL,
"errorCode" TEXT,
"auditLogId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AgentTask_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ReviewRecord" (
"id" TEXT NOT NULL,
"gameVersionId" TEXT NOT NULL,
"status" "ReviewRecordStatus" NOT NULL,
"decision" "ReviewDecision",
"reasonCode" TEXT,
"decidedById" TEXT,
"decidedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ReviewRecord_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LifecycleEvent" (
"eventId" TEXT NOT NULL,
"gameVersionId" TEXT,
"event" TEXT NOT NULL,
"from" TEXT NOT NULL,
"to" TEXT NOT NULL,
"actorJson" JSONB NOT NULL,
"requiredRole" TEXT NOT NULL,
"requiredRecordRefsJson" JSONB NOT NULL,
"auditEvent" TEXT NOT NULL,
"reasonCode" TEXT NOT NULL,
"occurredAt" TIMESTAMP(3) NOT NULL,
"approval" TEXT NOT NULL,
"requiredRecordsJson" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LifecycleEvent_pkey" PRIMARY KEY ("eventId")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "UserRole_role_idx" ON "UserRole"("role");
-- CreateIndex
CREATE UNIQUE INDEX "UserRole_userId_role_key" ON "UserRole"("userId", "role");
-- CreateIndex
CREATE INDEX "AnonymousIdentity_userId_idx" ON "AnonymousIdentity"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "AnonymousIdentity_deviceKey_key" ON "AnonymousIdentity"("deviceKey");
-- CreateIndex
CREATE UNIQUE INDEX "GameProject_slug_key" ON "GameProject"("slug");
-- CreateIndex
CREATE INDEX "GameProject_ownerId_idx" ON "GameProject"("ownerId");
-- CreateIndex
CREATE INDEX "GameVersion_projectId_status_idx" ON "GameVersion"("projectId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "GameVersion_projectId_versionNumber_key" ON "GameVersion"("projectId", "versionNumber");
-- CreateIndex
CREATE UNIQUE INDEX "GameVersion_id_projectId_key" ON "GameVersion"("id", "projectId");
-- CreateIndex
CREATE INDEX "Asset_projectId_kind_idx" ON "Asset"("projectId", "kind");
-- CreateIndex
CREATE UNIQUE INDEX "Asset_projectId_storageKey_key" ON "Asset"("projectId", "storageKey");
-- CreateIndex
CREATE INDEX "Job_projectId_status_idx" ON "Job"("projectId", "status");
-- CreateIndex
CREATE INDEX "Job_actorId_status_idx" ON "Job"("actorId", "status");
-- CreateIndex
CREATE INDEX "Job_gameProjectId_idx" ON "Job"("gameProjectId");
-- CreateIndex
CREATE INDEX "Job_gameVersionId_idx" ON "Job"("gameVersionId");
-- CreateIndex
CREATE UNIQUE INDEX "Job_actorId_projectId_type_targetScopeKey_idempotencyKey_key" ON "Job"("actorId", "projectId", "type", "targetScopeKey", "idempotencyKey");
-- CreateIndex
CREATE INDEX "AuditLog_actorId_createdAt_idx" ON "AuditLog"("actorId", "createdAt");
-- CreateIndex
CREATE INDEX "AuditLog_targetType_targetId_idx" ON "AuditLog"("targetType", "targetId");
-- CreateIndex
CREATE INDEX "MainCreationAgentSession_creatorId_idx" ON "MainCreationAgentSession"("creatorId");
-- CreateIndex
CREATE INDEX "MainCreationAgentSession_projectId_idx" ON "MainCreationAgentSession"("projectId");
-- CreateIndex
CREATE INDEX "MainCreationAgentSession_versionId_idx" ON "MainCreationAgentSession"("versionId");
-- CreateIndex
CREATE INDEX "AgentTask_sessionId_idx" ON "AgentTask"("sessionId");
-- CreateIndex
CREATE INDEX "AgentTask_status_idx" ON "AgentTask"("status");
-- CreateIndex
CREATE INDEX "ReviewRecord_gameVersionId_status_idx" ON "ReviewRecord"("gameVersionId", "status");
-- CreateIndex
CREATE INDEX "ReviewRecord_decidedById_idx" ON "ReviewRecord"("decidedById");
-- CreateIndex
CREATE INDEX "LifecycleEvent_gameVersionId_idx" ON "LifecycleEvent"("gameVersionId");
-- CreateIndex
CREATE INDEX "LifecycleEvent_event_occurredAt_idx" ON "LifecycleEvent"("event", "occurredAt");
-- AddForeignKey
ALTER TABLE "UserRole" ADD CONSTRAINT "UserRole_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AnonymousIdentity" ADD CONSTRAINT "AnonymousIdentity_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GameProject" ADD CONSTRAINT "GameProject_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "GameVersion" ADD CONSTRAINT "GameVersion_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "GameProject"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Asset" ADD CONSTRAINT "Asset_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "GameProject"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Job" ADD CONSTRAINT "Job_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Job" ADD CONSTRAINT "Job_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "GameProject"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Job" ADD CONSTRAINT "Job_gameProjectId_fkey" FOREIGN KEY ("gameProjectId") REFERENCES "GameProject"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Job" ADD CONSTRAINT "Job_gameVersionId_projectId_fkey" FOREIGN KEY ("gameVersionId", "projectId") REFERENCES "GameVersion"("id", "projectId") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MainCreationAgentSession" ADD CONSTRAINT "MainCreationAgentSession_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MainCreationAgentSession" ADD CONSTRAINT "MainCreationAgentSession_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "GameProject"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MainCreationAgentSession" ADD CONSTRAINT "MainCreationAgentSession_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "GameVersion"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AgentTask" ADD CONSTRAINT "AgentTask_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "MainCreationAgentSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReviewRecord" ADD CONSTRAINT "ReviewRecord_gameVersionId_fkey" FOREIGN KEY ("gameVersionId") REFERENCES "GameVersion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReviewRecord" ADD CONSTRAINT "ReviewRecord_decidedById_fkey" FOREIGN KEY ("decidedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LifecycleEvent" ADD CONSTRAINT "LifecycleEvent_gameVersionId_fkey" FOREIGN KEY ("gameVersionId") REFERENCES "GameVersion"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- Task 4 DB invariants: 用命名 CHECK 锁住 Job 的具体目标、幂等作用域和重试计数。
ALTER TABLE "Job" ADD CONSTRAINT "Job_target_xor_check" CHECK (
(
"targetType" = 'project'
AND "gameProjectId" IS NOT NULL
AND "gameVersionId" IS NULL
)
OR (
"targetType" = 'version'
AND "gameVersionId" IS NOT NULL
AND "gameProjectId" IS NULL
)
);
ALTER TABLE "Job" ADD CONSTRAINT "Job_target_id_matches_check" CHECK (
(
"targetType" = 'project'
AND "targetId" = "gameProjectId"
)
OR (
"targetType" = 'version'
AND "targetId" = "gameVersionId"
)
);
ALTER TABLE "Job" ADD CONSTRAINT "Job_project_scope_matches_check" CHECK (
"targetType" <> 'project'
OR "projectId" = "gameProjectId"
);
ALTER TABLE "Job" ADD CONSTRAINT "Job_target_scope_key_check" CHECK (
(
"targetType" = 'project'
AND "targetScopeKey" = ('project:' || "gameProjectId")
)
OR (
"targetType" = 'version'
AND "targetScopeKey" = ('version:' || "gameVersionId")
)
);
ALTER TABLE "Job" ADD CONSTRAINT "Job_non_empty_idempotency_scope_check" CHECK (
length(btrim("idempotencyKey")) > 0
AND length(btrim("targetScopeKey")) > 0
);
ALTER TABLE "Job" ADD CONSTRAINT "Job_retry_counts_check" CHECK (
"attempts" >= 0
AND "maxAttempts" > 0
AND "attempts" <= "maxAttempts"
);
-- AgentTask 只能使用 S0 已确认的 taskType/subagentId 组合。
ALTER TABLE "AgentTask" ADD CONSTRAINT "AgentTask_task_subagent_allowlist_check" CHECK (
(
"taskType" = 'requirement_clarifier'
AND "subagentId" = 'subagent-requirement-clarifier-001'
)
OR (
"taskType" = 'game_design_draft_generator'
AND "subagentId" = 'subagent-game-draft-001'
)
);
-- ReviewRecord 的人工决策事实必须带 reasonCode并且 status 与 decision 保持一致。
ALTER TABLE "ReviewRecord" ADD CONSTRAINT "ReviewRecord_decision_reason_check" CHECK (
"decision" IS NULL
OR (
"reasonCode" IS NOT NULL
AND "status"::text = "decision"::text
)
);
-- 状态边界由后续 state-transition service 在事务内 SET LOCALTask 4 只安装 DB guard。
CREATE OR REPLACE FUNCTION app_require_state_transition_guard()
RETURNS trigger AS $$
BEGIN
IF current_setting('app.state_transition_guard', true) IS DISTINCT FROM 'on' THEN
RAISE EXCEPTION 'state transition guard is required' USING ERRCODE = '42501';
END IF;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "GameVersion_a_guard_status_insert"
BEFORE INSERT ON "GameVersion"
FOR EACH ROW
EXECUTE FUNCTION app_require_state_transition_guard();
CREATE TRIGGER "GameVersion_a_guard_status_update"
BEFORE UPDATE OF "status" ON "GameVersion"
FOR EACH ROW
WHEN (OLD."status" IS DISTINCT FROM NEW."status")
EXECUTE FUNCTION app_require_state_transition_guard();
CREATE TRIGGER "ReviewRecord_a_guard_status_insert"
BEFORE INSERT ON "ReviewRecord"
FOR EACH ROW
EXECUTE FUNCTION app_require_state_transition_guard();
CREATE TRIGGER "ReviewRecord_a_guard_status_update"
BEFORE UPDATE OF "status" ON "ReviewRecord"
FOR EACH ROW
WHEN (OLD."status" IS DISTINCT FROM NEW."status")
EXECUTE FUNCTION app_require_state_transition_guard();
CREATE TRIGGER "LifecycleEvent_a_guard_insert"
BEFORE INSERT ON "LifecycleEvent"
FOR EACH ROW
EXECUTE FUNCTION app_require_state_transition_guard();
CREATE TRIGGER "LifecycleEvent_a_guard_update"
BEFORE UPDATE ON "LifecycleEvent"
FOR EACH ROW
EXECUTE FUNCTION app_require_state_transition_guard();
CREATE TRIGGER "LifecycleEvent_a_guard_delete"
BEFORE DELETE ON "LifecycleEvent"
FOR EACH ROW
EXECUTE FUNCTION app_require_state_transition_guard();
-- AuditLog 是 append-only 事实表,任何 UPDATE/DELETE 都在 DB 层拒绝。
CREATE OR REPLACE FUNCTION app_reject_audit_log_mutation()
RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'AuditLog is append-only' USING ERRCODE = '42501';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "AuditLog_reject_update"
BEFORE UPDATE ON "AuditLog"
FOR EACH ROW
EXECUTE FUNCTION app_reject_audit_log_mutation();
CREATE TRIGGER "AuditLog_reject_delete"
BEFORE DELETE ON "AuditLog"
FOR EACH ROW
EXECUTE FUNCTION app_reject_audit_log_mutation();

View File

@ -0,0 +1,27 @@
-- DropForeignKey
ALTER TABLE "MainCreationAgentSession" DROP CONSTRAINT "MainCreationAgentSession_versionId_fkey";
-- AddForeignKey
ALTER TABLE "MainCreationAgentSession" ADD CONSTRAINT "MainCreationAgentSession_versionId_projectId_fkey" FOREIGN KEY ("versionId", "projectId") REFERENCES "GameVersion"("id", "projectId") ON DELETE RESTRICT ON UPDATE CASCADE;
-- ReviewRecord 审核事实必须完整pending/canceled 不能带决策字段approved/rejected 必须带完整可审计证据。
ALTER TABLE "ReviewRecord" DROP CONSTRAINT "ReviewRecord_decision_reason_check";
ALTER TABLE "ReviewRecord" ADD CONSTRAINT "ReviewRecord_decision_reason_check" CHECK (
(
"status" IN ('pending_review', 'canceled')
AND "decision" IS NULL
AND "reasonCode" IS NULL
AND "decidedById" IS NULL
AND "decidedAt" IS NULL
)
OR (
"status" IN ('approved', 'rejected')
AND "decision" IS NOT NULL
AND "decision"::text = "status"::text
AND "reasonCode" IS NOT NULL
AND length(btrim("reasonCode")) > 0
AND "decidedById" IS NOT NULL
AND "decidedAt" IS NOT NULL
)
);

View File

@ -0,0 +1,28 @@
-- Task 7 Job 执行边界:持久化 lease 和乐观版本号,避免 worker 用内存状态伪装并发语义。
ALTER TABLE "Job" ADD COLUMN "leaseToken" TEXT;
ALTER TABLE "Job" ADD COLUMN "leasedBy" TEXT;
ALTER TABLE "Job" ADD COLUMN "leaseExpiresAt" TIMESTAMP(3);
ALTER TABLE "Job" ADD COLUMN "lockVersion" INTEGER NOT NULL DEFAULT 0;
CREATE INDEX "Job_status_nextRetryAt_createdAt_idx" ON "Job"("status", "nextRetryAt", "createdAt");
CREATE INDEX "Job_leaseToken_idx" ON "Job"("leaseToken");
-- running 必须带完整 lease非 running 状态不得保留可完成的 lease避免 stale worker 误提交。
ALTER TABLE "Job" ADD CONSTRAINT "Job_lease_consistency_check" CHECK (
(
"status" = 'running'
AND "leaseToken" IS NOT NULL
AND length(btrim("leaseToken")) > 0
AND "leasedBy" IS NOT NULL
AND length(btrim("leasedBy")) > 0
AND "leaseExpiresAt" IS NOT NULL
)
OR (
"status" <> 'running'
AND "leaseToken" IS NULL
AND "leasedBy" IS NULL
AND "leaseExpiresAt" IS NULL
)
);
ALTER TABLE "Job" ADD CONSTRAINT "Job_lock_version_check" CHECK ("lockVersion" >= 0);

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@ -0,0 +1,306 @@
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
enum UserStatus {
active
disabled
}
enum UserRoleName {
admin
operator
creator
player
}
enum ProjectStatus {
active
archived
}
enum GameVersionStatus {
draft
candidate
active
archived
failed
}
enum AssetStatus {
uploaded
deleted
}
enum JobStatus {
queued
running
succeeded
pending_retry
failed
canceled
}
enum JobTargetType {
project
version
}
enum MainCreationAgentSessionStatus {
routing_internal_tasks
completed
failed
canceled
}
enum AgentTaskType {
requirement_clarifier
game_design_draft_generator
}
enum AgentTaskStatus {
queued
running
succeeded
failed
canceled
timed_out
}
enum ReviewRecordStatus {
pending_review
approved
rejected
canceled
}
enum ReviewDecision {
approved
rejected
}
model User {
id String @id
email String? @unique
displayName String
status UserStatus @default(active)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
roles UserRole[]
anonymousIdentities AnonymousIdentity[]
gameProjects GameProject[]
auditLogs AuditLog[] @relation("AuditActor")
jobs Job[] @relation("JobActor")
mainCreationAgentSessions MainCreationAgentSession[]
reviewDecisions ReviewRecord[] @relation("ReviewDecider")
}
model UserRole {
id String @id @default(cuid())
userId String
role UserRoleName
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, role])
@@index([role])
}
model AnonymousIdentity {
id String @id
userId String
deviceKey String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([deviceKey])
@@index([userId])
}
model GameProject {
id String @id
ownerId String
slug String @unique
title String
status ProjectStatus @default(active)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation(fields: [ownerId], references: [id], onDelete: Restrict)
versions GameVersion[]
assets Asset[]
jobs Job[] @relation("JobProjectScope")
projectTargetJobs Job[] @relation("JobGameProjectTarget")
mainCreationAgentSessionScopes MainCreationAgentSession[] @relation("MainCreationAgentSessionProjectScope")
@@index([ownerId])
}
model GameVersion {
id String @id
projectId String
versionNumber Int
status GameVersionStatus
configJson Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project GameProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
versionTargetJobs Job[] @relation("JobGameVersionTarget")
mainCreationAgentSessionScopes MainCreationAgentSession[] @relation("MainCreationAgentSessionVersionScope")
reviewRecords ReviewRecord[]
lifecycleEvents LifecycleEvent[]
@@unique([projectId, versionNumber])
@@unique([id, projectId])
@@index([projectId, status])
}
model Asset {
id String @id
projectId String
kind String
storageKey String
mimeType String
byteSize Int
sha256 String
status AssetStatus @default(uploaded)
metadataJson Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project GameProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@unique([projectId, storageKey])
@@index([projectId, kind])
}
model Job {
id String @id
actorId String
projectId String
type String
idempotencyKey String
status JobStatus @default(queued)
attempts Int @default(0)
maxAttempts Int @default(3)
timeoutAt DateTime?
nextRetryAt DateTime?
errorCode String?
leaseToken String?
leasedBy String?
leaseExpiresAt DateTime?
lockVersion Int @default(0)
targetType JobTargetType
targetId String
targetScopeKey String
gameProjectId String?
gameVersionId String?
payloadJson Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
actor User @relation("JobActor", fields: [actorId], references: [id], onDelete: Restrict)
project GameProject @relation("JobProjectScope", fields: [projectId], references: [id], onDelete: Cascade)
gameProject GameProject? @relation("JobGameProjectTarget", fields: [gameProjectId], references: [id], onDelete: Restrict)
gameVersion GameVersion? @relation("JobGameVersionTarget", fields: [gameVersionId, projectId], references: [id, projectId], onDelete: Restrict)
@@unique([actorId, projectId, type, targetScopeKey, idempotencyKey])
@@index([projectId, status])
@@index([actorId, status])
@@index([status, nextRetryAt, createdAt])
@@index([leaseToken])
@@index([gameProjectId])
@@index([gameVersionId])
}
model AuditLog {
id String @id
actorId String
action String
targetType String
targetId String
eventJson Json
createdAt DateTime @default(now())
actor User @relation("AuditActor", fields: [actorId], references: [id], onDelete: Restrict)
@@index([actorId, createdAt])
@@index([targetType, targetId])
}
model MainCreationAgentSession {
id String @id
creatorId String
projectId String
versionId String
status MainCreationAgentSessionStatus
contextSummary String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
creator User @relation(fields: [creatorId], references: [id], onDelete: Restrict)
project GameProject @relation("MainCreationAgentSessionProjectScope", fields: [projectId], references: [id], onDelete: Cascade)
version GameVersion @relation("MainCreationAgentSessionVersionScope", fields: [versionId, projectId], references: [id, projectId], onDelete: Restrict)
tasks AgentTask[]
@@index([creatorId])
@@index([projectId])
@@index([versionId])
}
model AgentTask {
id String @id
sessionId String
taskType AgentTaskType
subagentId String
inputRef String
outputRef String?
status AgentTaskStatus
timeoutAt DateTime
errorCode String?
auditLogId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
session MainCreationAgentSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
@@index([sessionId])
@@index([status])
}
model ReviewRecord {
id String @id
gameVersionId String
status ReviewRecordStatus
decision ReviewDecision?
reasonCode String?
decidedById String?
decidedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
gameVersion GameVersion @relation(fields: [gameVersionId], references: [id], onDelete: Cascade)
decidedBy User? @relation("ReviewDecider", fields: [decidedById], references: [id], onDelete: Restrict)
@@index([gameVersionId, status])
@@index([decidedById])
}
model LifecycleEvent {
eventId String @id
gameVersionId String?
event String
from String
to String
actorJson Json
requiredRole String
requiredRecordRefsJson Json
auditEvent String
reasonCode String
occurredAt DateTime
approval String
requiredRecordsJson Json
createdAt DateTime @default(now())
gameVersion GameVersion? @relation(fields: [gameVersionId], references: [id], onDelete: SetNull)
@@index([gameVersionId])
@@index([event, occurredAt])
}

View File

@ -0,0 +1,13 @@
import { Module } from "@nestjs/common";
import { HealthController } from "./health.controller.js";
import { AuditModule } from "./modules/audit/index.js";
import { AssetsModule } from "./modules/assets/index.js";
import { AuthModule } from "./modules/auth/index.js";
import { JobsApiModule } from "./modules/jobs/index.js";
import { ProjectsModule } from "./modules/projects/index.js";
@Module({
imports: [AuthModule, ProjectsModule, AssetsModule, JobsApiModule, AuditModule],
controllers: [HealthController]
})
export class AppModule {}

View File

@ -0,0 +1,79 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file should be your main import to use Prisma-related types and utilities in a browser.
* Use it to get access to models, enums, and input types.
*
* This file does not contain a `PrismaClient` class, nor several other helpers that are intended as server-side only.
* See `client.ts` for the standard, server-side entry point.
*
* 🟢 You can import this file directly.
*/
import * as Prisma from './internal/prismaNamespaceBrowser.js'
export { Prisma }
export * as $Enums from './enums.js'
export * from './enums.js';
/**
* Model User
*
*/
export type User = Prisma.UserModel
/**
* Model UserRole
*
*/
export type UserRole = Prisma.UserRoleModel
/**
* Model AnonymousIdentity
*
*/
export type AnonymousIdentity = Prisma.AnonymousIdentityModel
/**
* Model GameProject
*
*/
export type GameProject = Prisma.GameProjectModel
/**
* Model GameVersion
*
*/
export type GameVersion = Prisma.GameVersionModel
/**
* Model Asset
*
*/
export type Asset = Prisma.AssetModel
/**
* Model Job
*
*/
export type Job = Prisma.JobModel
/**
* Model AuditLog
*
*/
export type AuditLog = Prisma.AuditLogModel
/**
* Model MainCreationAgentSession
*
*/
export type MainCreationAgentSession = Prisma.MainCreationAgentSessionModel
/**
* Model AgentTask
*
*/
export type AgentTask = Prisma.AgentTaskModel
/**
* Model ReviewRecord
*
*/
export type ReviewRecord = Prisma.ReviewRecordModel
/**
* Model LifecycleEvent
*
*/
export type LifecycleEvent = Prisma.LifecycleEventModel

View File

@ -0,0 +1,103 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file should be your main import to use Prisma. Through it you get access to all the models, enums, and input types.
* If you're looking for something you can import in the client-side of your application, please refer to the `browser.ts` file instead.
*
* 🟢 You can import this file directly.
*/
import * as process from 'node:process'
import * as path from 'node:path'
import { fileURLToPath } from 'node:url'
globalThis['__dirname'] = path.dirname(fileURLToPath(import.meta.url))
import * as runtime from "@prisma/client/runtime/client"
import * as $Enums from "./enums.js"
import * as $Class from "./internal/class.js"
import * as Prisma from "./internal/prismaNamespace.js"
export * as $Enums from './enums.js'
export * from "./enums.js"
/**
* ## Prisma Client
*
* Type-safe database client for TypeScript
* @example
* ```
* const prisma = new PrismaClient({
* adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL })
* })
* // Fetch zero or more Users
* const users = await prisma.user.findMany()
* ```
*
* Read more in our [docs](https://pris.ly/d/client).
*/
export const PrismaClient = $Class.getPrismaClientClass()
export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts extends Prisma.PrismaClientOptions["omit"] = Prisma.PrismaClientOptions["omit"], ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>
export { Prisma }
/**
* Model User
*
*/
export type User = Prisma.UserModel
/**
* Model UserRole
*
*/
export type UserRole = Prisma.UserRoleModel
/**
* Model AnonymousIdentity
*
*/
export type AnonymousIdentity = Prisma.AnonymousIdentityModel
/**
* Model GameProject
*
*/
export type GameProject = Prisma.GameProjectModel
/**
* Model GameVersion
*
*/
export type GameVersion = Prisma.GameVersionModel
/**
* Model Asset
*
*/
export type Asset = Prisma.AssetModel
/**
* Model Job
*
*/
export type Job = Prisma.JobModel
/**
* Model AuditLog
*
*/
export type AuditLog = Prisma.AuditLogModel
/**
* Model MainCreationAgentSession
*
*/
export type MainCreationAgentSession = Prisma.MainCreationAgentSessionModel
/**
* Model AgentTask
*
*/
export type AgentTask = Prisma.AgentTaskModel
/**
* Model ReviewRecord
*
*/
export type ReviewRecord = Prisma.ReviewRecordModel
/**
* Model LifecycleEvent
*
*/
export type LifecycleEvent = Prisma.LifecycleEventModel

View File

@ -0,0 +1,884 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file exports various common sort, input & filter types that are not directly linked to a particular model.
*
* 🟢 You can import this file directly.
*/
import type * as runtime from "@prisma/client/runtime/client"
import * as $Enums from "./enums.js"
import type * as Prisma from "./internal/prismaNamespace.js"
export type StringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode
not?: Prisma.NestedStringFilter<$PrismaModel> | string
}
export type StringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type EnumUserStatusFilter<$PrismaModel = never> = {
equals?: $Enums.UserStatus | Prisma.EnumUserStatusFieldRefInput<$PrismaModel>
in?: $Enums.UserStatus[] | Prisma.ListEnumUserStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.UserStatus[] | Prisma.ListEnumUserStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumUserStatusFilter<$PrismaModel> | $Enums.UserStatus
}
export type DateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}
export type SortOrderInput = {
sort: Prisma.SortOrder
nulls?: Prisma.NullsOrder
}
export type StringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
mode?: Prisma.QueryMode
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type EnumUserStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.UserStatus | Prisma.EnumUserStatusFieldRefInput<$PrismaModel>
in?: $Enums.UserStatus[] | Prisma.ListEnumUserStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.UserStatus[] | Prisma.ListEnumUserStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumUserStatusWithAggregatesFilter<$PrismaModel> | $Enums.UserStatus
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumUserStatusFilter<$PrismaModel>
_max?: Prisma.NestedEnumUserStatusFilter<$PrismaModel>
}
export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
export type EnumUserRoleNameFilter<$PrismaModel = never> = {
equals?: $Enums.UserRoleName | Prisma.EnumUserRoleNameFieldRefInput<$PrismaModel>
in?: $Enums.UserRoleName[] | Prisma.ListEnumUserRoleNameFieldRefInput<$PrismaModel>
notIn?: $Enums.UserRoleName[] | Prisma.ListEnumUserRoleNameFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumUserRoleNameFilter<$PrismaModel> | $Enums.UserRoleName
}
export type EnumUserRoleNameWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.UserRoleName | Prisma.EnumUserRoleNameFieldRefInput<$PrismaModel>
in?: $Enums.UserRoleName[] | Prisma.ListEnumUserRoleNameFieldRefInput<$PrismaModel>
notIn?: $Enums.UserRoleName[] | Prisma.ListEnumUserRoleNameFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumUserRoleNameWithAggregatesFilter<$PrismaModel> | $Enums.UserRoleName
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumUserRoleNameFilter<$PrismaModel>
_max?: Prisma.NestedEnumUserRoleNameFilter<$PrismaModel>
}
export type EnumProjectStatusFilter<$PrismaModel = never> = {
equals?: $Enums.ProjectStatus | Prisma.EnumProjectStatusFieldRefInput<$PrismaModel>
in?: $Enums.ProjectStatus[] | Prisma.ListEnumProjectStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.ProjectStatus[] | Prisma.ListEnumProjectStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumProjectStatusFilter<$PrismaModel> | $Enums.ProjectStatus
}
export type EnumProjectStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.ProjectStatus | Prisma.EnumProjectStatusFieldRefInput<$PrismaModel>
in?: $Enums.ProjectStatus[] | Prisma.ListEnumProjectStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.ProjectStatus[] | Prisma.ListEnumProjectStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumProjectStatusWithAggregatesFilter<$PrismaModel> | $Enums.ProjectStatus
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumProjectStatusFilter<$PrismaModel>
_max?: Prisma.NestedEnumProjectStatusFilter<$PrismaModel>
}
export type IntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntFilter<$PrismaModel> | number
}
export type EnumGameVersionStatusFilter<$PrismaModel = never> = {
equals?: $Enums.GameVersionStatus | Prisma.EnumGameVersionStatusFieldRefInput<$PrismaModel>
in?: $Enums.GameVersionStatus[] | Prisma.ListEnumGameVersionStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.GameVersionStatus[] | Prisma.ListEnumGameVersionStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumGameVersionStatusFilter<$PrismaModel> | $Enums.GameVersionStatus
}
export type JsonFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<JsonFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonFilterBase<$PrismaModel>>, 'path'>>,
Required<JsonFilterBase<$PrismaModel>>
>
| Prisma.OptionalFlat<Omit<Required<JsonFilterBase<$PrismaModel>>, 'path'>>
export type JsonFilterBase<$PrismaModel = never> = {
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
path?: string[]
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
}
export type IntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedIntFilter<$PrismaModel>
_max?: Prisma.NestedIntFilter<$PrismaModel>
}
export type EnumGameVersionStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.GameVersionStatus | Prisma.EnumGameVersionStatusFieldRefInput<$PrismaModel>
in?: $Enums.GameVersionStatus[] | Prisma.ListEnumGameVersionStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.GameVersionStatus[] | Prisma.ListEnumGameVersionStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumGameVersionStatusWithAggregatesFilter<$PrismaModel> | $Enums.GameVersionStatus
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumGameVersionStatusFilter<$PrismaModel>
_max?: Prisma.NestedEnumGameVersionStatusFilter<$PrismaModel>
}
export type JsonWithAggregatesFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<JsonWithAggregatesFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonWithAggregatesFilterBase<$PrismaModel>>, 'path'>>,
Required<JsonWithAggregatesFilterBase<$PrismaModel>>
>
| Prisma.OptionalFlat<Omit<Required<JsonWithAggregatesFilterBase<$PrismaModel>>, 'path'>>
export type JsonWithAggregatesFilterBase<$PrismaModel = never> = {
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
path?: string[]
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedJsonFilter<$PrismaModel>
_max?: Prisma.NestedJsonFilter<$PrismaModel>
}
export type EnumAssetStatusFilter<$PrismaModel = never> = {
equals?: $Enums.AssetStatus | Prisma.EnumAssetStatusFieldRefInput<$PrismaModel>
in?: $Enums.AssetStatus[] | Prisma.ListEnumAssetStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.AssetStatus[] | Prisma.ListEnumAssetStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumAssetStatusFilter<$PrismaModel> | $Enums.AssetStatus
}
export type JsonNullableFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<JsonNullableFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonNullableFilterBase<$PrismaModel>>, 'path'>>,
Required<JsonNullableFilterBase<$PrismaModel>>
>
| Prisma.OptionalFlat<Omit<Required<JsonNullableFilterBase<$PrismaModel>>, 'path'>>
export type JsonNullableFilterBase<$PrismaModel = never> = {
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
path?: string[]
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
}
export type EnumAssetStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.AssetStatus | Prisma.EnumAssetStatusFieldRefInput<$PrismaModel>
in?: $Enums.AssetStatus[] | Prisma.ListEnumAssetStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.AssetStatus[] | Prisma.ListEnumAssetStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumAssetStatusWithAggregatesFilter<$PrismaModel> | $Enums.AssetStatus
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumAssetStatusFilter<$PrismaModel>
_max?: Prisma.NestedEnumAssetStatusFilter<$PrismaModel>
}
export type JsonNullableWithAggregatesFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>, 'path'>>,
Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>
>
| Prisma.OptionalFlat<Omit<Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>, 'path'>>
export type JsonNullableWithAggregatesFilterBase<$PrismaModel = never> = {
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
path?: string[]
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedJsonNullableFilter<$PrismaModel>
_max?: Prisma.NestedJsonNullableFilter<$PrismaModel>
}
export type EnumJobStatusFilter<$PrismaModel = never> = {
equals?: $Enums.JobStatus | Prisma.EnumJobStatusFieldRefInput<$PrismaModel>
in?: $Enums.JobStatus[] | Prisma.ListEnumJobStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.JobStatus[] | Prisma.ListEnumJobStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumJobStatusFilter<$PrismaModel> | $Enums.JobStatus
}
export type DateTimeNullableFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
}
export type EnumJobTargetTypeFilter<$PrismaModel = never> = {
equals?: $Enums.JobTargetType | Prisma.EnumJobTargetTypeFieldRefInput<$PrismaModel>
in?: $Enums.JobTargetType[] | Prisma.ListEnumJobTargetTypeFieldRefInput<$PrismaModel>
notIn?: $Enums.JobTargetType[] | Prisma.ListEnumJobTargetTypeFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumJobTargetTypeFilter<$PrismaModel> | $Enums.JobTargetType
}
export type EnumJobStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.JobStatus | Prisma.EnumJobStatusFieldRefInput<$PrismaModel>
in?: $Enums.JobStatus[] | Prisma.ListEnumJobStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.JobStatus[] | Prisma.ListEnumJobStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumJobStatusWithAggregatesFilter<$PrismaModel> | $Enums.JobStatus
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumJobStatusFilter<$PrismaModel>
_max?: Prisma.NestedEnumJobStatusFilter<$PrismaModel>
}
export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
}
export type EnumJobTargetTypeWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.JobTargetType | Prisma.EnumJobTargetTypeFieldRefInput<$PrismaModel>
in?: $Enums.JobTargetType[] | Prisma.ListEnumJobTargetTypeFieldRefInput<$PrismaModel>
notIn?: $Enums.JobTargetType[] | Prisma.ListEnumJobTargetTypeFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumJobTargetTypeWithAggregatesFilter<$PrismaModel> | $Enums.JobTargetType
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumJobTargetTypeFilter<$PrismaModel>
_max?: Prisma.NestedEnumJobTargetTypeFilter<$PrismaModel>
}
export type EnumMainCreationAgentSessionStatusFilter<$PrismaModel = never> = {
equals?: $Enums.MainCreationAgentSessionStatus | Prisma.EnumMainCreationAgentSessionStatusFieldRefInput<$PrismaModel>
in?: $Enums.MainCreationAgentSessionStatus[] | Prisma.ListEnumMainCreationAgentSessionStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.MainCreationAgentSessionStatus[] | Prisma.ListEnumMainCreationAgentSessionStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumMainCreationAgentSessionStatusFilter<$PrismaModel> | $Enums.MainCreationAgentSessionStatus
}
export type EnumMainCreationAgentSessionStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.MainCreationAgentSessionStatus | Prisma.EnumMainCreationAgentSessionStatusFieldRefInput<$PrismaModel>
in?: $Enums.MainCreationAgentSessionStatus[] | Prisma.ListEnumMainCreationAgentSessionStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.MainCreationAgentSessionStatus[] | Prisma.ListEnumMainCreationAgentSessionStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumMainCreationAgentSessionStatusWithAggregatesFilter<$PrismaModel> | $Enums.MainCreationAgentSessionStatus
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumMainCreationAgentSessionStatusFilter<$PrismaModel>
_max?: Prisma.NestedEnumMainCreationAgentSessionStatusFilter<$PrismaModel>
}
export type EnumAgentTaskTypeFilter<$PrismaModel = never> = {
equals?: $Enums.AgentTaskType | Prisma.EnumAgentTaskTypeFieldRefInput<$PrismaModel>
in?: $Enums.AgentTaskType[] | Prisma.ListEnumAgentTaskTypeFieldRefInput<$PrismaModel>
notIn?: $Enums.AgentTaskType[] | Prisma.ListEnumAgentTaskTypeFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumAgentTaskTypeFilter<$PrismaModel> | $Enums.AgentTaskType
}
export type EnumAgentTaskStatusFilter<$PrismaModel = never> = {
equals?: $Enums.AgentTaskStatus | Prisma.EnumAgentTaskStatusFieldRefInput<$PrismaModel>
in?: $Enums.AgentTaskStatus[] | Prisma.ListEnumAgentTaskStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.AgentTaskStatus[] | Prisma.ListEnumAgentTaskStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumAgentTaskStatusFilter<$PrismaModel> | $Enums.AgentTaskStatus
}
export type EnumAgentTaskTypeWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.AgentTaskType | Prisma.EnumAgentTaskTypeFieldRefInput<$PrismaModel>
in?: $Enums.AgentTaskType[] | Prisma.ListEnumAgentTaskTypeFieldRefInput<$PrismaModel>
notIn?: $Enums.AgentTaskType[] | Prisma.ListEnumAgentTaskTypeFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumAgentTaskTypeWithAggregatesFilter<$PrismaModel> | $Enums.AgentTaskType
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumAgentTaskTypeFilter<$PrismaModel>
_max?: Prisma.NestedEnumAgentTaskTypeFilter<$PrismaModel>
}
export type EnumAgentTaskStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.AgentTaskStatus | Prisma.EnumAgentTaskStatusFieldRefInput<$PrismaModel>
in?: $Enums.AgentTaskStatus[] | Prisma.ListEnumAgentTaskStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.AgentTaskStatus[] | Prisma.ListEnumAgentTaskStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumAgentTaskStatusWithAggregatesFilter<$PrismaModel> | $Enums.AgentTaskStatus
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumAgentTaskStatusFilter<$PrismaModel>
_max?: Prisma.NestedEnumAgentTaskStatusFilter<$PrismaModel>
}
export type EnumReviewRecordStatusFilter<$PrismaModel = never> = {
equals?: $Enums.ReviewRecordStatus | Prisma.EnumReviewRecordStatusFieldRefInput<$PrismaModel>
in?: $Enums.ReviewRecordStatus[] | Prisma.ListEnumReviewRecordStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.ReviewRecordStatus[] | Prisma.ListEnumReviewRecordStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumReviewRecordStatusFilter<$PrismaModel> | $Enums.ReviewRecordStatus
}
export type EnumReviewDecisionNullableFilter<$PrismaModel = never> = {
equals?: $Enums.ReviewDecision | Prisma.EnumReviewDecisionFieldRefInput<$PrismaModel> | null
in?: $Enums.ReviewDecision[] | Prisma.ListEnumReviewDecisionFieldRefInput<$PrismaModel> | null
notIn?: $Enums.ReviewDecision[] | Prisma.ListEnumReviewDecisionFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumReviewDecisionNullableFilter<$PrismaModel> | $Enums.ReviewDecision | null
}
export type EnumReviewRecordStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.ReviewRecordStatus | Prisma.EnumReviewRecordStatusFieldRefInput<$PrismaModel>
in?: $Enums.ReviewRecordStatus[] | Prisma.ListEnumReviewRecordStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.ReviewRecordStatus[] | Prisma.ListEnumReviewRecordStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumReviewRecordStatusWithAggregatesFilter<$PrismaModel> | $Enums.ReviewRecordStatus
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumReviewRecordStatusFilter<$PrismaModel>
_max?: Prisma.NestedEnumReviewRecordStatusFilter<$PrismaModel>
}
export type EnumReviewDecisionNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.ReviewDecision | Prisma.EnumReviewDecisionFieldRefInput<$PrismaModel> | null
in?: $Enums.ReviewDecision[] | Prisma.ListEnumReviewDecisionFieldRefInput<$PrismaModel> | null
notIn?: $Enums.ReviewDecision[] | Prisma.ListEnumReviewDecisionFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumReviewDecisionNullableWithAggregatesFilter<$PrismaModel> | $Enums.ReviewDecision | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedEnumReviewDecisionNullableFilter<$PrismaModel>
_max?: Prisma.NestedEnumReviewDecisionNullableFilter<$PrismaModel>
}
export type NestedStringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringFilter<$PrismaModel> | string
}
export type NestedStringNullableFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
}
export type NestedEnumUserStatusFilter<$PrismaModel = never> = {
equals?: $Enums.UserStatus | Prisma.EnumUserStatusFieldRefInput<$PrismaModel>
in?: $Enums.UserStatus[] | Prisma.ListEnumUserStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.UserStatus[] | Prisma.ListEnumUserStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumUserStatusFilter<$PrismaModel> | $Enums.UserStatus
}
export type NestedDateTimeFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
}
export type NestedStringWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedStringFilter<$PrismaModel>
_max?: Prisma.NestedStringFilter<$PrismaModel>
}
export type NestedIntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntFilter<$PrismaModel> | number
}
export type NestedStringNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type NestedIntNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
}
export type NestedEnumUserStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.UserStatus | Prisma.EnumUserStatusFieldRefInput<$PrismaModel>
in?: $Enums.UserStatus[] | Prisma.ListEnumUserStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.UserStatus[] | Prisma.ListEnumUserStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumUserStatusWithAggregatesFilter<$PrismaModel> | $Enums.UserStatus
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumUserStatusFilter<$PrismaModel>
_max?: Prisma.NestedEnumUserStatusFilter<$PrismaModel>
}
export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
}
export type NestedEnumUserRoleNameFilter<$PrismaModel = never> = {
equals?: $Enums.UserRoleName | Prisma.EnumUserRoleNameFieldRefInput<$PrismaModel>
in?: $Enums.UserRoleName[] | Prisma.ListEnumUserRoleNameFieldRefInput<$PrismaModel>
notIn?: $Enums.UserRoleName[] | Prisma.ListEnumUserRoleNameFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumUserRoleNameFilter<$PrismaModel> | $Enums.UserRoleName
}
export type NestedEnumUserRoleNameWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.UserRoleName | Prisma.EnumUserRoleNameFieldRefInput<$PrismaModel>
in?: $Enums.UserRoleName[] | Prisma.ListEnumUserRoleNameFieldRefInput<$PrismaModel>
notIn?: $Enums.UserRoleName[] | Prisma.ListEnumUserRoleNameFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumUserRoleNameWithAggregatesFilter<$PrismaModel> | $Enums.UserRoleName
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumUserRoleNameFilter<$PrismaModel>
_max?: Prisma.NestedEnumUserRoleNameFilter<$PrismaModel>
}
export type NestedEnumProjectStatusFilter<$PrismaModel = never> = {
equals?: $Enums.ProjectStatus | Prisma.EnumProjectStatusFieldRefInput<$PrismaModel>
in?: $Enums.ProjectStatus[] | Prisma.ListEnumProjectStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.ProjectStatus[] | Prisma.ListEnumProjectStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumProjectStatusFilter<$PrismaModel> | $Enums.ProjectStatus
}
export type NestedEnumProjectStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.ProjectStatus | Prisma.EnumProjectStatusFieldRefInput<$PrismaModel>
in?: $Enums.ProjectStatus[] | Prisma.ListEnumProjectStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.ProjectStatus[] | Prisma.ListEnumProjectStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumProjectStatusWithAggregatesFilter<$PrismaModel> | $Enums.ProjectStatus
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumProjectStatusFilter<$PrismaModel>
_max?: Prisma.NestedEnumProjectStatusFilter<$PrismaModel>
}
export type NestedEnumGameVersionStatusFilter<$PrismaModel = never> = {
equals?: $Enums.GameVersionStatus | Prisma.EnumGameVersionStatusFieldRefInput<$PrismaModel>
in?: $Enums.GameVersionStatus[] | Prisma.ListEnumGameVersionStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.GameVersionStatus[] | Prisma.ListEnumGameVersionStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumGameVersionStatusFilter<$PrismaModel> | $Enums.GameVersionStatus
}
export type NestedIntWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedIntFilter<$PrismaModel>
_max?: Prisma.NestedIntFilter<$PrismaModel>
}
export type NestedFloatFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
}
export type NestedEnumGameVersionStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.GameVersionStatus | Prisma.EnumGameVersionStatusFieldRefInput<$PrismaModel>
in?: $Enums.GameVersionStatus[] | Prisma.ListEnumGameVersionStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.GameVersionStatus[] | Prisma.ListEnumGameVersionStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumGameVersionStatusWithAggregatesFilter<$PrismaModel> | $Enums.GameVersionStatus
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumGameVersionStatusFilter<$PrismaModel>
_max?: Prisma.NestedEnumGameVersionStatusFilter<$PrismaModel>
}
export type NestedJsonFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<NestedJsonFilterBase<$PrismaModel>>, Exclude<keyof Required<NestedJsonFilterBase<$PrismaModel>>, 'path'>>,
Required<NestedJsonFilterBase<$PrismaModel>>
>
| Prisma.OptionalFlat<Omit<Required<NestedJsonFilterBase<$PrismaModel>>, 'path'>>
export type NestedJsonFilterBase<$PrismaModel = never> = {
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
path?: string[]
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
}
export type NestedEnumAssetStatusFilter<$PrismaModel = never> = {
equals?: $Enums.AssetStatus | Prisma.EnumAssetStatusFieldRefInput<$PrismaModel>
in?: $Enums.AssetStatus[] | Prisma.ListEnumAssetStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.AssetStatus[] | Prisma.ListEnumAssetStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumAssetStatusFilter<$PrismaModel> | $Enums.AssetStatus
}
export type NestedEnumAssetStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.AssetStatus | Prisma.EnumAssetStatusFieldRefInput<$PrismaModel>
in?: $Enums.AssetStatus[] | Prisma.ListEnumAssetStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.AssetStatus[] | Prisma.ListEnumAssetStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumAssetStatusWithAggregatesFilter<$PrismaModel> | $Enums.AssetStatus
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumAssetStatusFilter<$PrismaModel>
_max?: Prisma.NestedEnumAssetStatusFilter<$PrismaModel>
}
export type NestedJsonNullableFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<NestedJsonNullableFilterBase<$PrismaModel>>, Exclude<keyof Required<NestedJsonNullableFilterBase<$PrismaModel>>, 'path'>>,
Required<NestedJsonNullableFilterBase<$PrismaModel>>
>
| Prisma.OptionalFlat<Omit<Required<NestedJsonNullableFilterBase<$PrismaModel>>, 'path'>>
export type NestedJsonNullableFilterBase<$PrismaModel = never> = {
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
path?: string[]
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
}
export type NestedEnumJobStatusFilter<$PrismaModel = never> = {
equals?: $Enums.JobStatus | Prisma.EnumJobStatusFieldRefInput<$PrismaModel>
in?: $Enums.JobStatus[] | Prisma.ListEnumJobStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.JobStatus[] | Prisma.ListEnumJobStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumJobStatusFilter<$PrismaModel> | $Enums.JobStatus
}
export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
}
export type NestedEnumJobTargetTypeFilter<$PrismaModel = never> = {
equals?: $Enums.JobTargetType | Prisma.EnumJobTargetTypeFieldRefInput<$PrismaModel>
in?: $Enums.JobTargetType[] | Prisma.ListEnumJobTargetTypeFieldRefInput<$PrismaModel>
notIn?: $Enums.JobTargetType[] | Prisma.ListEnumJobTargetTypeFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumJobTargetTypeFilter<$PrismaModel> | $Enums.JobTargetType
}
export type NestedEnumJobStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.JobStatus | Prisma.EnumJobStatusFieldRefInput<$PrismaModel>
in?: $Enums.JobStatus[] | Prisma.ListEnumJobStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.JobStatus[] | Prisma.ListEnumJobStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumJobStatusWithAggregatesFilter<$PrismaModel> | $Enums.JobStatus
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumJobStatusFilter<$PrismaModel>
_max?: Prisma.NestedEnumJobStatusFilter<$PrismaModel>
}
export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
}
export type NestedEnumJobTargetTypeWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.JobTargetType | Prisma.EnumJobTargetTypeFieldRefInput<$PrismaModel>
in?: $Enums.JobTargetType[] | Prisma.ListEnumJobTargetTypeFieldRefInput<$PrismaModel>
notIn?: $Enums.JobTargetType[] | Prisma.ListEnumJobTargetTypeFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumJobTargetTypeWithAggregatesFilter<$PrismaModel> | $Enums.JobTargetType
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumJobTargetTypeFilter<$PrismaModel>
_max?: Prisma.NestedEnumJobTargetTypeFilter<$PrismaModel>
}
export type NestedEnumMainCreationAgentSessionStatusFilter<$PrismaModel = never> = {
equals?: $Enums.MainCreationAgentSessionStatus | Prisma.EnumMainCreationAgentSessionStatusFieldRefInput<$PrismaModel>
in?: $Enums.MainCreationAgentSessionStatus[] | Prisma.ListEnumMainCreationAgentSessionStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.MainCreationAgentSessionStatus[] | Prisma.ListEnumMainCreationAgentSessionStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumMainCreationAgentSessionStatusFilter<$PrismaModel> | $Enums.MainCreationAgentSessionStatus
}
export type NestedEnumMainCreationAgentSessionStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.MainCreationAgentSessionStatus | Prisma.EnumMainCreationAgentSessionStatusFieldRefInput<$PrismaModel>
in?: $Enums.MainCreationAgentSessionStatus[] | Prisma.ListEnumMainCreationAgentSessionStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.MainCreationAgentSessionStatus[] | Prisma.ListEnumMainCreationAgentSessionStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumMainCreationAgentSessionStatusWithAggregatesFilter<$PrismaModel> | $Enums.MainCreationAgentSessionStatus
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumMainCreationAgentSessionStatusFilter<$PrismaModel>
_max?: Prisma.NestedEnumMainCreationAgentSessionStatusFilter<$PrismaModel>
}
export type NestedEnumAgentTaskTypeFilter<$PrismaModel = never> = {
equals?: $Enums.AgentTaskType | Prisma.EnumAgentTaskTypeFieldRefInput<$PrismaModel>
in?: $Enums.AgentTaskType[] | Prisma.ListEnumAgentTaskTypeFieldRefInput<$PrismaModel>
notIn?: $Enums.AgentTaskType[] | Prisma.ListEnumAgentTaskTypeFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumAgentTaskTypeFilter<$PrismaModel> | $Enums.AgentTaskType
}
export type NestedEnumAgentTaskStatusFilter<$PrismaModel = never> = {
equals?: $Enums.AgentTaskStatus | Prisma.EnumAgentTaskStatusFieldRefInput<$PrismaModel>
in?: $Enums.AgentTaskStatus[] | Prisma.ListEnumAgentTaskStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.AgentTaskStatus[] | Prisma.ListEnumAgentTaskStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumAgentTaskStatusFilter<$PrismaModel> | $Enums.AgentTaskStatus
}
export type NestedEnumAgentTaskTypeWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.AgentTaskType | Prisma.EnumAgentTaskTypeFieldRefInput<$PrismaModel>
in?: $Enums.AgentTaskType[] | Prisma.ListEnumAgentTaskTypeFieldRefInput<$PrismaModel>
notIn?: $Enums.AgentTaskType[] | Prisma.ListEnumAgentTaskTypeFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumAgentTaskTypeWithAggregatesFilter<$PrismaModel> | $Enums.AgentTaskType
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumAgentTaskTypeFilter<$PrismaModel>
_max?: Prisma.NestedEnumAgentTaskTypeFilter<$PrismaModel>
}
export type NestedEnumAgentTaskStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.AgentTaskStatus | Prisma.EnumAgentTaskStatusFieldRefInput<$PrismaModel>
in?: $Enums.AgentTaskStatus[] | Prisma.ListEnumAgentTaskStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.AgentTaskStatus[] | Prisma.ListEnumAgentTaskStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumAgentTaskStatusWithAggregatesFilter<$PrismaModel> | $Enums.AgentTaskStatus
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumAgentTaskStatusFilter<$PrismaModel>
_max?: Prisma.NestedEnumAgentTaskStatusFilter<$PrismaModel>
}
export type NestedEnumReviewRecordStatusFilter<$PrismaModel = never> = {
equals?: $Enums.ReviewRecordStatus | Prisma.EnumReviewRecordStatusFieldRefInput<$PrismaModel>
in?: $Enums.ReviewRecordStatus[] | Prisma.ListEnumReviewRecordStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.ReviewRecordStatus[] | Prisma.ListEnumReviewRecordStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumReviewRecordStatusFilter<$PrismaModel> | $Enums.ReviewRecordStatus
}
export type NestedEnumReviewDecisionNullableFilter<$PrismaModel = never> = {
equals?: $Enums.ReviewDecision | Prisma.EnumReviewDecisionFieldRefInput<$PrismaModel> | null
in?: $Enums.ReviewDecision[] | Prisma.ListEnumReviewDecisionFieldRefInput<$PrismaModel> | null
notIn?: $Enums.ReviewDecision[] | Prisma.ListEnumReviewDecisionFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumReviewDecisionNullableFilter<$PrismaModel> | $Enums.ReviewDecision | null
}
export type NestedEnumReviewRecordStatusWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.ReviewRecordStatus | Prisma.EnumReviewRecordStatusFieldRefInput<$PrismaModel>
in?: $Enums.ReviewRecordStatus[] | Prisma.ListEnumReviewRecordStatusFieldRefInput<$PrismaModel>
notIn?: $Enums.ReviewRecordStatus[] | Prisma.ListEnumReviewRecordStatusFieldRefInput<$PrismaModel>
not?: Prisma.NestedEnumReviewRecordStatusWithAggregatesFilter<$PrismaModel> | $Enums.ReviewRecordStatus
_count?: Prisma.NestedIntFilter<$PrismaModel>
_min?: Prisma.NestedEnumReviewRecordStatusFilter<$PrismaModel>
_max?: Prisma.NestedEnumReviewRecordStatusFilter<$PrismaModel>
}
export type NestedEnumReviewDecisionNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: $Enums.ReviewDecision | Prisma.EnumReviewDecisionFieldRefInput<$PrismaModel> | null
in?: $Enums.ReviewDecision[] | Prisma.ListEnumReviewDecisionFieldRefInput<$PrismaModel> | null
notIn?: $Enums.ReviewDecision[] | Prisma.ListEnumReviewDecisionFieldRefInput<$PrismaModel> | null
not?: Prisma.NestedEnumReviewDecisionNullableWithAggregatesFilter<$PrismaModel> | $Enums.ReviewDecision | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedEnumReviewDecisionNullableFilter<$PrismaModel>
_max?: Prisma.NestedEnumReviewDecisionNullableFilter<$PrismaModel>
}

View File

@ -0,0 +1,122 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This file exports all enum related types from the schema.
*
* 🟢 You can import this file directly.
*/
export const UserStatus = {
active: 'active',
disabled: 'disabled'
} as const
export type UserStatus = (typeof UserStatus)[keyof typeof UserStatus]
export const UserRoleName = {
admin: 'admin',
operator: 'operator',
creator: 'creator',
player: 'player'
} as const
export type UserRoleName = (typeof UserRoleName)[keyof typeof UserRoleName]
export const ProjectStatus = {
active: 'active',
archived: 'archived'
} as const
export type ProjectStatus = (typeof ProjectStatus)[keyof typeof ProjectStatus]
export const GameVersionStatus = {
draft: 'draft',
candidate: 'candidate',
active: 'active',
archived: 'archived',
failed: 'failed'
} as const
export type GameVersionStatus = (typeof GameVersionStatus)[keyof typeof GameVersionStatus]
export const AssetStatus = {
uploaded: 'uploaded',
deleted: 'deleted'
} as const
export type AssetStatus = (typeof AssetStatus)[keyof typeof AssetStatus]
export const JobStatus = {
queued: 'queued',
running: 'running',
succeeded: 'succeeded',
pending_retry: 'pending_retry',
failed: 'failed',
canceled: 'canceled'
} as const
export type JobStatus = (typeof JobStatus)[keyof typeof JobStatus]
export const JobTargetType = {
project: 'project',
version: 'version'
} as const
export type JobTargetType = (typeof JobTargetType)[keyof typeof JobTargetType]
export const MainCreationAgentSessionStatus = {
routing_internal_tasks: 'routing_internal_tasks',
completed: 'completed',
failed: 'failed',
canceled: 'canceled'
} as const
export type MainCreationAgentSessionStatus = (typeof MainCreationAgentSessionStatus)[keyof typeof MainCreationAgentSessionStatus]
export const AgentTaskType = {
requirement_clarifier: 'requirement_clarifier',
game_design_draft_generator: 'game_design_draft_generator'
} as const
export type AgentTaskType = (typeof AgentTaskType)[keyof typeof AgentTaskType]
export const AgentTaskStatus = {
queued: 'queued',
running: 'running',
succeeded: 'succeeded',
failed: 'failed',
canceled: 'canceled',
timed_out: 'timed_out'
} as const
export type AgentTaskStatus = (typeof AgentTaskStatus)[keyof typeof AgentTaskStatus]
export const ReviewRecordStatus = {
pending_review: 'pending_review',
approved: 'approved',
rejected: 'rejected',
canceled: 'canceled'
} as const
export type ReviewRecordStatus = (typeof ReviewRecordStatus)[keyof typeof ReviewRecordStatus]
export const ReviewDecision = {
approved: 'approved',
rejected: 'rejected'
} as const
export type ReviewDecision = (typeof ReviewDecision)[keyof typeof ReviewDecision]

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,314 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* WARNING: This is an internal file that is subject to change!
*
* 🛑 Under no circumstances should you import this file directly! 🛑
*
* All exports from this file are wrapped under a `Prisma` namespace object in the browser.ts file.
* While this enables partial backward compatibility, it is not part of the stable public API.
*
* If you are looking for your Models, Enums, and Input Types, please import them from the respective
* model files in the `model` directory!
*/
import * as runtime from "@prisma/client/runtime/index-browser"
export type * from '../models.js'
export type * from './prismaNamespace.js'
export const Decimal = runtime.Decimal
export const NullTypes = {
DbNull: runtime.NullTypes.DbNull as (new (secret: never) => typeof runtime.DbNull),
JsonNull: runtime.NullTypes.JsonNull as (new (secret: never) => typeof runtime.JsonNull),
AnyNull: runtime.NullTypes.AnyNull as (new (secret: never) => typeof runtime.AnyNull),
}
/**
* Helper for filtering JSON entries that have `null` on the database (empty on the db)
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const DbNull = runtime.DbNull
/**
* Helper for filtering JSON entries that have JSON `null` values (not empty on the db)
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const JsonNull = runtime.JsonNull
/**
* Helper for filtering JSON entries that are `Prisma.DbNull` or `Prisma.JsonNull`
*
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
*/
export const AnyNull = runtime.AnyNull
export const ModelName = {
User: 'User',
UserRole: 'UserRole',
AnonymousIdentity: 'AnonymousIdentity',
GameProject: 'GameProject',
GameVersion: 'GameVersion',
Asset: 'Asset',
Job: 'Job',
AuditLog: 'AuditLog',
MainCreationAgentSession: 'MainCreationAgentSession',
AgentTask: 'AgentTask',
ReviewRecord: 'ReviewRecord',
LifecycleEvent: 'LifecycleEvent'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
/*
* Enums
*/
export const TransactionIsolationLevel = runtime.makeStrictEnum({
ReadUncommitted: 'ReadUncommitted',
ReadCommitted: 'ReadCommitted',
RepeatableRead: 'RepeatableRead',
Serializable: 'Serializable'
} as const)
export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel]
export const UserScalarFieldEnum = {
id: 'id',
email: 'email',
displayName: 'displayName',
status: 'status',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum]
export const UserRoleScalarFieldEnum = {
id: 'id',
userId: 'userId',
role: 'role',
createdAt: 'createdAt'
} as const
export type UserRoleScalarFieldEnum = (typeof UserRoleScalarFieldEnum)[keyof typeof UserRoleScalarFieldEnum]
export const AnonymousIdentityScalarFieldEnum = {
id: 'id',
userId: 'userId',
deviceKey: 'deviceKey',
createdAt: 'createdAt'
} as const
export type AnonymousIdentityScalarFieldEnum = (typeof AnonymousIdentityScalarFieldEnum)[keyof typeof AnonymousIdentityScalarFieldEnum]
export const GameProjectScalarFieldEnum = {
id: 'id',
ownerId: 'ownerId',
slug: 'slug',
title: 'title',
status: 'status',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type GameProjectScalarFieldEnum = (typeof GameProjectScalarFieldEnum)[keyof typeof GameProjectScalarFieldEnum]
export const GameVersionScalarFieldEnum = {
id: 'id',
projectId: 'projectId',
versionNumber: 'versionNumber',
status: 'status',
configJson: 'configJson',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type GameVersionScalarFieldEnum = (typeof GameVersionScalarFieldEnum)[keyof typeof GameVersionScalarFieldEnum]
export const AssetScalarFieldEnum = {
id: 'id',
projectId: 'projectId',
kind: 'kind',
storageKey: 'storageKey',
mimeType: 'mimeType',
byteSize: 'byteSize',
sha256: 'sha256',
status: 'status',
metadataJson: 'metadataJson',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type AssetScalarFieldEnum = (typeof AssetScalarFieldEnum)[keyof typeof AssetScalarFieldEnum]
export const JobScalarFieldEnum = {
id: 'id',
actorId: 'actorId',
projectId: 'projectId',
type: 'type',
idempotencyKey: 'idempotencyKey',
status: 'status',
attempts: 'attempts',
maxAttempts: 'maxAttempts',
timeoutAt: 'timeoutAt',
nextRetryAt: 'nextRetryAt',
errorCode: 'errorCode',
leaseToken: 'leaseToken',
leasedBy: 'leasedBy',
leaseExpiresAt: 'leaseExpiresAt',
lockVersion: 'lockVersion',
targetType: 'targetType',
targetId: 'targetId',
targetScopeKey: 'targetScopeKey',
gameProjectId: 'gameProjectId',
gameVersionId: 'gameVersionId',
payloadJson: 'payloadJson',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type JobScalarFieldEnum = (typeof JobScalarFieldEnum)[keyof typeof JobScalarFieldEnum]
export const AuditLogScalarFieldEnum = {
id: 'id',
actorId: 'actorId',
action: 'action',
targetType: 'targetType',
targetId: 'targetId',
eventJson: 'eventJson',
createdAt: 'createdAt'
} as const
export type AuditLogScalarFieldEnum = (typeof AuditLogScalarFieldEnum)[keyof typeof AuditLogScalarFieldEnum]
export const MainCreationAgentSessionScalarFieldEnum = {
id: 'id',
creatorId: 'creatorId',
projectId: 'projectId',
versionId: 'versionId',
status: 'status',
contextSummary: 'contextSummary',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type MainCreationAgentSessionScalarFieldEnum = (typeof MainCreationAgentSessionScalarFieldEnum)[keyof typeof MainCreationAgentSessionScalarFieldEnum]
export const AgentTaskScalarFieldEnum = {
id: 'id',
sessionId: 'sessionId',
taskType: 'taskType',
subagentId: 'subagentId',
inputRef: 'inputRef',
outputRef: 'outputRef',
status: 'status',
timeoutAt: 'timeoutAt',
errorCode: 'errorCode',
auditLogId: 'auditLogId',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type AgentTaskScalarFieldEnum = (typeof AgentTaskScalarFieldEnum)[keyof typeof AgentTaskScalarFieldEnum]
export const ReviewRecordScalarFieldEnum = {
id: 'id',
gameVersionId: 'gameVersionId',
status: 'status',
decision: 'decision',
reasonCode: 'reasonCode',
decidedById: 'decidedById',
decidedAt: 'decidedAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ReviewRecordScalarFieldEnum = (typeof ReviewRecordScalarFieldEnum)[keyof typeof ReviewRecordScalarFieldEnum]
export const LifecycleEventScalarFieldEnum = {
eventId: 'eventId',
gameVersionId: 'gameVersionId',
event: 'event',
from: 'from',
to: 'to',
actorJson: 'actorJson',
requiredRole: 'requiredRole',
requiredRecordRefsJson: 'requiredRecordRefsJson',
auditEvent: 'auditEvent',
reasonCode: 'reasonCode',
occurredAt: 'occurredAt',
approval: 'approval',
requiredRecordsJson: 'requiredRecordsJson',
createdAt: 'createdAt'
} as const
export type LifecycleEventScalarFieldEnum = (typeof LifecycleEventScalarFieldEnum)[keyof typeof LifecycleEventScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
} as const
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
export const JsonNullValueInput = {
JsonNull: JsonNull
} as const
export type JsonNullValueInput = (typeof JsonNullValueInput)[keyof typeof JsonNullValueInput]
export const NullableJsonNullValueInput = {
DbNull: DbNull,
JsonNull: JsonNull
} as const
export type NullableJsonNullValueInput = (typeof NullableJsonNullValueInput)[keyof typeof NullableJsonNullValueInput]
export const QueryMode = {
default: 'default',
insensitive: 'insensitive'
} as const
export type QueryMode = (typeof QueryMode)[keyof typeof QueryMode]
export const NullsOrder = {
first: 'first',
last: 'last'
} as const
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]
export const JsonNullValueFilter = {
DbNull: DbNull,
JsonNull: JsonNull,
AnyNull: AnyNull
} as const
export type JsonNullValueFilter = (typeof JsonNullValueFilter)[keyof typeof JsonNullValueFilter]

View File

@ -0,0 +1,23 @@
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
/* eslint-disable */
// biome-ignore-all lint: generated file
// @ts-nocheck
/*
* This is a barrel export file for all models and their related types.
*
* 🟢 You can import this file directly.
*/
export type * from './models/User.js'
export type * from './models/UserRole.js'
export type * from './models/AnonymousIdentity.js'
export type * from './models/GameProject.js'
export type * from './models/GameVersion.js'
export type * from './models/Asset.js'
export type * from './models/Job.js'
export type * from './models/AuditLog.js'
export type * from './models/MainCreationAgentSession.js'
export type * from './models/AgentTask.js'
export type * from './models/ReviewRecord.js'
export type * from './models/LifecycleEvent.js'
export type * from './commonInputTypes.js'

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,19 @@
import { Test } from "@nestjs/testing";
import { describe, expect, it } from "vitest";
import { AppModule } from "./app.module.js";
import { HealthController } from "./health.controller.js";
describe("HealthController", () => {
it("is wired by AppModule and returns the API health payload", async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule]
}).compile();
const controller = moduleRef.get(HealthController);
expect(controller.check()).toEqual({
service: "@huijing/api",
status: "ok"
});
});
});

View File

@ -0,0 +1,17 @@
import { Controller, Get } from "@nestjs/common";
export interface HealthResponse {
readonly service: "@huijing/api";
readonly status: "ok";
}
@Controller("health")
export class HealthController {
@Get()
check(): HealthResponse {
return {
service: "@huijing/api",
status: "ok"
};
}
}

17
apps/api/src/main.ts Normal file
View File

@ -0,0 +1,17 @@
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { AppModule } from "./app.module.js";
export async function bootstrap(port = 3001): Promise<void> {
const app = await NestFactory.create(AppModule);
await app.listen(port);
}
const currentModulePath = fileURLToPath(import.meta.url);
const executedModulePath = process.argv[1] ? resolve(process.argv[1]) : undefined;
if (executedModulePath === currentModulePath) {
void bootstrap();
}

View File

@ -0,0 +1,247 @@
import { type INestApplication } from "@nestjs/common";
import { Test } from "@nestjs/testing";
import { PrismaPg } from "@prisma/adapter-pg";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AppModule } from "../../app.module.js";
import { PrismaClient } from "../../generated/prisma/client.js";
import { StorageBoundaryError } from "../storage/index.js";
import { S1_STORAGE_ADAPTER, type StorageObjectPlanner } from "./index.js";
const databaseUrl = process.env.DATABASE_URL ?? "postgresql://huijing:huijing@localhost:5432/huijing_dev?schema=public";
const adapter = new PrismaPg({ connectionString: databaseUrl });
const prisma = new PrismaClient({ adapter });
const runId = `task8-assets-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
type TestApp = {
readonly app: INestApplication;
readonly baseUrl: string;
};
async function createTestApp(storageRoot: string, storagePlanner?: StorageObjectPlanner): Promise<TestApp> {
process.env.S1_STORAGE_ROOT = storageRoot;
const builder = Test.createTestingModule({
imports: [AppModule]
});
if (storagePlanner) builder.overrideProvider(S1_STORAGE_ADAPTER).useValue(storagePlanner);
const moduleRef = await builder.compile();
const app = moduleRef.createNestApplication();
await app.init();
const server = await app.listen(0);
const address = server.address();
if (address === null || typeof address === "string") throw new Error("Nest test server did not expose a TCP port");
return { app, baseUrl: `http://127.0.0.1:${address.port}` };
}
async function requestJson(testApp: TestApp, requestPath: string, init: RequestInit = {}) {
const response = await fetch(`${testApp.baseUrl}${requestPath}`, {
...init,
headers: {
"content-type": "application/json",
...init.headers
}
});
const body = (await response.json().catch(() => null)) as unknown;
return { response, body };
}
async function login(testApp: TestApp, email: string): Promise<string> {
const response = await requestJson(testApp, "/auth/login", {
method: "POST",
body: JSON.stringify({ email })
});
expect(response.response.status).toBe(201);
return (response.body as { token: string }).token;
}
async function seedDb(): Promise<{ readonly ownedProjectId: string; readonly foreignProjectId: string }> {
await prisma.user.upsert({
where: { id: "seed-creator" },
update: {},
create: { id: "seed-creator", email: "creator@example.test", displayName: "Seed Creator" }
});
await prisma.user.upsert({
where: { id: "seed-creator-other" },
update: {},
create: { id: "seed-creator-other", email: "creator-other@example.test", displayName: "Seed Other Creator" }
});
const ownedProjectId = `${runId}-owned-project`;
const foreignProjectId = `${runId}-foreign-project`;
await prisma.gameProject.create({
data: { id: ownedProjectId, ownerId: "seed-creator", slug: `${runId}-owned`, title: "owned" }
});
await prisma.gameProject.create({
data: { id: foreignProjectId, ownerId: "seed-creator-other", slug: `${runId}-foreign`, title: "foreign" }
});
return { ownedProjectId, foreignProjectId };
}
async function cleanup(): Promise<void> {
await prisma.gameProject.deleteMany({ where: { id: { startsWith: runId } } });
}
describe("Asset HTTP APIs", () => {
let testApp: TestApp | undefined;
let storageRoot: string | undefined;
let previousStorageRoot: string | undefined;
beforeEach(async () => {
await cleanup();
previousStorageRoot = process.env.S1_STORAGE_ROOT;
storageRoot = await mkdtemp(path.join(os.tmpdir(), "huijing-task8-assets-"));
testApp = await createTestApp(storageRoot);
});
afterEach(async () => {
await testApp?.app.close();
testApp = undefined;
await cleanup();
if (storageRoot) await rm(storageRoot, { recursive: true, force: true });
storageRoot = undefined;
if (previousStorageRoot === undefined) {
delete process.env.S1_STORAGE_ROOT;
} else {
process.env.S1_STORAGE_ROOT = previousStorageRoot;
}
previousStorageRoot = undefined;
});
it("POST /assets/presign returns a local mock upload target for the owning creator and writes audit", async () => {
const { ownedProjectId } = await seedDb();
const token = await login(testApp!, "creator@example.test");
const presign = await requestJson(testApp!, "/assets/presign", {
method: "POST",
headers: { authorization: `Bearer ${token}` },
body: JSON.stringify({
projectId: ownedProjectId,
kind: "cover",
objectName: "cover.png",
mimeType: "image/png",
byteSize: 128,
sha256: "a".repeat(64)
})
});
expect(presign.response.status).toBe(201);
expect(presign.body).toMatchObject({
projectId: ownedProjectId,
storageKey: `owners/seed-creator/projects/${ownedProjectId}/cover.png`,
uploadUrl: `local://s1-storage/owners/seed-creator/projects/${ownedProjectId}/cover.png`,
method: "PUT"
});
await expect(prisma.auditLog.findFirst({ where: { targetId: ownedProjectId, action: "asset.presign.created" } })).resolves.toMatchObject({
actorId: "seed-creator",
targetType: "GameProject"
});
});
it("POST /assets/presign rejects foreign project access before any audit mutation", async () => {
const { foreignProjectId } = await seedDb();
const token = await login(testApp!, "creator@example.test");
const denied = await requestJson(testApp!, "/assets/presign", {
method: "POST",
headers: { authorization: `Bearer ${token}` },
body: JSON.stringify({
projectId: foreignProjectId,
kind: "cover",
objectName: "cover.png",
mimeType: "image/png",
byteSize: 128,
sha256: "a".repeat(64)
})
});
expect(denied.response.status).toBe(403);
expect(denied.body).toEqual({
code: "FORBIDDEN",
message: expect.any(String),
requestId: null,
details: expect.anything()
});
await expect(prisma.auditLog.count({ where: { targetId: foreignProjectId } })).resolves.toBe(0);
});
it("POST /assets/presign delegates key construction to the storage boundary planner", async () => {
const { ownedProjectId } = await seedDb();
const plannedInputs: unknown[] = [];
await testApp?.app.close();
testApp = await createTestApp(storageRoot!, {
planObject(input) {
plannedInputs.push(input);
return {
storageKey: "owners/from-storage-boundary/projects/fake/planned.png",
byteSize: input.byteSize,
sha256: input.expectedSha256.toLowerCase(),
mimeType: input.mimeType
};
}
});
const token = await login(testApp!, "creator@example.test");
const presign = await requestJson(testApp!, "/assets/presign", {
method: "POST",
headers: { authorization: `Bearer ${token}` },
body: JSON.stringify({
projectId: ownedProjectId,
kind: "cover",
objectName: "api-layer-must-not-parse%2Fthis.png",
mimeType: "image/png",
byteSize: 128,
sha256: "b".repeat(64)
})
});
expect(presign.response.status).toBe(201);
expect(presign.body).toMatchObject({
storageKey: "owners/from-storage-boundary/projects/fake/planned.png",
uploadUrl: "local://s1-storage/owners/from-storage-boundary/projects/fake/planned.png"
});
expect(plannedInputs).toEqual([
expect.objectContaining({
ownerId: "seed-creator",
projectId: ownedProjectId,
objectName: "api-layer-must-not-parse%2Fthis.png"
})
]);
});
it("POST /assets/presign maps storage boundary errors to structured API errors without audit mutation", async () => {
const { ownedProjectId } = await seedDb();
await testApp?.app.close();
testApp = await createTestApp(storageRoot!, {
planObject() {
throw new StorageBoundaryError("MIME_NOT_ALLOWED");
}
});
const token = await login(testApp!, "creator@example.test");
const beforeAuditCount = await prisma.auditLog.count({ where: { targetId: ownedProjectId, action: "asset.presign.created" } });
const denied = await requestJson(testApp!, "/assets/presign", {
method: "POST",
headers: { authorization: `Bearer ${token}` },
body: JSON.stringify({
projectId: ownedProjectId,
kind: "cover",
objectName: "cover.png",
mimeType: "image/png",
byteSize: 128,
sha256: "c".repeat(64)
})
});
expect(denied.response.status).toBe(400);
expect(denied.body).toEqual({
code: "MIME_NOT_ALLOWED",
message: expect.any(String),
requestId: null,
details: expect.anything()
});
await expect(prisma.auditLog.count({ where: { targetId: ownedProjectId, action: "asset.presign.created" } })).resolves.toBe(
beforeAuditCount
);
});
});

View File

@ -0,0 +1,167 @@
import {
BadRequestException,
Body,
Controller,
ForbiddenException,
Inject,
Injectable,
Module,
NotFoundException,
Post,
Req,
UseGuards
} from "@nestjs/common";
import { randomUUID } from "node:crypto";
import { AuthGuard, AuthModule, type AuthActor } from "../auth/index.js";
import { AuditService } from "../audit/index.js";
import { creatorOwnsResource } from "../rbac/index.js";
import { apiError, S1ApiRuntimeModule, S1PrismaClient } from "../projects/api-runtime.js";
import { LocalStorageAdapter, StorageBoundaryError, type PlanObjectInput, type PlanObjectResult } from "../storage/index.js";
type RequestWithActor = {
readonly actor: AuthActor;
};
type PresignBody = {
readonly projectId?: unknown;
readonly kind?: unknown;
readonly objectName?: unknown;
readonly mimeType?: unknown;
readonly byteSize?: unknown;
readonly sha256?: unknown;
};
type LocalUploadTarget = {
readonly projectId: string;
readonly kind: string;
readonly storageKey: string;
readonly uploadUrl: string;
readonly method: "PUT";
readonly headers: {
readonly "content-type": string;
readonly "x-sha256": string;
};
readonly maxBytes: number;
};
export interface StorageObjectPlanner {
planObject(input: PlanObjectInput): PlanObjectResult;
}
export const S1_STORAGE_ADAPTER = Symbol("S1_STORAGE_ADAPTER");
const maxUploadBytes = 10 * 1024 * 1024;
const allowedMimeTypes = ["image/png", "image/jpeg", "image/webp", "text/plain", "application/json"] as const;
function requireNonEmptyString(value: unknown, field: string): string {
if (typeof value !== "string" || value.trim() === "") {
throw new BadRequestException(apiError("INVALID_REQUEST", `${field} must be a non-empty string`, { field }));
}
return value.trim();
}
function requireInteger(value: unknown, field: string): number {
if (typeof value !== "number" || !Number.isInteger(value)) {
throw new BadRequestException(apiError("INVALID_REQUEST", `${field} must be an integer`, { field }));
}
return value;
}
@Injectable()
export class AssetsApiService {
constructor(
private readonly db: S1PrismaClient,
@Inject(S1_STORAGE_ADAPTER) private readonly storage: StorageObjectPlanner
) {}
async presign(actor: AuthActor, body: PresignBody): Promise<LocalUploadTarget> {
const projectId = requireNonEmptyString(body.projectId, "projectId");
const kind = requireNonEmptyString(body.kind, "kind");
const objectName = requireNonEmptyString(body.objectName, "objectName");
const mimeType = requireNonEmptyString(body.mimeType, "mimeType");
const byteSize = requireInteger(body.byteSize, "byteSize");
const sha256 = requireNonEmptyString(body.sha256, "sha256");
const project = await this.db.gameProject.findUnique({ where: { id: projectId } });
if (!project) throw new NotFoundException(apiError("NOT_FOUND", "Project not found", { projectId }));
if (!creatorOwnsResource(actor, project)) {
// 外部项目在任何 presign/audit 写入前拒绝,避免为越权尝试产生可误用的 upload target。
throw new ForbiddenException(apiError("FORBIDDEN", "Actor cannot presign assets for this project", { projectId }));
}
let planned: PlanObjectResult;
try {
planned = this.storage.planObject({
ownerId: actor.id,
projectId,
objectName,
mimeType,
byteSize,
expectedSha256: sha256
});
} catch (error) {
if (error instanceof StorageBoundaryError) {
throw new BadRequestException(apiError(error.code, error.message, { storageBoundary: true }));
}
throw error;
}
await new AuditService({ db: this.db }).append({
id: randomUUID(),
actorId: actor.id,
action: "asset.presign.created",
targetType: "GameProject",
targetId: projectId,
eventJson: {
kind,
storageKey: planned.storageKey,
mimeType: planned.mimeType,
byteSize: planned.byteSize,
sha256: planned.sha256
}
});
return {
projectId,
kind,
storageKey: planned.storageKey,
uploadUrl: `local://s1-storage/${planned.storageKey}`,
method: "PUT",
headers: {
"content-type": planned.mimeType,
"x-sha256": planned.sha256
},
maxBytes: maxUploadBytes
};
}
}
@Controller()
@UseGuards(AuthGuard)
export class AssetsController {
constructor(private readonly service: AssetsApiService) {}
@Post("assets/presign")
presign(@Req() request: RequestWithActor, @Body() body: PresignBody): Promise<LocalUploadTarget> {
return this.service.presign(request.actor, body);
}
}
@Module({
imports: [S1ApiRuntimeModule, AuthModule],
controllers: [AssetsController],
providers: [
AssetsApiService,
{
provide: S1_STORAGE_ADAPTER,
useFactory: () =>
new LocalStorageAdapter({
root: process.env.S1_STORAGE_ROOT ?? ".s1-storage",
allowedMimeTypes,
maxBytes: maxUploadBytes
})
}
],
exports: [S1_STORAGE_ADAPTER]
})
export class AssetsModule {}

View File

@ -0,0 +1,134 @@
import { type INestApplication } from "@nestjs/common";
import { Test } from "@nestjs/testing";
import { PrismaPg } from "@prisma/adapter-pg";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AppModule } from "../../app.module.js";
import { PrismaClient } from "../../generated/prisma/client.js";
const databaseUrl = process.env.DATABASE_URL ?? "postgresql://huijing:huijing@localhost:5432/huijing_dev?schema=public";
const adapter = new PrismaPg({ connectionString: databaseUrl });
const prisma = new PrismaClient({ adapter });
const runId = `task8-audit-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
type TestApp = {
readonly app: INestApplication;
readonly baseUrl: string;
};
async function createTestApp(): Promise<TestApp> {
const moduleRef = await Test.createTestingModule({
imports: [AppModule]
}).compile();
const app = moduleRef.createNestApplication();
await app.init();
const server = await app.listen(0);
const address = server.address();
if (address === null || typeof address === "string") throw new Error("Nest test server did not expose a TCP port");
return { app, baseUrl: `http://127.0.0.1:${address.port}` };
}
async function requestJson(testApp: TestApp, requestPath: string, init: RequestInit = {}) {
const response = await fetch(`${testApp.baseUrl}${requestPath}`, {
...init,
headers: {
"content-type": "application/json",
...init.headers
}
});
const body = (await response.json().catch(() => null)) as unknown;
return { response, body };
}
async function login(testApp: TestApp, email: string): Promise<string> {
const response = await requestJson(testApp, "/auth/login", {
method: "POST",
body: JSON.stringify({ email })
});
expect(response.response.status).toBe(201);
return (response.body as { token: string }).token;
}
async function ensureSeedUsers(): Promise<void> {
await prisma.user.upsert({
where: { id: "seed-creator" },
update: {},
create: { id: "seed-creator", email: "creator@example.test", displayName: "Seed Creator" }
});
await prisma.user.upsert({
where: { id: "seed-operator" },
update: {},
create: { id: "seed-operator", email: "operator@example.test", displayName: "Seed Operator" }
});
await prisma.user.upsert({
where: { id: "seed-admin" },
update: {},
create: { id: "seed-admin", email: "admin@example.test", displayName: "Seed Admin" }
});
}
async function cleanup(): Promise<void> {
await prisma.gameProject.deleteMany({ where: { id: { startsWith: runId } } });
}
describe("Audit HTTP APIs", () => {
let testApp: TestApp | undefined;
beforeEach(async () => {
await cleanup();
await ensureSeedUsers();
testApp = await createTestApp();
});
afterEach(async () => {
await testApp?.app.close();
testApp = undefined;
await cleanup();
});
it("GET /audit-logs requires operator/admin and returns S1 foundation audit facts", async () => {
await prisma.auditLog.create({
data: {
id: `${runId}-audit`,
actorId: "seed-creator",
action: "project.created",
targetType: "GameProject",
targetId: `${runId}-project`,
eventJson: { source: "task8-test" }
}
});
const creatorToken = await login(testApp!, "creator@example.test");
const operatorToken = await login(testApp!, "operator@example.test");
const adminToken = await login(testApp!, "admin@example.test");
const denied = await requestJson(testApp!, "/audit-logs", {
headers: { authorization: `Bearer ${creatorToken}` }
});
expect(denied.response.status).toBe(403);
expect(denied.body).toEqual({
code: "FORBIDDEN",
message: expect.any(String),
requestId: null,
details: expect.anything()
});
const operatorRead = await requestJson(testApp!, "/audit-logs", {
headers: { authorization: `Bearer ${operatorToken}` }
});
expect(operatorRead.response.status).toBe(200);
expect(operatorRead.body).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: `${runId}-audit`, action: "project.created", targetType: "GameProject" })
])
);
const adminRead = await requestJson(testApp!, "/audit-logs", {
headers: { authorization: `Bearer ${adminToken}` }
});
expect(adminRead.response.status).toBe(200);
expect(adminRead.body).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: `${runId}-audit`, action: "project.created", targetId: `${runId}-project` })
])
);
});
});

View File

@ -0,0 +1,189 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../../generated/prisma/client.js";
import {
AuditMutationRejectedError,
AuditService,
findAuditBoundaryViolations,
scanAuditBoundary
} from "./index.js";
import { afterAll, describe, expect, it } from "vitest";
const databaseUrl = process.env.DATABASE_URL ?? "postgresql://huijing:huijing@localhost:5432/huijing_dev?schema=public";
const adapter = new PrismaPg({ connectionString: databaseUrl });
const prisma = new PrismaClient({ adapter });
const runId = `task7-audit-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const rollbackToken = Symbol("audit rollback");
let savepointSequence = 0;
type DbClient = Prisma.TransactionClient;
async function withRollback(callback: (tx: DbClient) => Promise<void>): Promise<void> {
try {
await prisma.$transaction(async (tx) => {
await callback(tx);
throw rollbackToken;
});
} catch (error) {
if (error !== rollbackToken) throw error;
}
}
async function expectPrismaRejectedInSavepoint(
db: DbClient,
action: () => Promise<unknown>,
pattern: RegExp
): Promise<void> {
const savepointName = `task7_audit_sp_${++savepointSequence}`;
await db.$executeRawUnsafe(`SAVEPOINT ${savepointName}`);
let caught: unknown;
try {
await action();
} catch (error) {
caught = error;
}
await db.$executeRawUnsafe(`ROLLBACK TO SAVEPOINT ${savepointName}`);
await db.$executeRawUnsafe(`RELEASE SAVEPOINT ${savepointName}`);
if (caught === undefined) throw new Error(`Expected Prisma action to reject with ${pattern}`);
expect(() => {
throw caught;
}).toThrow(pattern);
}
async function seedActor(db: DbClient, suffix: string) {
return db.user.create({
data: {
id: `${runId}-${suffix}-actor`,
email: `${runId}-${suffix}@example.test`,
displayName: `${suffix} actor`
}
});
}
describe("AuditService", () => {
afterAll(async () => {
await prisma.$disconnect();
});
it("appends audit entries and exposes no update/delete path", async () => {
await withRollback(async (tx) => {
const actor = await seedActor(tx, "append");
const service = new AuditService({ db: tx });
const audit = await service.append({
id: `${runId}-append-audit`,
actorId: actor.id,
action: "job.cancel.blocked",
targetType: "Job",
targetId: `${runId}-job`,
eventJson: { reasonCode: "NOT_QUEUED" }
});
expect(audit.action).toBe("job.cancel.blocked");
expect("update" in service).toBe(false);
expect("delete" in service).toBe(false);
await expect(service.rejectMutation("update")).rejects.toMatchObject<AuditMutationRejectedError>({
code: "AUDIT_APPEND_ONLY"
});
await expect(service.rejectMutation("delete")).rejects.toMatchObject<AuditMutationRejectedError>({
code: "AUDIT_APPEND_ONLY"
});
});
});
it("DB trigger rejects direct Prisma auditLog.update/delete", async () => {
await withRollback(async (tx) => {
const actor = await seedActor(tx, "trigger");
const service = new AuditService({ db: tx });
const audit = await service.append({
id: `${runId}-trigger-audit`,
actorId: actor.id,
action: "audit.trigger",
targetType: "AuditLog",
targetId: "self",
eventJson: {}
});
await expectPrismaRejectedInSavepoint(
tx,
() => tx.auditLog.update({ where: { id: audit.id }, data: { action: "changed" } }),
/AuditLog is append-only/
);
await expectPrismaRejectedInSavepoint(tx, () => tx.auditLog.delete({ where: { id: audit.id } }), /AuditLog is append-only/);
});
});
it("static scan fails if auditLog.update/delete appears in S1 application code", async () => {
expect(
findAuditBoundaryViolations([
{
path: "apps/api/src/modules/projects/project.service.ts",
content: "await db.auditLog.update({ where: { id }, data: {} });"
},
{
path: "apps/api/src/modules/audit/index.ts",
content: "await db.auditLog.create({ data });"
},
{
path: "apps/api/src/modules/audit/bad.ts",
content: "await db.auditLog.update({ where: { id }, data: {} });"
}
])
).toEqual([
{ path: "apps/api/src/modules/projects/project.service.ts", reason: "AUDIT_UPDATE_DELETE_FORBIDDEN" },
{ path: "apps/api/src/modules/audit/bad.ts", reason: "AUDIT_UPDATE_DELETE_FORBIDDEN" }
]);
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "huijing-audit-scan-"));
await writeFile(
path.join(tempRoot, "bad.ts"),
"async function bad(db) { await db.auditLog.delete({ where: { id: 'x' } }); }",
"utf8"
);
await expect(scanAuditBoundary(tempRoot)).resolves.toEqual([
{ path: "bad.ts", reason: "AUDIT_UPDATE_DELETE_FORBIDDEN" }
]);
await rm(tempRoot, { recursive: true, force: true });
await expect(scanAuditBoundary(process.cwd())).resolves.toEqual([]);
});
it("high-risk blocked actions can write audit facts without mutating target state", async () => {
const actor = await prisma.$transaction((tx) => seedActor(tx, "blocked"));
await expect(
prisma.$transaction(async (tx) => {
const service = new AuditService({ db: prisma });
await service.append({
id: `${runId}-blocked-audit`,
actorId: actor.id,
action: "asset.upload.blocked",
targetType: "Asset",
targetId: `${runId}-asset`,
eventJson: { reasonCode: "MIME_NOT_ALLOWED" }
});
await tx.asset.create({
data: {
id: `${runId}-asset`,
projectId: "missing-project",
kind: "image",
storageKey: "bad",
mimeType: "application/x-msdownload",
byteSize: 1,
sha256: "0".repeat(64)
}
});
})
).rejects.toThrow();
await expect(prisma.auditLog.findUnique({ where: { id: `${runId}-blocked-audit` } })).resolves.toMatchObject({
action: "asset.upload.blocked",
targetId: `${runId}-asset`
});
await expect(prisma.asset.findUnique({ where: { id: `${runId}-asset` } })).resolves.toBeNull();
});
});

View File

@ -0,0 +1,271 @@
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { Controller, ForbiddenException, Get, Injectable, Module, Req, UseGuards } from "@nestjs/common";
import { Prisma, type AuditLog } from "../../generated/prisma/client.js";
import { AuthGuard, AuthModule, type AuthActor } from "../auth/index.js";
import { canReadReviewFoundationData } from "../rbac/index.js";
import { apiError, S1ApiRuntimeModule, S1PrismaClient } from "../projects/api-runtime.js";
export type AppendAuditInput = {
readonly id: string;
readonly actorId: string;
readonly action: string;
readonly targetType: string;
readonly targetId: string;
readonly eventJson: Prisma.InputJsonValue;
};
export type AuditServiceOptions = {
readonly db: Pick<Prisma.TransactionClient, "auditLog">;
};
export type AuditBoundaryViolation = {
readonly path: string;
readonly reason: "AUDIT_UPDATE_DELETE_FORBIDDEN";
};
type RequestWithActor = {
readonly actor: AuthActor;
};
export type AuditLogDto = {
readonly id: string;
readonly actorId: string;
readonly action: string;
readonly targetType: string;
readonly targetId: string;
readonly eventJson: unknown;
readonly createdAt: string;
};
type ScanFile = {
readonly path: string;
readonly content: string;
};
const generatedPrismaPrefix = "apps/api/src/generated/prisma/";
const historicalAuditTriggerTest = "apps/api/src/prisma.schema.spec.ts";
const ignoredScanSegments = new Set(["node_modules", "dist", ".vite", "coverage", ".next"]);
export class AuditMutationRejectedError extends Error {
readonly code = "AUDIT_APPEND_ONLY";
constructor(action: "update" | "delete") {
super(`AuditLog is append-only; ${action} is not allowed`);
this.name = "AuditMutationRejectedError";
}
}
export class AuditService {
private readonly db: Pick<Prisma.TransactionClient, "auditLog">;
constructor(options: AuditServiceOptions) {
this.db = options.db;
}
async append(input: AppendAuditInput): Promise<AuditLog> {
return this.db.auditLog.create({
data: {
id: input.id,
actorId: input.actorId,
action: input.action,
targetType: input.targetType,
targetId: input.targetId,
eventJson: input.eventJson
}
});
}
async rejectMutation(action: "update" | "delete"): Promise<never> {
// S1 service 层只暴露 append测试用这个显式拒绝入口证明没有静默 update/delete 路径。
throw new AuditMutationRejectedError(action);
}
}
function auditLogDto(auditLog: AuditLog): AuditLogDto {
return {
id: auditLog.id,
actorId: auditLog.actorId,
action: auditLog.action,
targetType: auditLog.targetType,
targetId: auditLog.targetId,
eventJson: auditLog.eventJson,
createdAt: auditLog.createdAt.toISOString()
};
}
@Injectable()
export class AuditApiService {
constructor(private readonly db: S1PrismaClient) {}
async listAuditLogs(actor: AuthActor): Promise<AuditLogDto[]> {
if (!canReadReviewFoundationData(actor, "audit-log")) {
// S1 audit endpoint 是审核基础数据读面,不是 creator 自助查询或管理 CRUD。
throw new ForbiddenException(apiError("FORBIDDEN", "Operator or admin role is required for audit logs"));
}
const auditLogs = await this.db.auditLog.findMany({
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take: 200
});
return auditLogs.map(auditLogDto);
}
}
@Controller()
@UseGuards(AuthGuard)
export class AuditController {
constructor(private readonly service: AuditApiService) {}
@Get("audit-logs")
listAuditLogs(@Req() request: RequestWithActor): Promise<AuditLogDto[]> {
return this.service.listAuditLogs(request.actor);
}
}
@Module({
imports: [S1ApiRuntimeModule, AuthModule],
controllers: [AuditController],
providers: [AuditApiService],
exports: [AuditApiService]
})
export class AuditModule {}
function isIgnoredScanPath(filePath: string): boolean {
return (
filePath.split("/").some((segment) => ignoredScanSegments.has(segment)) ||
filePath.includes("/generated/prisma/") ||
filePath.endsWith(".spec.ts")
);
}
function findMatchingParen(content: string, openParenIndex: number): number {
let depth = 0;
for (let index = openParenIndex; index < content.length; index += 1) {
const char = content[index];
if (char === "(") depth += 1;
if (char === ")") {
depth -= 1;
if (depth === 0) return index;
}
}
return -1;
}
function maskStringLiterals(content: string): string {
let output = "";
let index = 0;
while (index < content.length) {
const quote = content[index];
if (quote !== '"' && quote !== "'" && quote !== "`") {
output += quote;
index += 1;
continue;
}
output += quote;
index += 1;
while (index < content.length) {
const char = content[index];
if (char === "\\") {
output += " ";
index += 1;
if (index < content.length) {
output += " ";
index += 1;
}
continue;
}
if (char === quote) {
output += quote;
index += 1;
break;
}
output += " ";
index += 1;
}
}
return output;
}
function isAuditTriggerAssertion(filePath: string, content: string, structure: string, mutationIndex: number): boolean {
if (filePath !== historicalAuditTriggerTest && filePath !== "apps/api/src/modules/audit/audit.spec.ts") return false;
let searchCursor = mutationIndex;
while (searchCursor >= 0) {
const assertionIndex = structure.lastIndexOf("expectPrismaRejectedInSavepoint", searchCursor);
if (assertionIndex === -1) return false;
const openParenIndex = structure.indexOf("(", assertionIndex);
if (openParenIndex === -1 || openParenIndex > mutationIndex) {
searchCursor = assertionIndex - 1;
continue;
}
const closeParenIndex = findMatchingParen(structure, openParenIndex);
if (closeParenIndex === -1 || closeParenIndex < mutationIndex) {
searchCursor = assertionIndex - 1;
continue;
}
const assertionSource = content.slice(openParenIndex, closeParenIndex + 1);
return /AuditLog is append-only/.test(assertionSource);
}
return false;
}
function hasAuditMutation(filePath: string, content: string): boolean {
const structure = maskStringLiterals(content);
const mutationPattern = /\.auditLog\.(?:update|updateMany|updateManyAndReturn|upsert|delete|deleteMany)\s*\(/g;
for (const match of structure.matchAll(mutationPattern)) {
if (typeof match.index === "number" && isAuditTriggerAssertion(filePath, content, structure, match.index)) continue;
return true;
}
return false;
}
export function findAuditBoundaryViolations(files: readonly ScanFile[]): AuditBoundaryViolation[] {
const violations: AuditBoundaryViolation[] = [];
for (const file of files) {
const normalizedPath = file.path.split(path.sep).join("/");
if (isIgnoredScanPath(normalizedPath)) continue;
if (normalizedPath.startsWith(generatedPrismaPrefix) || normalizedPath.startsWith("src/generated/prisma/")) continue;
if (hasAuditMutation(normalizedPath, file.content)) {
violations.push({ path: normalizedPath, reason: "AUDIT_UPDATE_DELETE_FORBIDDEN" });
}
}
return violations;
}
async function collectFiles(root: string, relativeDir = ""): Promise<ScanFile[]> {
const absoluteDir = path.join(root, relativeDir);
const entries = await readdir(absoluteDir, { withFileTypes: true }).catch(() => []);
const files: ScanFile[] = [];
for (const entry of entries) {
if (entry.isDirectory() && ignoredScanSegments.has(entry.name)) continue;
const relativePath = path.join(relativeDir, entry.name);
const absolutePath = path.join(root, relativePath);
if (entry.isDirectory()) files.push(...(await collectFiles(root, relativePath)));
if (entry.isFile() && /\.(?:ts|tsx|js|mjs|cjs)$/.test(entry.name)) {
files.push({ path: relativePath, content: await readFile(absolutePath, "utf8") });
}
}
return files;
}
export async function scanAuditBoundary(root: string): Promise<AuditBoundaryViolation[]> {
const files = root.endsWith("games-development-ai")
? [
...(await collectFiles(root, "apps/api/src/modules")),
...(await collectFiles(root, "apps/worker/src"))
]
: await collectFiles(root);
return findAuditBoundaryViolations(files);
}

View File

@ -0,0 +1,118 @@
import { type INestApplication, Controller, Get, UseGuards } from "@nestjs/common";
import { Test } from "@nestjs/testing";
import { afterEach, describe, expect, it } from "vitest";
import { AppModule } from "../../app.module.js";
import { AuthGuard, AuthModule } from "./index.js";
@Controller("task6-protected")
class Task6ProtectedController {
@Get()
@UseGuards(AuthGuard)
check() {
return { ok: true };
}
}
type Task6TestApp = {
readonly app: INestApplication;
readonly baseUrl: string;
};
async function createTestApp(): Promise<Task6TestApp> {
const moduleRef = await Test.createTestingModule({
imports: [AppModule, AuthModule],
controllers: [Task6ProtectedController]
}).compile();
const app = moduleRef.createNestApplication();
await app.init();
const server = await app.listen(0);
const address = server.address();
if (address === null || typeof address === "string") throw new Error("Nest test server did not expose a TCP port");
return { app, baseUrl: `http://127.0.0.1:${address.port}` };
}
async function requestJson(testApp: Task6TestApp, path: string, init: RequestInit = {}) {
const response = await fetch(`${testApp.baseUrl}${path}`, {
...init,
headers: {
"content-type": "application/json",
...init.headers
}
});
const body = (await response.json().catch(() => null)) as unknown;
return { response, body };
}
describe("Auth HTTP boundary", () => {
let testApp: Task6TestApp | undefined;
afterEach(async () => {
await testApp?.app.close();
testApp = undefined;
});
it("POST /auth/login returns a usable token for a seeded user and GET /me returns the actor roles", async () => {
testApp = await createTestApp();
const login = await requestJson(testApp, "/auth/login", {
method: "POST",
body: JSON.stringify({ email: "creator@example.test" })
});
expect(login.response.status).toBe(201);
expect(login.body).toEqual({
token: expect.any(String),
actor: {
id: "seed-creator",
email: "creator@example.test",
displayName: "Seed Creator",
roles: ["creator"]
}
});
const token = (login.body as { token: string }).token;
const me = await requestJson(testApp, "/me", {
headers: { authorization: `Bearer ${token}` }
});
expect(me.response.status).toBe(200);
expect(me.body).toEqual({
id: "seed-creator",
email: "creator@example.test",
displayName: "Seed Creator",
roles: ["creator"]
});
});
it("POST /auth/logout invalidates the session token used by tests", async () => {
testApp = await createTestApp();
const login = await requestJson(testApp, "/auth/login", {
method: "POST",
body: JSON.stringify({ email: "operator@example.test" })
});
const token = (login.body as { token: string }).token;
const logout = await requestJson(testApp, "/auth/logout", {
method: "POST",
headers: { authorization: `Bearer ${token}` }
});
expect(logout.response.status).toBe(201);
expect(logout.body).toEqual({ ok: true });
const meAfterLogout = await requestJson(testApp, "/me", {
headers: { authorization: `Bearer ${token}` }
});
expect(meAfterLogout.response.status).toBe(401);
expect(meAfterLogout.body).toMatchObject({ code: "UNAUTHENTICATED" });
});
it("rejects anonymous access to a protected test route without calling later project/version endpoints", async () => {
testApp = await createTestApp();
const protectedResponse = await requestJson(testApp, "/task6-protected");
expect(protectedResponse.response.status).toBe(403);
expect(protectedResponse.body).toMatchObject({ code: "FORBIDDEN" });
});
});

View File

@ -0,0 +1,181 @@
import { randomBytes } from "node:crypto";
import {
Body,
Controller,
ForbiddenException,
Get,
Headers,
Injectable,
Module,
Post,
UnauthorizedException,
type CanActivate,
type ExecutionContext
} from "@nestjs/common";
import type { AuthenticatedActor } from "../rbac/index.js";
export type AuthActor = AuthenticatedActor & {
readonly email: string;
readonly displayName: string;
};
export type LoginResult = {
readonly token: string;
readonly actor: AuthActor;
};
type LoginBody = {
readonly email?: unknown;
readonly userId?: unknown;
};
type RequestLike = {
readonly headers?: {
readonly authorization?: string | readonly string[];
};
actor?: AuthActor;
};
const seededUsers: readonly AuthActor[] = [
{
id: "seed-admin",
email: "admin@example.test",
displayName: "Seed Admin",
roles: ["admin"]
},
{
id: "seed-operator",
email: "operator@example.test",
displayName: "Seed Operator",
roles: ["operator"]
},
{
id: "seed-creator",
email: "creator@example.test",
displayName: "Seed Creator",
roles: ["creator"]
},
{
id: "seed-player",
email: "player@example.test",
displayName: "Seed Player",
roles: ["player"]
}
];
function apiError(code: string, message: string, details: unknown = null) {
return {
code,
message,
requestId: null,
details
};
}
function normalizeAuthorizationHeader(header: string | readonly string[] | undefined): string | undefined {
if (typeof header === "string") return header;
return header?.[0];
}
@Injectable()
export class AuthService {
private readonly usersById = new Map(seededUsers.map((user) => [user.id, user]));
private readonly usersByEmail = new Map(seededUsers.map((user) => [user.email, user]));
private readonly sessions = new Map<string, string>();
login(body: LoginBody): LoginResult {
const actor = this.findSeededUser(body);
if (!actor) {
throw new UnauthorizedException(apiError("AUTH_INVALID_CREDENTIALS", "Unknown local S1 test user"));
}
const token = randomBytes(32).toString("base64url");
this.sessions.set(token, actor.id);
return { token, actor };
}
logout(authorization: string | readonly string[] | undefined): { readonly ok: true } {
const token = this.extractBearerToken(authorization);
if (!token || !this.sessions.delete(token)) {
throw new UnauthorizedException(apiError("UNAUTHENTICATED", "Session token is missing or invalid"));
}
return { ok: true };
}
getActorFromAuthorizationHeader(authorization: string | readonly string[] | undefined): AuthActor | null {
const token = this.extractBearerToken(authorization);
if (!token) return null;
const userId = this.sessions.get(token);
return userId ? this.usersById.get(userId) ?? null : null;
}
requireActorFromAuthorizationHeader(authorization: string | readonly string[] | undefined): AuthActor {
const actor = this.getActorFromAuthorizationHeader(authorization);
if (!actor) {
throw new UnauthorizedException(apiError("UNAUTHENTICATED", "Session token is missing or invalid"));
}
return actor;
}
private findSeededUser(body: LoginBody): AuthActor | undefined {
if (typeof body.email === "string") return this.usersByEmail.get(body.email);
if (typeof body.userId === "string") return this.usersById.get(body.userId);
return undefined;
}
private extractBearerToken(authorization: string | readonly string[] | undefined): string | null {
const header = normalizeAuthorizationHeader(authorization);
if (!header) return null;
const [scheme, token, extra] = header.trim().split(/\s+/);
if (scheme !== "Bearer" || !token || extra) return null;
return token;
}
}
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private readonly authService: AuthService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<RequestLike>();
const actor = this.authService.getActorFromAuthorizationHeader(request.headers?.authorization);
if (!actor) {
// 受保护路由必须在服务端拒绝匿名访问S6 之前不把 public feed 行为混入这里。
throw new ForbiddenException(apiError("FORBIDDEN", "Authenticated actor is required"));
}
request.actor = actor;
return true;
}
}
@Controller()
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post("auth/login")
login(@Body() body: LoginBody): LoginResult {
return this.authService.login(body);
}
@Post("auth/logout")
logout(@Headers("authorization") authorization: string | readonly string[] | undefined): { readonly ok: true } {
return this.authService.logout(authorization);
}
@Get("me")
me(@Headers("authorization") authorization: string | readonly string[] | undefined): AuthActor {
return this.authService.requireActorFromAuthorizationHeader(authorization);
}
}
@Module({
controllers: [AuthController],
providers: [AuthService, AuthGuard],
exports: [AuthService, AuthGuard]
})
export class AuthModule {}

View File

@ -0,0 +1,62 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { HarnessGateService, parseHarnessGateConfig } from "./index.js";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../../..");
const fixturePath = (...segments: string[]) => path.join(repoRoot, "harness", "fixtures", ...segments);
async function readFixture(...segments: string[]): Promise<unknown> {
return JSON.parse(await readFile(fixturePath(...segments), "utf8"));
}
describe("HarnessGateService", () => {
it("uses the S0 CLI client to accept a real passing contract fixture", async () => {
const gate = new HarnessGateService();
await expect(
gate.validateContract("GameIR", await readFixture("mvp", "valid", "simulation-game-ir-valid.json"))
).resolves.toEqual({ ok: true, reasonCode: null });
});
it("preserves S0 reasonCode for a real failing contract fixture", async () => {
const gate = new HarnessGateService();
await expect(
gate.validateContract(
"ValidationReport",
await readFixture("mvp", "invalid", "game-logic-validation-report-web-only-sdk-invalid.json")
)
).resolves.toEqual({ ok: false, reasonCode: "WEB_ONLY_SDK_USAGE" });
});
it("accepts the real S0 review_approved transition fixture", async () => {
const gate = new HarnessGateService();
await expect(
gate.validateTransition("review_approved", await readFixture("lifecycle", "publish-approved-valid.json"))
).resolves.toEqual({ ok: true, reasonCode: null });
});
it("parses HARNESS_CLI_TIMEOUT_MS from the API env boundary with a 5000ms default", () => {
expect(parseHarnessGateConfig({})).toEqual({ timeoutMs: 5_000 });
expect(parseHarnessGateConfig({ HARNESS_CLI_TIMEOUT_MS: "17" })).toEqual({ timeoutMs: 17 });
expect(() => parseHarnessGateConfig({ HARNESS_CLI_TIMEOUT_MS: "0" })).toThrow(/HARNESS_CLI_TIMEOUT_MS/);
});
it("injects non-default env timeout into the harness client", async () => {
const gate = new HarnessGateService(parseHarnessGateConfig({ HARNESS_CLI_TIMEOUT_MS: "1" }));
await expect(
gate.validateContract("GameIR", await readFixture("mvp", "valid", "simulation-game-ir-valid.json"))
).resolves.toEqual({ ok: false, reasonCode: "HARNESS_TIMEOUT" });
});
it("delegates CLI runtime ownership to the harness-client runtime subpath", async () => {
const source = await readFile(path.join(repoRoot, "apps/api/src/modules/harness-gate/index.ts"), "utf8");
expect(source).toContain('from "@huijing/harness-client/runtime"');
expect(source).not.toMatch(/node:child_process|mkdtemp|writeFile|validate-harness\.mjs|JSON\.parse/);
});
});

View File

@ -0,0 +1,40 @@
import type { HarnessClient, HarnessGateResult } from "@huijing/harness-client";
import { createHarnessClient } from "@huijing/harness-client/runtime";
export type { HarnessClient, HarnessGateResult };
export type HarnessGateConfig = {
readonly timeoutMs: number;
};
const defaultHarnessTimeoutMs = 5_000;
export function parseHarnessGateConfig(env: { readonly HARNESS_CLI_TIMEOUT_MS?: string } = process.env): HarnessGateConfig {
const rawTimeout = env.HARNESS_CLI_TIMEOUT_MS;
const timeoutMs = rawTimeout === undefined || rawTimeout.trim() === "" ? defaultHarnessTimeoutMs : Number(rawTimeout);
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
throw new Error("HARNESS_CLI_TIMEOUT_MS must be a positive integer");
}
return { timeoutMs };
}
export class HarnessGateService implements HarnessClient {
private readonly client: HarnessClient;
constructor(clientOrConfig?: HarnessClient | HarnessGateConfig) {
this.client =
clientOrConfig && "validateContract" in clientOrConfig
? clientOrConfig
: createHarnessClient(clientOrConfig ?? parseHarnessGateConfig());
}
validateContract(contract: string, payload: unknown): Promise<HarnessGateResult> {
return this.client.validateContract(contract, payload);
}
validateTransition(event: string, payload: unknown): Promise<HarnessGateResult> {
return this.client.validateTransition(event, payload);
}
}

View File

@ -0,0 +1,982 @@
import { randomUUID } from "node:crypto";
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import {
Controller,
ForbiddenException,
Get,
Inject,
Injectable,
Module,
NotFoundException,
Param,
Req,
UseGuards,
type OnModuleDestroy
} from "@nestjs/common";
import { Prisma, type Job } from "../../generated/prisma/client.js";
import { AuthGuard, AuthModule, type AuthActor } from "../auth/index.js";
import { AuditService } from "../audit/index.js";
import { BullMqQueueAdapter, InMemoryQueueAdapter, QueueUnavailableError } from "../queue/index.js";
import { apiError, S1ApiRuntimeModule, S1PrismaClient } from "../projects/api-runtime.js";
import type { QueueAdapter } from "../queue/index.js";
export type JobTarget =
| {
readonly type: "project";
readonly id: string;
}
| {
readonly type: "version";
readonly id: string;
};
export type EnqueueScopedJobInput = {
readonly id: string;
readonly actorId: string;
readonly projectId: string;
readonly type: string;
readonly idempotencyKey: string;
readonly target: JobTarget;
readonly payloadJson: Prisma.InputJsonValue;
readonly maxAttempts?: number;
readonly timeoutMs?: number;
readonly onJobEnqueueFailed?: (db: Prisma.TransactionClient, job: Job, error: unknown) => Promise<void>;
};
export type EnqueueScopedJobResult = {
readonly job: Job;
readonly created: boolean;
readonly enqueued: boolean;
};
export type JobExecutionStoreOptions = {
readonly db: Prisma.TransactionClient;
readonly now?: () => Date;
readonly leaseMs?: number;
readonly retryDelayMs?: number;
};
export type JobExecutionErrorCode =
| "TARGET_NOT_FOUND"
| "TARGET_PROJECT_MISMATCH"
| "JOB_NOT_FOUND"
| "STALE_JOB_LEASE"
| "JOB_CANCEL_NOT_ALLOWED"
| "JOB_ENQUEUE_REQUIRES_ROOT_CLIENT";
export type JobExecutionBoundaryViolation = {
readonly path: string;
readonly reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE";
};
type RequestWithActor = {
readonly actor: AuthActor;
};
export type JobDto = {
readonly id: string;
readonly actorId: string;
readonly projectId: string;
readonly type: string;
readonly status: string;
readonly attempts: number;
readonly maxAttempts: number;
readonly targetType: string;
readonly targetId: string;
readonly payloadJson: unknown;
readonly errorCode: string | null;
readonly createdAt: string;
readonly updatedAt: string;
};
type ScanFile = {
readonly path: string;
readonly content: string;
};
const jobExecutionStorePath = "apps/api/src/modules/jobs/index.ts";
const ignoredScanSegments = new Set(["node_modules", "dist", ".vite", "coverage", ".next", "generated"]);
const jobRuntimeFields = [
"status",
"attempts",
"nextRetryAt",
"errorCode",
"leaseToken",
"leasedBy",
"leaseExpiresAt",
"lockVersion",
"timeoutAt"
];
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export const S1_QUEUE_ADAPTER = Symbol("S1_QUEUE_ADAPTER");
export const S1_QUEUE_ADAPTER_INNER = Symbol("S1_QUEUE_ADAPTER_INNER");
export type QueueEnv = {
readonly QUEUE_ADAPTER?: string;
readonly REDIS_URL?: string;
};
class UnavailableQueueAdapter implements QueueAdapter {
constructor(private readonly reason: string) {}
async enqueue(): Promise<void> {
throw new QueueUnavailableError(this.reason);
}
async process(): Promise<void> {
throw new QueueUnavailableError(this.reason);
}
async close(): Promise<void> {
return undefined;
}
}
export function createQueueAdapterFromEnv(env: QueueEnv = process.env): QueueAdapter {
const adapter = env.QUEUE_ADAPTER?.trim();
if (!adapter) return new UnavailableQueueAdapter("QUEUE_ADAPTER is required");
if (adapter === "memory") return new InMemoryQueueAdapter();
if (adapter === "bullmq") {
if (!env.REDIS_URL || env.REDIS_URL.trim() === "") {
return new UnavailableQueueAdapter("REDIS_URL is required when QUEUE_ADAPTER=bullmq");
}
return new BullMqQueueAdapter({ queueName: "huijing-s1-jobs", redisUrl: env.REDIS_URL });
}
return new UnavailableQueueAdapter(`Unsupported QUEUE_ADAPTER: ${adapter}`);
}
@Injectable()
export class ManagedQueueAdapter implements QueueAdapter, OnModuleDestroy {
constructor(private readonly inner: QueueAdapter) {}
async enqueue(message: Parameters<QueueAdapter["enqueue"]>[0]): Promise<void> {
return this.inner.enqueue(message);
}
async process(handler: Parameters<QueueAdapter["process"]>[0]): Promise<void> {
return this.inner.process(handler);
}
async close(): Promise<void> {
await this.inner.close();
}
async onModuleDestroy(): Promise<void> {
// Nest app.close() 必须关闭底层队列连接BullMQ/ioredis 不能依赖进程退出回收。
await this.close();
}
}
export class JobExecutionError extends Error {
readonly code: JobExecutionErrorCode;
constructor(code: JobExecutionErrorCode, message = code) {
super(message);
this.name = "JobExecutionError";
this.code = code;
}
}
function targetScopeKey(target: JobTarget): string {
return `${target.type}:${target.id}`;
}
function terminalOrIdleLeaseData() {
return {
leaseToken: null,
leasedBy: null,
leaseExpiresAt: null
};
}
export class JobExecutionStore {
private readonly db: Prisma.TransactionClient;
private readonly now: () => Date;
private readonly leaseMs: number;
private readonly retryDelayMs: number;
constructor(options: JobExecutionStoreOptions) {
this.db = options.db;
this.now = options.now ?? (() => new Date());
this.leaseMs = options.leaseMs ?? 30_000;
this.retryDelayMs = options.retryDelayMs ?? 30_000;
}
async enqueueScopedJob(input: EnqueueScopedJobInput, queue: QueueAdapter): Promise<EnqueueScopedJobResult> {
if (!this.isRootClient()) {
throw new JobExecutionError("JOB_ENQUEUE_REQUIRES_ROOT_CLIENT");
}
const existing = await this.db.job.findUnique({
where: {
actorId_projectId_type_targetScopeKey_idempotencyKey: {
actorId: input.actorId,
projectId: input.projectId,
type: input.type,
targetScopeKey: targetScopeKey(input.target),
idempotencyKey: input.idempotencyKey
}
}
});
if (existing) return { job: existing, created: false, enqueued: false };
const { job, created } = await this.withTransaction(async (tx) => {
await this.assertTargetMatchesProject(tx, input);
const inserted = await tx.job.createManyAndReturn({
data: [
{
id: input.id,
actorId: input.actorId,
projectId: input.projectId,
type: input.type,
idempotencyKey: input.idempotencyKey,
status: "queued",
attempts: 0,
maxAttempts: input.maxAttempts ?? 3,
timeoutAt: input.timeoutMs ? new Date(this.now().getTime() + input.timeoutMs) : null,
nextRetryAt: null,
errorCode: null,
targetType: input.target.type,
targetId: input.target.id,
targetScopeKey: targetScopeKey(input.target),
gameProjectId: input.target.type === "project" ? input.target.id : null,
gameVersionId: input.target.type === "version" ? input.target.id : null,
payloadJson: input.payloadJson
}
],
skipDuplicates: true
});
const job =
inserted[0] ??
(await tx.job.findUniqueOrThrow({
where: {
actorId_projectId_type_targetScopeKey_idempotencyKey: {
actorId: input.actorId,
projectId: input.projectId,
type: input.type,
targetScopeKey: targetScopeKey(input.target),
idempotencyKey: input.idempotencyKey
}
}
}));
// S1 generic Job enqueue 不暴露 pre-enqueue DB callback事务只创建 Job避免 queue 前扩展点制造半成品 target 或错误审计。
return { job, created: inserted.length > 0 };
});
if (!created) return { job, created: false, enqueued: false };
try {
await queue.enqueue({ jobId: job.id, type: job.type, payload: job.payloadJson });
} catch (error) {
await this.markEnqueueFailed(job.id);
try {
// queue 失败后必须留下可排障事实;若审计自身失败,仍保留原始 QUEUE_UNAVAILABLE 作为 API 失败语义。
await input.onJobEnqueueFailed?.(this.db, job, error);
} catch {
// 原始 queue failure 比审计失败更接近调用方需要处理的故障边界。
}
throw error;
}
return { job, created: true, enqueued: true };
}
async claimNext(workerId: string, now = this.now()): Promise<Job | null> {
const candidate = await this.db.job.findFirst({
where: {
OR: [
{ status: "queued" },
{
status: "pending_retry",
OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: now } }]
}
]
},
orderBy: [{ createdAt: "asc" }, { id: "asc" }]
});
if (!candidate) return null;
const leaseToken = randomUUID();
const claimed = await this.db.job.updateManyAndReturn({
where: {
id: candidate.id,
lockVersion: candidate.lockVersion,
OR: [
{ status: "queued" },
{
status: "pending_retry",
OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: now } }]
}
]
},
data: {
status: "running",
leaseToken,
leasedBy: workerId,
leaseExpiresAt: new Date(now.getTime() + this.leaseMs),
nextRetryAt: null,
errorCode: null,
lockVersion: { increment: 1 }
}
});
return claimed[0] ?? null;
}
async markSucceeded(jobId: string, leaseToken: string): Promise<Job> {
const updated = await this.db.job.updateManyAndReturn({
where: { id: jobId, status: "running", leaseToken },
data: {
status: "succeeded",
...terminalOrIdleLeaseData(),
nextRetryAt: null,
errorCode: null,
lockVersion: { increment: 1 }
}
});
return this.requireLeaseUpdate(updated);
}
async markFailedOrRetry(jobId: string, leaseToken: string, errorCode: string): Promise<Job> {
const job = await this.db.job.findUnique({ where: { id: jobId } });
if (!job) throw new JobExecutionError("JOB_NOT_FOUND");
if (job.status !== "running" || job.leaseToken !== leaseToken) throw new JobExecutionError("STALE_JOB_LEASE");
const attempts = job.attempts + 1;
const willRetry = attempts < job.maxAttempts;
const updated = await this.db.job.updateManyAndReturn({
where: { id: jobId, status: "running", leaseToken, lockVersion: job.lockVersion },
data: {
status: willRetry ? "pending_retry" : "failed",
attempts,
errorCode,
nextRetryAt: willRetry ? new Date(this.now().getTime() + this.retryDelayMs) : null,
...terminalOrIdleLeaseData(),
lockVersion: { increment: 1 }
}
});
return this.requireLeaseUpdate(updated);
}
async markTimedOut(jobId: string, now = this.now(), errorCode = "JOB_TIMEOUT"): Promise<Job> {
const job = await this.db.job.findUnique({ where: { id: jobId } });
if (!job) throw new JobExecutionError("JOB_NOT_FOUND");
const leaseTimedOut = job.leaseExpiresAt !== null && job.leaseExpiresAt <= now;
const jobTimedOut = job.timeoutAt !== null && job.timeoutAt <= now;
if (job.status !== "running" || (!leaseTimedOut && !jobTimedOut)) {
throw new JobExecutionError("STALE_JOB_LEASE");
}
const attempts = job.attempts + 1;
const willRetry = attempts < job.maxAttempts;
const updated = await this.db.job.updateManyAndReturn({
where: { id: jobId, status: "running", lockVersion: job.lockVersion },
data: {
status: willRetry ? "pending_retry" : "failed",
attempts,
errorCode,
nextRetryAt: willRetry ? new Date(now.getTime() + this.retryDelayMs) : null,
...terminalOrIdleLeaseData(),
lockVersion: { increment: 1 }
}
});
return this.requireLeaseUpdate(updated);
}
async cancelQueued(jobId: string, actorId: string): Promise<Job> {
const updated = await this.db.job.updateManyAndReturn({
where: { id: jobId, actorId, status: "queued" },
data: {
status: "canceled",
...terminalOrIdleLeaseData(),
lockVersion: { increment: 1 }
}
});
if (updated[0]) return updated[0];
const existing = await this.db.job.findUnique({ where: { id: jobId } });
if (!existing) throw new JobExecutionError("JOB_NOT_FOUND");
throw new JobExecutionError("JOB_CANCEL_NOT_ALLOWED");
}
private async markEnqueueFailed(jobId: string): Promise<void> {
await this.db.job.updateMany({
where: { id: jobId, status: "queued" },
data: {
status: "pending_retry",
errorCode: "QUEUE_ENQUEUE_FAILED",
nextRetryAt: new Date(this.now().getTime() + this.retryDelayMs),
lockVersion: { increment: 1 }
}
});
}
private async assertTargetMatchesProject(tx: Prisma.TransactionClient, input: EnqueueScopedJobInput): Promise<void> {
if (input.target.type === "project") {
const project = await tx.gameProject.findUnique({ where: { id: input.target.id } });
if (!project) throw new JobExecutionError("TARGET_NOT_FOUND");
if (project.id !== input.projectId) throw new JobExecutionError("TARGET_PROJECT_MISMATCH");
return;
}
const version = await tx.gameVersion.findUnique({ where: { id: input.target.id } });
if (!version) throw new JobExecutionError("TARGET_NOT_FOUND");
if (version.projectId !== input.projectId) throw new JobExecutionError("TARGET_PROJECT_MISMATCH");
}
private requireLeaseUpdate(updated: Job[]): Job {
if (updated[0]) return updated[0];
throw new JobExecutionError("STALE_JOB_LEASE");
}
private async withTransaction<T>(callback: (tx: Prisma.TransactionClient) => Promise<T>): Promise<T> {
const maybeRootClient = this.db as Prisma.TransactionClient & {
$transaction?: <Result>(fn: (tx: Prisma.TransactionClient) => Promise<Result>) => Promise<Result>;
};
if (typeof maybeRootClient.$transaction === "function") return maybeRootClient.$transaction(callback);
return callback(this.db);
}
private isRootClient(): boolean {
const candidate = this.db as { $transaction?: unknown; $connect?: unknown; $disconnect?: unknown };
return (
typeof candidate.$transaction === "function" &&
typeof candidate.$connect === "function" &&
typeof candidate.$disconnect === "function"
);
}
}
function jobDto(job: Job): JobDto {
return {
id: job.id,
actorId: job.actorId,
projectId: job.projectId,
type: job.type,
status: job.status,
attempts: job.attempts,
maxAttempts: job.maxAttempts,
targetType: job.targetType,
targetId: job.targetId,
payloadJson: job.payloadJson,
errorCode: job.errorCode,
createdAt: job.createdAt.toISOString(),
updatedAt: job.updatedAt.toISOString()
};
}
@Injectable()
export class JobApiService {
constructor(
private readonly db: S1PrismaClient,
@Inject(S1_QUEUE_ADAPTER) private readonly queue: QueueAdapter
) {}
async getJob(actor: AuthActor, jobId: string): Promise<JobDto> {
const job = await this.db.job.findUnique({ where: { id: jobId } });
if (!job) throw new NotFoundException(apiError("NOT_FOUND", "Job not found", { jobId }));
if (job.actorId !== actor.id) {
// Job 是 creator 发起的直接对象S1 不给 operator/admin 暴露未列明的 job 管理读权限。
throw new ForbiddenException(apiError("FORBIDDEN", "Actor cannot access this job", { jobId }));
}
return jobDto(job);
}
async enqueueScopedJobForActor(input: EnqueueScopedJobInput): Promise<Job> {
const store = new JobExecutionStore({ db: this.db });
const result = await store.enqueueScopedJob(
{
...input,
onJobEnqueueFailed: async (db, job) => {
await new AuditService({ db }).append({
id: randomUUID(),
actorId: input.actorId,
action: "job.enqueue_failed",
targetType: "Job",
targetId: job.id,
eventJson: {
projectId: input.projectId,
type: input.type,
targetType: input.target.type,
targetId: input.target.id,
errorCode: "QUEUE_ENQUEUE_FAILED"
}
});
}
},
this.queue
);
if (result.created && result.enqueued) {
// job.enqueued 只在 queue side effect 成功后追加;审计失败会向调用方抛出,避免静默成功但缺审计。
await new AuditService({ db: this.db }).append({
id: randomUUID(),
actorId: input.actorId,
action: "job.enqueued",
targetType: "Job",
targetId: result.job.id,
eventJson: {
projectId: input.projectId,
type: input.type,
targetType: input.target.type,
targetId: input.target.id,
idempotencyKey: input.idempotencyKey
}
});
}
return result.job;
}
}
@Controller()
@UseGuards(AuthGuard)
export class JobsController {
constructor(private readonly service: JobApiService) {}
@Get("jobs/:jobId")
getJob(@Req() request: RequestWithActor, @Param("jobId") jobId: string): Promise<JobDto> {
return this.service.getJob(request.actor, jobId);
}
}
@Module({
imports: [S1ApiRuntimeModule, AuthModule],
controllers: [JobsController],
providers: [
JobApiService,
{
provide: S1_QUEUE_ADAPTER_INNER,
useFactory: () => createQueueAdapterFromEnv()
},
{
provide: S1_QUEUE_ADAPTER,
useFactory: (queue: QueueAdapter) => new ManagedQueueAdapter(queue),
inject: [S1_QUEUE_ADAPTER_INNER]
}
],
exports: [JobApiService, S1_QUEUE_ADAPTER, S1_QUEUE_ADAPTER_INNER]
})
export class JobsApiModule {}
function skipWhitespaceForward(content: string, index: number): number {
let cursor = index;
while (cursor < content.length && /\s/.test(content.charAt(cursor))) cursor += 1;
return cursor;
}
function skipWhitespaceBackward(content: string, index: number): number {
let cursor = index;
while (cursor >= 0 && /\s/.test(content.charAt(cursor))) cursor -= 1;
return cursor;
}
function isStaticQuotedPropertyKey(content: string, literalStart: number, literalEnd: number): boolean {
const afterLiteral = skipWhitespaceForward(content, literalEnd);
if (content[afterLiteral] === ":") return true;
const afterComputedKey = skipWhitespaceForward(content, afterLiteral + 1);
if (content[afterLiteral] !== "]" || content[afterComputedKey] !== ":") return false;
const beforeLiteral = skipWhitespaceBackward(content, literalStart - 1);
return content[beforeLiteral] === "[";
}
function stripStringLiteralsExceptPropertyKeys(content: string): string {
let output = "";
for (let index = 0; index < content.length; index += 1) {
const quote = content[index];
if (quote === "/" && isRegexLiteralStart(content, index)) {
const literalEnd = findRegexLiteralEnd(content, index);
if (literalEnd !== -1) {
output += "/".padEnd(literalEnd - index, " ") + "/";
index = literalEnd;
continue;
}
}
if (quote !== '"' && quote !== "'" && quote !== "`") {
output += quote;
continue;
}
const literalStart = index;
let literalEnd = literalStart + 1;
while (literalEnd < content.length) {
const char = content[literalEnd];
if (char === "\\") {
literalEnd += 2;
continue;
}
literalEnd += 1;
if (char === quote) break;
}
const literal = content.slice(literalStart, literalEnd);
// 只保留对象静态 key普通字符串值和测试 fixture 字符串仍剥离,避免边界扫描误报。
output += isStaticQuotedPropertyKey(content, literalStart, literalEnd) ? literal : `${quote}${quote}`;
index = literalEnd - 1;
}
return output;
}
function maskCommentsOutsideStringLiterals(content: string): string {
let output = "";
let index = 0;
while (index < content.length) {
const char = content[index];
const next = content[index + 1];
if (char === "/" && next === "/") {
output += " ";
index += 2;
while (index < content.length && content[index] !== "\n" && content[index] !== "\r") {
output += " ";
index += 1;
}
continue;
}
if (char === "/" && next === "*") {
output += " ";
index += 2;
while (index < content.length) {
const blockChar = content[index];
const blockNext = content[index + 1];
if (blockChar === "*" && blockNext === "/") {
output += " ";
index += 2;
break;
}
output += blockChar === "\n" || blockChar === "\r" ? blockChar : " ";
index += 1;
}
continue;
}
if (char === "/" && isRegexLiteralStart(content, index)) {
const literalEnd = findRegexLiteralEnd(content, index);
if (literalEnd !== -1) {
output += content.slice(index, literalEnd + 1);
index = literalEnd + 1;
continue;
}
}
if (char !== '"' && char !== "'" && char !== "`") {
output += char;
index += 1;
continue;
}
output += char;
index += 1;
while (index < content.length) {
const stringChar = content[index];
output += stringChar;
index += 1;
if (stringChar === "\\") {
if (index < content.length) {
output += content[index];
index += 1;
}
continue;
}
if (stringChar === char) break;
}
}
return output;
}
function isRegexLiteralStart(content: string, slashIndex: number): boolean {
const cursor = skipWhitespaceBackward(content, slashIndex - 1);
if (cursor < 0) return true;
const previous = content.charAt(cursor);
if ("([{:;,=!?&|+-*%^~<>".includes(previous)) return true;
return /\b(?:return|throw|case|delete|typeof|void|new|in|of|yield|await)\b/.test(content.slice(Math.max(0, cursor - 12), cursor + 1));
}
function findRegexLiteralEnd(content: string, slashIndex: number): number {
let inCharClass = false;
for (let cursor = slashIndex + 1; cursor < content.length; cursor += 1) {
const char = content[cursor];
if (char === "\\") {
cursor += 1;
continue;
}
if (char === "[") inCharClass = true;
if (char === "]") inCharClass = false;
if (char === "/" && !inCharClass) {
let end = cursor + 1;
while (end < content.length && /[a-z]/i.test(content.charAt(end))) end += 1;
return end - 1;
}
}
return -1;
}
function findMatchingParen(content: string, openParenIndex: number): number {
let depth = 0;
for (let index = openParenIndex; index < content.length; index += 1) {
const char = content[index];
if (char === "(") depth += 1;
if (char === ")") {
depth -= 1;
if (depth === 0) return index;
}
}
return -1;
}
function findMatchingBrace(content: string, openBraceIndex: number): number {
let depth = 0;
for (let index = openBraceIndex; index < content.length; index += 1) {
const char = content[index];
if (char === "{") depth += 1;
if (char === "}") {
depth -= 1;
if (depth === 0) return index;
}
}
return -1;
}
function maskStringLiterals(content: string): string {
let output = "";
let index = 0;
while (index < content.length) {
const quote = content[index];
if (quote === "/" && isRegexLiteralStart(content, index)) {
const literalEnd = findRegexLiteralEnd(content, index);
if (literalEnd !== -1) {
output += "/".padEnd(literalEnd - index, " ") + "/";
index = literalEnd + 1;
continue;
}
}
if (quote !== '"' && quote !== "'" && quote !== "`") {
output += quote;
index += 1;
continue;
}
output += quote;
index += 1;
while (index < content.length) {
const char = content[index];
if (char === "\\") {
output += " ";
index += 1;
if (index < content.length) {
output += " ";
index += 1;
}
continue;
}
if (char === quote) {
output += quote;
index += 1;
break;
}
output += " ";
index += 1;
}
}
return output;
}
function findStringLiteralEnd(content: string, literalStart: number): number {
const quote = content[literalStart];
let cursor = literalStart + 1;
while (cursor < content.length) {
const char = content[cursor];
if (char === "\\") {
cursor += 2;
continue;
}
if (char === quote) return cursor;
cursor += 1;
}
return -1;
}
function stripSqlComments(content: string): string {
return content
.replace(/\/\*[\s\S]*?\*\//g, "")
.split("\n")
.map((line) => line.replace(/--.*$/, ""))
.join("\n");
}
function hasVariableizedOrSpreadJobData(callBody: string): boolean {
return (
/\bdata\s*(?:[,}])/.test(callBody) ||
/\bdata\s*:\s*[A-Za-z_$][\w$]*\b/.test(callBody) ||
/\bdata\s*:\s*(?:\{|\[)[\s\S]*\.\.\./.test(callBody)
);
}
function hasComputedJobDataKey(callBody: string): boolean {
const dataObjectPattern = /\bdata\s*:\s*\{/g;
for (const match of callBody.matchAll(dataObjectPattern)) {
const openBraceIndex = callBody.indexOf("{", match.index);
if (openBraceIndex === -1) continue;
const closeBraceIndex = findMatchingBrace(callBody, openBraceIndex);
if (closeBraceIndex === -1) continue;
const dataObjectBody = callBody.slice(openBraceIndex, closeBraceIndex + 1);
// Job 运行态字段只能通过 JobExecutionStore 写入computed key 无法静态证明安全,统一拦截。
if (/\[[^\]]+\]\s*:/.test(dataObjectBody)) return true;
}
return false;
}
function hasForbiddenJobWriteCallBody(callBody: string): boolean {
const runtimeFieldPattern = new RegExp(`\\b(?:${jobRuntimeFields.map(escapeRegExp).join("|")})\\b`);
return runtimeFieldPattern.test(callBody) || hasVariableizedOrSpreadJobData(callBody) || hasComputedJobDataKey(callBody);
}
function hasRawJobRuntimeFieldWrite(sqlSource: string): boolean {
const sql = stripSqlComments(sqlSource)
.replace(/\\(["'`])/g, "$1")
.replace(/\\n/g, "\n")
.replace(/\\r/g, "\r")
.replace(/\\t/g, "\t");
const quotedJob = String.raw`["'\`]?Job["'\`]?`;
const runtimeField = String.raw`["'\`]?(?:${jobRuntimeFields.map(escapeRegExp).join("|")})["'\`]?`;
return (
new RegExp(String.raw`\bUPDATE\s+${quotedJob}\s+SET\b[\s\S]*?${runtimeField}\s*=`, "i").test(sql) ||
new RegExp(String.raw`\bINSERT\s+INTO\s+${quotedJob}\s*\([^)]*${runtimeField}[\s\S]*?\)\s*VALUES\b`, "i").test(sql)
);
}
function hasRawJobStatusWrite(content: string): boolean {
// raw SQL 检测仍从原始 content 切片读取 SQL 字符串structure 只负责可靠找到调用边界。
const structure = maskStringLiterals(maskCommentsOutsideStringLiterals(content));
const rawCallPattern = /\.\$(?:executeRawUnsafe|executeRaw|queryRawUnsafe|queryRaw)\s*(?:<[^>()`]*>)?\s*/g;
for (const match of structure.matchAll(rawCallPattern)) {
const callStart = skipWhitespaceForward(structure, match.index + match[0].length);
if (structure[callStart] === "(") {
const closeParenIndex = findMatchingParen(structure, callStart);
if (closeParenIndex !== -1 && hasRawJobRuntimeFieldWrite(content.slice(callStart, closeParenIndex + 1))) return true;
continue;
}
if (structure[callStart] === "`") {
const templateEnd = findStringLiteralEnd(content, callStart);
if (templateEnd !== -1 && hasRawJobRuntimeFieldWrite(content.slice(callStart, templateEnd + 1))) return true;
}
}
return false;
}
function jobExecutionStoreClassSpans(content: string): Array<{ readonly start: number; readonly end: number }> {
const spans: Array<{ readonly start: number; readonly end: number }> = [];
const structure = maskStringLiterals(maskCommentsOutsideStringLiterals(content));
const classPattern = /\b(?:export\s+)?class\s+JobExecutionStore\b/g;
for (const match of structure.matchAll(classPattern)) {
if (typeof match.index !== "number") continue;
const openBraceIndex = structure.indexOf("{", match.index + match[0].length);
if (openBraceIndex === -1) continue;
const closeBraceIndex = findMatchingBrace(structure, openBraceIndex);
if (closeBraceIndex === -1) continue;
spans.push({ start: match.index, end: closeBraceIndex + 1 });
}
return spans;
}
function maskSpans(content: string, spans: ReadonlyArray<{ readonly start: number; readonly end: number }>): string {
if (spans.length === 0) return content;
let output = "";
let cursor = 0;
for (const span of [...spans].sort((left, right) => left.start - right.start)) {
if (span.start < cursor) continue;
output += content.slice(cursor, span.start);
output += " ".repeat(span.end - span.start);
cursor = span.end;
}
return output + content.slice(cursor);
}
function maskJobExecutionStoreClassBodies(content: string): string {
// 本地静态边界只豁免 JobExecutionStore 类体;同文件其他 class 仍按外部写入处理。
return maskSpans(content, jobExecutionStoreClassSpans(content));
}
function hasJobStatusWrite(content: string): boolean {
// 逐个 Prisma Job 写调用检查调用体,避免测试中的字符串 fixture 或后续断言文本造成跨调用误报。
const scanContent = stripStringLiteralsExceptPropertyKeys(maskCommentsOutsideStringLiterals(content));
const callPattern = /\.job\.(?:create|createMany|createManyAndReturn|update|updateMany|updateManyAndReturn|upsert)\s*\(/g;
for (const match of scanContent.matchAll(callPattern)) {
const openParenIndex = scanContent.indexOf("(", match.index);
if (openParenIndex === -1) continue;
const closeParenIndex = findMatchingParen(scanContent, openParenIndex);
if (closeParenIndex === -1) continue;
const callBody = scanContent.slice(openParenIndex, closeParenIndex + 1);
if (hasForbiddenJobWriteCallBody(callBody)) return true;
}
return hasRawJobStatusWrite(content);
}
function isIgnoredScanPath(filePath: string): boolean {
return filePath.split("/").some((segment) => ignoredScanSegments.has(segment));
}
export function findJobExecutionBoundaryViolations(files: readonly ScanFile[]): JobExecutionBoundaryViolation[] {
const violations: JobExecutionBoundaryViolation[] = [];
for (const file of files) {
const normalizedPath = file.path.split(path.sep).join("/");
if (isIgnoredScanPath(normalizedPath)) continue;
const scanContent = normalizedPath === jobExecutionStorePath ? maskJobExecutionStoreClassBodies(file.content) : file.content;
if (hasJobStatusWrite(scanContent)) {
violations.push({ path: normalizedPath, reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE" });
}
}
return violations;
}
async function collectFiles(root: string, relativeDir: string): Promise<ScanFile[]> {
const absoluteDir = path.join(root, relativeDir);
const entries = await readdir(absoluteDir, { withFileTypes: true }).catch(() => []);
const files: ScanFile[] = [];
for (const entry of entries) {
if (entry.isDirectory() && ignoredScanSegments.has(entry.name)) continue;
const relativePath = path.join(relativeDir, entry.name);
const absolutePath = path.join(root, relativePath);
if (entry.isDirectory()) files.push(...(await collectFiles(root, relativePath)));
if (entry.isFile() && /\.(?:ts|tsx|js|mjs|cjs)$/.test(entry.name)) {
files.push({ path: relativePath, content: await readFile(absolutePath, "utf8") });
}
}
return files;
}
export async function scanJobExecutionBoundary(repoRoot: string): Promise<JobExecutionBoundaryViolation[]> {
return findJobExecutionBoundaryViolations([
...(await collectFiles(repoRoot, "apps/api/src")),
...(await collectFiles(repoRoot, "apps/worker/src"))
]);
}

View File

@ -0,0 +1,449 @@
import { type INestApplication } from "@nestjs/common";
import { Test } from "@nestjs/testing";
import { PrismaPg } from "@prisma/adapter-pg";
import pg from "pg";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AppModule } from "../../app.module.js";
import { Prisma, PrismaClient } from "../../generated/prisma/client.js";
import { BullMqQueueAdapter, InMemoryQueueAdapter, QueueUnavailableError, type QueueAdapter, type QueueMessage } from "../queue/index.js";
import { StateTransitionService } from "../state-transition/index.js";
import {
JobApiService,
JobExecutionStore,
ManagedQueueAdapter,
S1_QUEUE_ADAPTER,
S1_QUEUE_ADAPTER_INNER,
createQueueAdapterFromEnv
} from "./index.js";
const databaseUrl = process.env.DATABASE_URL ?? "postgresql://huijing:huijing@localhost:5432/huijing_dev?schema=public";
const adapter = new PrismaPg({ connectionString: databaseUrl });
const prisma = new PrismaClient({ adapter });
const runId = `task8-jobs-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const jobTestIsolationLockKey = 810_008;
let lockClient: pg.Client | undefined;
type DbClient = Prisma.TransactionClient;
class RecordingQueue implements QueueAdapter {
readonly messages: QueueMessage[] = [];
async enqueue(message: QueueMessage): Promise<void> {
this.messages.push(message);
}
async process(): Promise<void> {
return undefined;
}
async close(): Promise<void> {
return undefined;
}
}
class FailingQueue implements QueueAdapter {
async enqueue(): Promise<void> {
throw new QueueUnavailableError("test queue unavailable");
}
async process(): Promise<void> {
return undefined;
}
async close(): Promise<void> {
return undefined;
}
}
class CloseCountingQueue implements QueueAdapter {
closeCalls = 0;
async enqueue(): Promise<void> {
return undefined;
}
async process(): Promise<void> {
return undefined;
}
async close(): Promise<void> {
this.closeCalls += 1;
}
}
class CompletingQueue extends RecordingQueue {
async enqueue(message: QueueMessage): Promise<void> {
// 测试队列模拟 worker 完成时也必须走 JobExecutionStore避免 spec 夹具绕过 Job 状态单写入口。
const store = new JobExecutionStore({ db: prisma });
await expectOnlyEligibleJob(message.jobId);
const claimed = await store.claimNext(`completing-queue-${runId}`);
expect(claimed).toMatchObject({ id: message.jobId, leaseToken: expect.any(String) });
await store.markSucceeded(message.jobId, claimed?.leaseToken ?? "missing");
await super.enqueue(message);
}
}
type TestApp = {
readonly app: INestApplication;
readonly baseUrl: string;
readonly jobApiService: JobApiService;
readonly queue: RecordingQueue;
};
async function createTestApp(queue: RecordingQueue = new RecordingQueue()): Promise<TestApp> {
const moduleRef = await Test.createTestingModule({
imports: [AppModule]
})
.overrideProvider(S1_QUEUE_ADAPTER)
.useValue(queue)
.compile();
const app = moduleRef.createNestApplication();
await app.init();
const server = await app.listen(0);
const address = server.address();
if (address === null || typeof address === "string") throw new Error("Nest test server did not expose a TCP port");
return {
app,
baseUrl: `http://127.0.0.1:${address.port}`,
jobApiService: moduleRef.get(JobApiService),
queue
};
}
async function requestJson(testApp: TestApp, requestPath: string, init: RequestInit = {}) {
const response = await fetch(`${testApp.baseUrl}${requestPath}`, {
...init,
headers: {
"content-type": "application/json",
...init.headers
}
});
const body = (await response.json().catch(() => null)) as unknown;
return { response, body };
}
async function login(testApp: TestApp, email: string): Promise<string> {
const response = await requestJson(testApp, "/auth/login", {
method: "POST",
body: JSON.stringify({ email })
});
expect(response.response.status).toBe(201);
return (response.body as { token: string }).token;
}
async function ensureSeedUsers(db: DbClient): Promise<void> {
await db.user.upsert({
where: { id: "seed-creator" },
update: {},
create: { id: "seed-creator", email: "creator@example.test", displayName: "Seed Creator" }
});
await db.user.upsert({
where: { id: "seed-creator-other" },
update: {},
create: { id: "seed-creator-other", email: "creator-other@example.test", displayName: "Seed Other Creator" }
});
}
async function seedProject(db: DbClient, suffix: string, ownerId: string) {
return db.gameProject.create({
data: { id: `${runId}-${suffix}-project`, ownerId, slug: `${runId}-${suffix}`, title: `${suffix} project` }
});
}
async function seedDraftVersion(db: DbClient, projectId: string, suffix: string) {
const service = new StateTransitionService({
db,
blockedAuditDb: db,
harnessGate: {
validateContract: async () => ({ ok: true, reasonCode: null }),
validateTransition: async () => ({ ok: true, reasonCode: null })
}
});
return service.createDraftVersion({
id: `${runId}-${suffix}-version`,
projectId,
versionNumber: 1,
configJson: { suffix }
});
}
async function seedCompletedProjectJob(db: PrismaClient, suffix: string, actorId: string, projectId: string) {
const store = new JobExecutionStore({ db });
const result = await store.enqueueScopedJob(
{
id: `${runId}-${suffix}`,
actorId,
projectId,
type: "noop",
idempotencyKey: suffix,
target: { type: "project", id: projectId },
payloadJson: { ok: true }
},
new RecordingQueue()
);
await expectOnlyEligibleJob(result.job.id);
const claimed = await store.claimNext(`completed-fixture-${suffix}`);
expect(claimed).toMatchObject({ id: result.job.id, leaseToken: expect.any(String) });
return store.markSucceeded(result.job.id, claimed?.leaseToken ?? "missing");
}
async function expectOnlyEligibleJob(jobId: string): Promise<void> {
const now = new Date();
const externalJobs = await prisma.job.findMany({
where: {
id: { not: jobId },
OR: [
{ status: "queued" },
{
status: "pending_retry",
OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: now } }]
}
]
},
select: { id: true, status: true, createdAt: true },
take: 5,
orderBy: [{ createdAt: "asc" }, { id: "asc" }]
});
// claimNext 是全局领取;测试只能在确认没有外部 eligible job 时模拟 worker 完成。
expect(externalJobs, "external eligible jobs would make this API fixture mutate data outside the current runId").toEqual([]);
}
async function cleanup(): Promise<void> {
await prisma.job.deleteMany({ where: { id: { startsWith: runId } } });
await prisma.gameProject.deleteMany({ where: { id: { startsWith: runId } } });
}
describe("Job HTTP APIs", () => {
let testApp: TestApp | undefined;
beforeEach(async () => {
await acquireJobTestIsolationLock();
await cleanup();
await ensureSeedUsers(prisma);
testApp = await createTestApp();
});
afterEach(async () => {
await testApp?.app.close();
testApp = undefined;
await cleanup();
await releaseJobTestIsolationLock();
});
it("GET /jobs/:jobId returns own job state", async () => {
const project = await seedProject(prisma, "own-job", "seed-creator");
const job = await seedCompletedProjectJob(prisma, "own-job", "seed-creator", project.id);
const token = await login(testApp!, "creator@example.test");
const read = await requestJson(testApp!, `/jobs/${job.id}`, {
headers: { authorization: `Bearer ${token}` }
});
expect(read.response.status).toBe(200);
expect(read.body).toMatchObject({
id: job.id,
actorId: "seed-creator",
projectId: project.id,
status: "succeeded",
targetType: "project",
targetId: project.id
});
});
it("GET /jobs/:jobId rejects foreign job access", async () => {
const project = await seedProject(prisma, "foreign-job", "seed-creator-other");
const job = await seedCompletedProjectJob(prisma, "foreign-job", "seed-creator-other", project.id);
const token = await login(testApp!, "creator@example.test");
const denied = await requestJson(testApp!, `/jobs/${job.id}`, {
headers: { authorization: `Bearer ${token}` }
});
expect(denied.response.status).toBe(403);
expect(denied.body).toEqual({
code: "FORBIDDEN",
message: expect.any(String),
requestId: null,
details: expect.anything()
});
});
it("rejects target project/version mismatch from the service layer before enqueue", async () => {
const project = await seedProject(prisma, "mismatch-project", "seed-creator");
const otherProject = await seedProject(prisma, "mismatch-other-project", "seed-creator");
const otherVersion = await prisma.$transaction((tx) => seedDraftVersion(tx, otherProject.id, "mismatch-other"));
await expect(
testApp!.jobApiService.enqueueScopedJobForActor({
id: `${runId}-mismatch-job`,
actorId: "seed-creator",
projectId: project.id,
type: "noop",
idempotencyKey: "mismatch",
target: { type: "version", id: otherVersion.id },
payloadJson: { mismatch: true }
})
).rejects.toMatchObject({ code: "TARGET_PROJECT_MISMATCH" });
expect(testApp!.queue.messages).toHaveLength(0);
await expect(prisma.job.findUnique({ where: { id: `${runId}-mismatch-job` } })).resolves.toBeNull();
});
it("writes job.enqueue_failed audit and keeps stable errorCode when queue enqueue fails", async () => {
await testApp?.app.close();
const failingQueue = new FailingQueue();
const moduleRef = await Test.createTestingModule({
imports: [AppModule]
})
.overrideProvider(S1_QUEUE_ADAPTER)
.useValue(failingQueue)
.compile();
const app = moduleRef.createNestApplication();
await app.init();
const server = await app.listen(0);
const address = server.address();
if (address === null || typeof address === "string") throw new Error("Nest test server did not expose a TCP port");
testApp = {
app,
baseUrl: `http://127.0.0.1:${address.port}`,
jobApiService: moduleRef.get(JobApiService),
queue: new RecordingQueue()
};
const project = await seedProject(prisma, "queue-failure", "seed-creator");
const jobId = `${runId}-queue-failure-job`;
await expect(
testApp.jobApiService.enqueueScopedJobForActor({
id: jobId,
actorId: "seed-creator",
projectId: project.id,
type: "noop",
idempotencyKey: "queue-failure",
target: { type: "project", id: project.id },
payloadJson: { ok: true }
})
).rejects.toMatchObject({ code: "QUEUE_UNAVAILABLE" });
await expect(prisma.job.findUnique({ where: { id: jobId } })).resolves.toMatchObject({
status: "pending_retry",
errorCode: "QUEUE_ENQUEUE_FAILED"
});
await expect(prisma.auditLog.findFirst({ where: { targetId: jobId, action: "job.enqueue_failed" } })).resolves.toMatchObject({
actorId: "seed-creator",
targetType: "Job",
eventJson: expect.objectContaining({ errorCode: "QUEUE_ENQUEUE_FAILED" })
});
await expect(prisma.auditLog.count({ where: { targetId: jobId, action: "job.enqueued" } })).resolves.toBe(0);
});
it("writes job.enqueued audit only once for the idempotent winner after enqueue succeeds", async () => {
await testApp?.app.close();
testApp = await createTestApp(new CompletingQueue());
const project = await seedProject(prisma, "duplicate-success", "seed-creator");
const firstJobId = `${runId}-duplicate-success-a-job`;
const secondJobId = `${runId}-duplicate-success-b-job`;
const [first, second] = await Promise.all([
testApp!.jobApiService.enqueueScopedJobForActor({
id: firstJobId,
actorId: "seed-creator",
projectId: project.id,
type: "noop",
idempotencyKey: "duplicate-success",
target: { type: "project", id: project.id },
payloadJson: { attempt: "a" }
}),
testApp!.jobApiService.enqueueScopedJobForActor({
id: secondJobId,
actorId: "seed-creator",
projectId: project.id,
type: "noop",
idempotencyKey: "duplicate-success",
target: { type: "project", id: project.id },
payloadJson: { attempt: "b" }
})
]);
expect(second.id).toBe(first.id);
expect(testApp!.queue.messages).toHaveLength(1);
await expect(prisma.auditLog.count({ where: { targetId: first.id, action: "job.enqueued" } })).resolves.toBe(1);
await expect(prisma.auditLog.count({ where: { targetId: first.id, action: "job.enqueue_failed" } })).resolves.toBe(0);
});
});
async function acquireJobTestIsolationLock(): Promise<void> {
// 仅测试隔离:本 spec 的 queue failure 会短暂产生 eligible pending_retry需与 store claimNext 用例错开。
lockClient = new pg.Client({ connectionString: databaseUrl });
await lockClient.connect();
await lockClient.query("SELECT pg_advisory_lock($1)", [jobTestIsolationLockKey]);
}
async function releaseJobTestIsolationLock(): Promise<void> {
if (!lockClient) return;
try {
await lockClient.query("SELECT pg_advisory_unlock($1)", [jobTestIsolationLockKey]);
} finally {
await lockClient.end();
lockClient = undefined;
}
}
describe("Task 8 queue provider factory", () => {
it("uses memory adapter only when QUEUE_ADAPTER=memory", async () => {
expect(createQueueAdapterFromEnv({ QUEUE_ADAPTER: "memory" })).toBeInstanceOf(InMemoryQueueAdapter);
});
it("fails closed when QUEUE_ADAPTER is missing instead of falling back to memory", async () => {
const adapter = createQueueAdapterFromEnv({});
expect(adapter).not.toBeInstanceOf(InMemoryQueueAdapter);
await expect(adapter.enqueue({ jobId: "missing-adapter", type: "noop", payload: {} })).rejects.toMatchObject({
code: "QUEUE_UNAVAILABLE"
});
});
it("fails closed when QUEUE_ADAPTER is unsupported instead of falling back to memory", async () => {
const adapter = createQueueAdapterFromEnv({ QUEUE_ADAPTER: "sidekiq" });
expect(adapter).not.toBeInstanceOf(InMemoryQueueAdapter);
await expect(adapter.enqueue({ jobId: "unsupported-adapter", type: "noop", payload: {} })).rejects.toMatchObject({
code: "QUEUE_UNAVAILABLE"
});
});
it("uses BullMQ when QUEUE_ADAPTER=bullmq and REDIS_URL is present", async () => {
expect(createQueueAdapterFromEnv({ QUEUE_ADAPTER: "bullmq", REDIS_URL: "redis://localhost:6379" })).toBeInstanceOf(
BullMqQueueAdapter
);
});
it("does not silently fallback to memory when QUEUE_ADAPTER=bullmq lacks REDIS_URL", async () => {
const adapter = createQueueAdapterFromEnv({ QUEUE_ADAPTER: "bullmq" });
expect(adapter).not.toBeInstanceOf(InMemoryQueueAdapter);
await expect(adapter.enqueue({ jobId: "missing-redis", type: "noop", payload: {} })).rejects.toMatchObject({
code: "QUEUE_UNAVAILABLE"
});
});
it("closes the wrapped queue adapter when the Nest app closes", async () => {
const closeCountingQueue = new CloseCountingQueue();
const moduleRef = await Test.createTestingModule({
providers: [
{ provide: S1_QUEUE_ADAPTER_INNER, useValue: closeCountingQueue },
{
provide: S1_QUEUE_ADAPTER,
useFactory: (queue: QueueAdapter) => new ManagedQueueAdapter(queue),
inject: [S1_QUEUE_ADAPTER_INNER]
}
]
}).compile();
const app = moduleRef.createNestApplication();
await app.init();
await app.close();
expect(closeCountingQueue.closeCalls).toBe(1);
});
});

View File

@ -0,0 +1,660 @@
import { readFile } from "node:fs/promises";
import { PrismaPg } from "@prisma/adapter-pg";
import pg from "pg";
import { Prisma, PrismaClient } from "../../generated/prisma/client.js";
import type { QueueAdapter, QueueMessage } from "../queue/index.js";
import { StateTransitionService } from "../state-transition/index.js";
import {
JobExecutionError,
JobExecutionStore,
findJobExecutionBoundaryViolations,
scanJobExecutionBoundary
} from "./index.js";
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
const databaseUrl = process.env.DATABASE_URL ?? "postgresql://huijing:huijing@localhost:5432/huijing_dev?schema=public";
const adapter = new PrismaPg({ connectionString: databaseUrl });
const prisma = new PrismaClient({ adapter });
const runId = `task7-jobs-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const fixedNow = new Date("2026-06-01T09:00:00.000Z");
const claimIsolationBase = new Date("2000-01-01T00:00:00.000Z");
const jobTestIsolationLockKey = 810_008;
let claimIsolationSequence = 0;
let lockClient: pg.Client | undefined;
type DbClient = Prisma.TransactionClient;
class RecordingQueue implements QueueAdapter {
readonly messages: QueueMessage[] = [];
readonly observedJobs: Array<{ readonly id: string; readonly status: string }> = [];
private readonly fail: boolean;
private readonly db?: PrismaClient;
constructor(options: { fail?: boolean; db?: PrismaClient } = {}) {
this.fail = options.fail ?? false;
this.db = options.db;
}
async enqueue(message: QueueMessage): Promise<void> {
const observed = await this.db?.job.findUnique({ where: { id: message.jobId } });
if (observed) this.observedJobs.push({ id: observed.id, status: observed.status });
if (this.fail) throw new Error("QUEUE_DOWN");
this.messages.push(message);
}
async process(): Promise<void> {
return undefined;
}
async close(): Promise<void> {
return undefined;
}
}
async function seedProjectSet(db: DbClient, suffix: string) {
const actor = await db.user.create({
data: {
id: `${runId}-${suffix}-actor`,
email: `${runId}-${suffix}-actor@example.test`,
displayName: `${suffix} actor`
}
});
const otherActor = await db.user.create({
data: {
id: `${runId}-${suffix}-other-actor`,
email: `${runId}-${suffix}-other-actor@example.test`,
displayName: `${suffix} other actor`
}
});
const project = await db.gameProject.create({
data: {
id: `${runId}-${suffix}-project`,
ownerId: actor.id,
slug: `${runId}-${suffix}-project`,
title: `${suffix} project`
}
});
const otherProject = await db.gameProject.create({
data: {
id: `${runId}-${suffix}-other-project`,
ownerId: otherActor.id,
slug: `${runId}-${suffix}-other-project`,
title: `${suffix} other project`
}
});
const versionService = new StateTransitionService({
db,
blockedAuditDb: db,
harnessGate: {
validateContract: async () => ({ ok: true, reasonCode: null }),
validateTransition: async () => ({ ok: true, reasonCode: null })
}
});
const version = await versionService.createDraftVersion({
id: `${runId}-${suffix}-version`,
projectId: project.id,
versionNumber: 1,
configJson: { suffix }
});
const otherVersion = await versionService.createDraftVersion({
id: `${runId}-${suffix}-other-version`,
projectId: otherProject.id,
versionNumber: 1,
configJson: { suffix, other: true }
});
return { actor, otherActor, project, otherProject, version, otherVersion };
}
function enqueueInput(seed: Awaited<ReturnType<typeof seedProjectSet>>, suffix: string) {
return {
id: `${runId}-${suffix}-job`,
actorId: seed.actor.id,
projectId: seed.project.id,
type: "noop",
idempotencyKey: "same-key",
target: { type: "version" as const, id: seed.version.id },
payloadJson: { suffix }
};
}
async function enqueueJob(store: JobExecutionStore, input: Parameters<JobExecutionStore["enqueueScopedJob"]>[0], queue: QueueAdapter) {
return (await store.enqueueScopedJob(input, queue)).job;
}
async function prepareClaimFixture(jobId: string): Promise<void> {
const createdAt = new Date(claimIsolationBase.getTime() + claimIsolationSequence++);
await prisma.job.update({ where: { id: jobId }, data: { createdAt } });
await expectNoExternalEligibleJobs();
}
async function expectNoExternalEligibleJobs(): Promise<void> {
const externalJobs = await prisma.job.findMany({
where: {
id: { not: { startsWith: runId } },
OR: [
{ status: "queued" },
{
status: "pending_retry",
OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: fixedNow } }]
}
]
},
select: { id: true, status: true, createdAt: true },
take: 5,
orderBy: [{ createdAt: "asc" }, { id: "asc" }]
});
// claimNext 的业务语义是全局领取;测试隔离只能检测外部 eligible job不能把其他 spec/dev 数据改成终态。
expect(externalJobs, "external eligible jobs would make this claimNext test mutate data outside the current runId").toEqual([]);
}
describe("JobExecutionStore", () => {
beforeEach(async () => {
await acquireJobTestIsolationLock();
await cleanupTask7JobRows();
});
afterEach(async () => {
await cleanupTask7JobRows();
await releaseJobTestIsolationLock();
});
afterAll(async () => {
await prisma.$disconnect();
});
it("scopes idempotency by actor/project/type/target/idempotencyKey", async () => {
const seed = await prisma.$transaction((tx) => seedProjectSet(tx, "idempotency"));
const queue = new RecordingQueue();
const store = new JobExecutionStore({ db: prisma, now: () => fixedNow });
const first = await enqueueJob(store, enqueueInput(seed, "first"), queue);
const duplicate = await enqueueJob(
store,
{ ...enqueueInput(seed, "duplicate"), id: `${runId}-idempotency-duplicate-job` },
queue
);
const otherCreator = await enqueueJob(
store,
{
...enqueueInput(seed, "other-creator"),
id: `${runId}-idempotency-other-creator-job`,
actorId: seed.otherActor.id
},
queue
);
expect(duplicate.id).toBe(first.id);
expect(otherCreator.id).not.toBe(first.id);
expect(queue.messages).toHaveLength(2);
});
it("rejects enqueueScopedJob when constructed with an outer transaction client", async () => {
const seed = await prisma.$transaction((tx) => seedProjectSet(tx, "tx-enqueue-rejected"));
const queue = new RecordingQueue();
await expect(
prisma.$transaction(async (tx) => {
const store = new JobExecutionStore({ db: tx, now: () => fixedNow });
await store.enqueueScopedJob(enqueueInput(seed, "tx-enqueue-rejected"), queue);
})
).rejects.toMatchObject<JobExecutionError>({ code: "JOB_ENQUEUE_REQUIRES_ROOT_CLIENT" });
expect(queue.messages).toHaveLength(0);
await expect(prisma.job.findUnique({ where: { id: `${runId}-tx-enqueue-rejected-job` } })).resolves.toBeNull();
});
it("rejects target project mismatch before enqueue and leaves target unchanged when enqueue fails", async () => {
const seed = await prisma.$transaction((tx) => seedProjectSet(tx, "enqueue-failure"));
const store = new JobExecutionStore({ db: prisma, now: () => fixedNow });
const queue = new RecordingQueue({ fail: true });
await expect(
store.enqueueScopedJob(
{
id: `${runId}-mismatch-job`,
actorId: seed.actor.id,
projectId: seed.project.id,
type: "noop",
idempotencyKey: "mismatch",
target: { type: "version", id: seed.otherVersion.id },
payloadJson: {}
},
queue
)
).rejects.toMatchObject<JobExecutionError>({ code: "TARGET_PROJECT_MISMATCH" });
expect(queue.messages).toHaveLength(0);
await expect(
store.enqueueScopedJob(
{
id: `${runId}-failed-enqueue-job`,
actorId: seed.actor.id,
projectId: seed.project.id,
type: "noop",
idempotencyKey: "failed-enqueue",
target: { type: "project", id: seed.project.id },
payloadJson: {}
},
queue
)
).rejects.toThrow(/QUEUE_DOWN/);
await expect(prisma.job.findUnique({ where: { id: `${runId}-failed-enqueue-job` } })).resolves.toMatchObject({
status: "pending_retry",
errorCode: "QUEUE_ENQUEUE_FAILED"
});
await expect(prisma.gameProject.findUniqueOrThrow({ where: { id: seed.project.id } })).resolves.toMatchObject({
title: "enqueue-failure project"
});
});
it("concurrent duplicate idempotency requests persist only one job while creator scopes stay isolated", async () => {
const seed = await prisma.$transaction((tx) => seedProjectSet(tx, "concurrent-idempotency"));
const queue = new RecordingQueue();
const store = new JobExecutionStore({ db: prisma, now: () => fixedNow });
const [first, second, isolated] = await Promise.all([
enqueueJob(store, enqueueInput(seed, "concurrent-a"), queue),
enqueueJob(store, { ...enqueueInput(seed, "concurrent-b"), id: `${runId}-concurrent-b-job` }, queue),
enqueueJob(
store,
{
...enqueueInput(seed, "concurrent-other"),
id: `${runId}-concurrent-other-job`,
actorId: seed.otherActor.id
},
queue
)
]);
expect(second.id).toBe(first.id);
expect(isolated.id).not.toBe(first.id);
await expect(
prisma.job.count({
where: {
projectId: seed.project.id,
type: "noop",
targetScopeKey: `version:${seed.version.id}`,
idempotencyKey: "same-key"
}
})
).resolves.toBe(2);
});
it("reports created only for the idempotent winner under concurrent duplicate requests", async () => {
const seed = await prisma.$transaction((tx) => seedProjectSet(tx, "winner-outcome-once"));
const queue = new RecordingQueue();
const store = new JobExecutionStore({ db: prisma, now: () => fixedNow });
const withDuplicateKey = (id: string) => ({
...enqueueInput(seed, "winner-outcome-once"),
id,
idempotencyKey: "winner-outcome-once"
});
const [first, second] = await Promise.all([
store.enqueueScopedJob(withDuplicateKey(`${runId}-winner-outcome-once-a-job`), queue),
store.enqueueScopedJob(withDuplicateKey(`${runId}-winner-outcome-once-b-job`), queue)
]);
expect(second.job.id).toBe(first.job.id);
expect([first.created, second.created].filter(Boolean)).toHaveLength(1);
expect([first.enqueued, second.enqueued].filter(Boolean)).toHaveLength(1);
expect(queue.messages).toHaveLength(1);
});
it("enqueues after DB commit and marks the job recoverable when enqueue fails", async () => {
const seed = await prisma.$transaction((tx) => seedProjectSet(tx, "enqueue-after-commit"));
const store = new JobExecutionStore({ db: prisma, now: () => fixedNow, retryDelayMs: 15_000 });
const queue = new RecordingQueue({ fail: true, db: prisma });
await expect(
store.enqueueScopedJob(
{
...enqueueInput(seed, "enqueue-after-commit"),
idempotencyKey: "enqueue-after-commit"
},
queue
)
).rejects.toThrow(/QUEUE_DOWN/);
expect(queue.observedJobs).toEqual([{ id: `${runId}-enqueue-after-commit-job`, status: "queued" }]);
await expect(prisma.gameProject.findUniqueOrThrow({ where: { id: seed.project.id } })).resolves.toMatchObject({
title: "enqueue-after-commit project"
});
await expect(prisma.job.findUniqueOrThrow({ where: { id: `${runId}-enqueue-after-commit-job` } })).resolves.toMatchObject({
status: "pending_retry",
errorCode: "QUEUE_ENQUEUE_FAILED",
nextRetryAt: new Date(fixedNow.getTime() + 15_000)
});
});
it("claims exactly one eligible job and prevents concurrent workers from claiming the same row", async () => {
const seed = await prisma.$transaction((tx) => seedProjectSet(tx, "claim"));
const queue = new RecordingQueue();
const store = new JobExecutionStore({ db: prisma, now: () => fixedNow });
const job = await enqueueJob(store, enqueueInput(seed, "claim"), queue);
await prepareClaimFixture(job.id);
const [first, second] = await Promise.all([store.claimNext("worker-a", fixedNow), store.claimNext("worker-b", fixedNow)]);
const claimed = [first, second].filter((value) => value !== null);
expect(claimed).toHaveLength(1);
expect(claimed[0]).toMatchObject({ id: job.id, status: "running", leasedBy: expect.stringMatching(/^worker-/) });
expect(claimed[0]?.leaseToken).toEqual(expect.any(String));
await expect(prisma.job.findUniqueOrThrow({ where: { id: job.id } })).resolves.toMatchObject({
status: "running",
lockVersion: 1
});
});
it("rejects stale completion after lease/version changes", async () => {
const seed = await prisma.$transaction((tx) => seedProjectSet(tx, "stale-complete"));
const store = new JobExecutionStore({ db: prisma, now: () => fixedNow, retryDelayMs: 1000 });
const job = await enqueueJob(store, enqueueInput(seed, "stale-complete"), new RecordingQueue());
await prepareClaimFixture(job.id);
const claimed = await store.claimNext("worker-a", fixedNow);
expect(claimed?.id).toBe(job.id);
await store.markTimedOut(job.id, new Date(fixedNow.getTime() + 31_000), "JOB_TIMEOUT");
await expect(store.markSucceeded(job.id, claimed?.leaseToken ?? "missing")).rejects.toMatchObject<JobExecutionError>({
code: "STALE_JOB_LEASE"
});
});
it("ensures API cancel and worker completion cannot both win", async () => {
const seed = await prisma.$transaction((tx) => seedProjectSet(tx, "cancel-complete"));
const store = new JobExecutionStore({ db: prisma, now: () => fixedNow });
const queued = await enqueueJob(store, enqueueInput(seed, "cancel-first"), new RecordingQueue());
await expect(store.cancelQueued(queued.id, seed.actor.id)).resolves.toMatchObject({ status: "canceled" });
await expect(prisma.job.findUniqueOrThrow({ where: { id: queued.id } })).resolves.toMatchObject({ status: "canceled" });
const running = await enqueueJob(
store,
{ ...enqueueInput(seed, "complete-first"), id: `${runId}-complete-first-job`, idempotencyKey: "complete-first" },
new RecordingQueue()
);
await prepareClaimFixture(running.id);
const claimed = await store.claimNext("worker-b", fixedNow);
expect(claimed?.id).toBe(running.id);
await expect(store.cancelQueued(running.id, seed.actor.id)).rejects.toMatchObject<JobExecutionError>({
code: "JOB_CANCEL_NOT_ALLOWED"
});
await expect(store.markSucceeded(running.id, claimed?.leaseToken ?? "missing")).resolves.toMatchObject({
status: "succeeded"
});
});
it("marks timeout and retry outcomes with attempts, nextRetryAt, and errorCode", async () => {
const seed = await prisma.$transaction((tx) => seedProjectSet(tx, "retry-timeout"));
const store = new JobExecutionStore({ db: prisma, now: () => fixedNow, retryDelayMs: 60_000 });
const retryJob = await enqueueJob(
store,
{ ...enqueueInput(seed, "retry"), idempotencyKey: "retry", maxAttempts: 2 },
new RecordingQueue()
);
await prepareClaimFixture(retryJob.id);
const retryClaim = await store.claimNext("worker-retry", fixedNow);
expect(retryClaim?.id).toBe(retryJob.id);
await expect(store.markFailedOrRetry(retryJob.id, retryClaim?.leaseToken ?? "missing", "WORKER_FAILED")).resolves.toMatchObject({
status: "pending_retry",
attempts: 1,
errorCode: "WORKER_FAILED",
nextRetryAt: new Date(fixedNow.getTime() + 60_000)
});
const timeoutJob = await enqueueJob(
store,
{ ...enqueueInput(seed, "timeout"), id: `${runId}-timeout-job`, idempotencyKey: "timeout", maxAttempts: 1 },
new RecordingQueue()
);
await prepareClaimFixture(timeoutJob.id);
const timeoutClaim = await store.claimNext("worker-timeout", fixedNow);
expect(timeoutClaim?.id).toBe(timeoutJob.id);
await expect(store.markTimedOut(timeoutJob.id, new Date(fixedNow.getTime() + 31_000), "JOB_TIMEOUT")).resolves.toMatchObject({
status: "failed",
attempts: 1,
errorCode: "JOB_TIMEOUT",
nextRetryAt: null
});
});
it("marks jobs timed out by persisted timeoutAt even when lease has not expired", async () => {
const seed = await prisma.$transaction((tx) => seedProjectSet(tx, "persisted-timeout"));
const store = new JobExecutionStore({ db: prisma, now: () => fixedNow, leaseMs: 60_000, retryDelayMs: 10_000 });
const job = await enqueueJob(
store,
{
...enqueueInput(seed, "persisted-timeout"),
timeoutMs: -1,
maxAttempts: 2
},
new RecordingQueue()
);
await prepareClaimFixture(job.id);
const claim = await store.claimNext("worker-persisted-timeout", fixedNow);
expect(claim).toMatchObject({
id: job.id,
status: "running",
leaseExpiresAt: new Date(fixedNow.getTime() + 60_000)
});
await expect(store.markTimedOut(job.id, fixedNow, "JOB_TIMEOUT")).resolves.toMatchObject({
status: "pending_retry",
attempts: 1,
errorCode: "JOB_TIMEOUT",
nextRetryAt: new Date(fixedNow.getTime() + 10_000)
});
});
it("keeps Job runtime field writes inside JobExecutionStore", async () => {
expect(
findJobExecutionBoundaryViolations([
{
path: "apps/api/src/modules/projects/project.service.ts",
content: "await db.job.update({ where: { id }, data: { status: 'failed' } });"
},
{
path: "apps/api/src/modules/projects/comment-paren-job.service.ts",
content: `
await db.job.update({ // )
where: { id },
data: { status: "failed" }
});
`
},
{
path: "apps/api/src/modules/jobs/index.ts",
content:
"export class JobExecutionStore { async ok(db: Prisma.TransactionClient) { await db.job.update({ where: { id }, data: { status: 'failed', attempts: { increment: 1 } } }); } }"
},
{
path: "apps/api/src/modules/jobs/index.ts",
content: `
export class JobExecutionStore {
async ok(db: Prisma.TransactionClient): Promise<void> {
await db.job.update({ where: { id: "ok" }, data: { status: "running" } });
}
}
export class JobApiService {
async bad(db: Prisma.TransactionClient): Promise<void> {
await db.job.update({ where: { id: "bad" }, data: { status: "failed" } });
}
}
`
},
{
path: "apps/api/src/modules/jobs/bad.ts",
content:
"await db.job.create({ data: { projectId, actorId, status: 'queued', attempts: 0, nextRetryAt: null } });"
},
{
path: "apps/api/src/modules/projects/quoted-job.service.ts",
content:
"await db.job.update({ where: { id }, data: { \"status\": \"failed\", 'attempts': 1, [`nextRetryAt`]: null } });"
},
{
path: "apps/api/src/modules/projects/lease-token-job.service.ts",
content: 'await db.job.update({ where: { id }, data: { leaseToken: "stolen" } });'
},
{
path: "apps/api/src/modules/projects/timeout-at-job.service.ts",
content: "await db.job.update({ where: { id }, data: { timeoutAt: new Date() } });"
},
{
path: "apps/api/src/modules/projects/computed-job.service.ts",
content: 'const field = "status"; await db.job.update({ where: { id }, data: { [field]: "failed" } });'
},
{
path: "apps/api/src/modules/projects/variable-job.service.ts",
content: 'const data = { status: "failed" }; await db.job.update({ where: { id }, data });'
},
{
path: "apps/api/src/modules/projects/spread-job.service.ts",
content: 'const patch = { attempts: 1 }; await db.job.update({ where: { id }, data: { ...patch } });'
},
{
path: "apps/api/src/modules/projects/raw-status-job.service.ts",
content: `await db.$executeRawUnsafe("UPDATE \\"Job\\" SET \\"status\\" = 'failed' WHERE id = $1", id);`
},
{
path: "apps/api/src/modules/projects/raw-comment-paren-job.service.ts",
content: `
await db.$executeRawUnsafe( // )
"UPDATE \\"Job\\" SET \\"status\\" = 'failed' WHERE id = $1",
id
);
`
},
{
path: "apps/api/src/modules/projects/raw-attempts-job.service.ts",
content: 'await db.$executeRawUnsafe("UPDATE Job SET attempts = attempts + 1 WHERE id = $1", id);'
},
{
path: "apps/api/src/modules/projects/raw-next-retry-job.service.ts",
content: 'await db.$executeRawUnsafe("UPDATE \\"Job\\" SET \\"nextRetryAt\\" = NULL WHERE id = $1", id);'
},
{
path: "apps/api/src/modules/projects/raw-insert-status-job.service.ts",
content: 'await db.$executeRawUnsafe("INSERT INTO \\"Job\\" (\\"id\\", \\"status\\") VALUES ($1, \'queued\')", id);'
},
{
path: "apps/api/src/modules/projects/raw-lease-job.service.ts",
content:
'await db.$executeRawUnsafe("UPDATE \\"Job\\" SET \\"leaseToken\\" = $1, \\"lockVersion\\" = \\"lockVersion\\" + 1 WHERE id = $2", token, id);'
},
{
path: "apps/api/src/modules/projects/raw-timeout-job.service.ts",
content: 'await db.$executeRawUnsafe("INSERT INTO Job (id, timeoutAt) VALUES ($1, NOW())", id);'
}
])
).toEqual([
{
path: "apps/api/src/modules/projects/project.service.ts",
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
},
{
path: "apps/api/src/modules/projects/comment-paren-job.service.ts",
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
},
{
path: "apps/api/src/modules/jobs/index.ts",
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
},
{
path: "apps/api/src/modules/jobs/bad.ts",
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
},
{
path: "apps/api/src/modules/projects/quoted-job.service.ts",
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
},
{
path: "apps/api/src/modules/projects/lease-token-job.service.ts",
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
},
{
path: "apps/api/src/modules/projects/timeout-at-job.service.ts",
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
},
{
path: "apps/api/src/modules/projects/computed-job.service.ts",
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
},
{
path: "apps/api/src/modules/projects/variable-job.service.ts",
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
},
{
path: "apps/api/src/modules/projects/spread-job.service.ts",
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
},
{
path: "apps/api/src/modules/projects/raw-status-job.service.ts",
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
},
{
path: "apps/api/src/modules/projects/raw-comment-paren-job.service.ts",
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
},
{
path: "apps/api/src/modules/projects/raw-attempts-job.service.ts",
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
},
{
path: "apps/api/src/modules/projects/raw-next-retry-job.service.ts",
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
},
{
path: "apps/api/src/modules/projects/raw-insert-status-job.service.ts",
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
},
{
path: "apps/api/src/modules/projects/raw-lease-job.service.ts",
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
},
{
path: "apps/api/src/modules/projects/raw-timeout-job.service.ts",
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
}
]);
await expect(scanJobExecutionBoundary(process.cwd())).resolves.toEqual([]);
});
it("does not expose pre-enqueue mutation hooks from the Task 8 job runtime", async () => {
const runtime = await readFile(new URL("./index.ts", import.meta.url), "utf8");
expect(runtime).not.toMatch(/\bonJobCreated\b/);
expect(runtime).not.toMatch(/\bmutateTarget\b/);
});
});
async function cleanupTask7JobRows(): Promise<void> {
await prisma.job.deleteMany({ where: { id: { startsWith: runId } } });
await prisma.gameProject.deleteMany({ where: { id: { startsWith: runId } } });
await prisma.user.deleteMany({ where: { id: { startsWith: runId } } });
}
async function acquireJobTestIsolationLock(): Promise<void> {
// 仅测试隔离claimNext 是全局领取,跨 spec 并发时用 DB advisory lock 缩小测试窗口,不改写外部 Job。
lockClient = new pg.Client({ connectionString: databaseUrl });
await lockClient.connect();
await lockClient.query("SELECT pg_advisory_lock($1)", [jobTestIsolationLockKey]);
}
async function releaseJobTestIsolationLock(): Promise<void> {
if (!lockClient) return;
try {
await lockClient.query("SELECT pg_advisory_unlock($1)", [jobTestIsolationLockKey]);
} finally {
await lockClient.end();
lockClient = undefined;
}
}

View File

@ -0,0 +1,49 @@
import { Prisma } from "../../generated/prisma/client.js";
import { JobExecutionError } from "../jobs/index.js";
import { QueueUnavailableError } from "../queue/index.js";
import { StateTransitionDomainError } from "../state-transition/index.js";
import { mapExceptionToHttpError } from "./api-runtime.js";
import { describe, expect, it } from "vitest";
describe("Structured API exception mapping", () => {
it("maps Prisma unique and missing-row errors to stable structured statuses", () => {
const unique = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "task8-test",
meta: { target: ["slug"] }
});
const missing = new Prisma.PrismaClientKnownRequestError("Record not found", {
code: "P2025",
clientVersion: "task8-test"
});
expect(mapExceptionToHttpError(unique, "req-1")).toEqual({
status: 409,
body: { code: "CONFLICT", message: expect.any(String), requestId: "req-1", details: expect.anything() }
});
expect(mapExceptionToHttpError(missing, "req-2")).toEqual({
status: 404,
body: { code: "NOT_FOUND", message: expect.any(String), requestId: "req-2", details: expect.anything() }
});
});
it("maps queue, job, and state-transition domain errors without leaking raw 500s", () => {
expect(mapExceptionToHttpError(new QueueUnavailableError("redis unavailable"), null)).toEqual({
status: 503,
body: { code: "QUEUE_UNAVAILABLE", message: expect.any(String), requestId: null, details: expect.anything() }
});
expect(mapExceptionToHttpError(new JobExecutionError("TARGET_PROJECT_MISMATCH"), null)).toEqual({
status: 422,
body: { code: "TARGET_PROJECT_MISMATCH", message: expect.any(String), requestId: null, details: expect.anything() }
});
expect(mapExceptionToHttpError(new StateTransitionDomainError("HARNESS_INVALID_OUTPUT"), null)).toEqual({
status: 422,
body: {
code: "STATE_TRANSITION_REJECTED",
message: expect.any(String),
requestId: null,
details: expect.objectContaining({ reasonCode: "HARNESS_INVALID_OUTPUT" })
}
});
});
});

View File

@ -0,0 +1,182 @@
import { Catch, Global, HttpException, HttpStatus, Injectable, Module, type ArgumentsHost, type ExceptionFilter } from "@nestjs/common";
import { APP_FILTER } from "@nestjs/core";
import { PrismaPg } from "@prisma/adapter-pg";
import { Prisma, PrismaClient } from "../../generated/prisma/client.js";
export type StructuredApiError = {
readonly code: string;
readonly message: string;
readonly requestId: string | null;
readonly details: unknown;
};
type HttpRequestLike = {
readonly headers?: {
readonly "x-request-id"?: string | readonly string[];
};
};
type HttpResponseLike = {
status(statusCode: number): {
json(body: StructuredApiError): void;
};
};
export type MappedHttpError = {
readonly status: number;
readonly body: StructuredApiError;
};
const databaseUrl = process.env.DATABASE_URL ?? "postgresql://huijing:huijing@localhost:5432/huijing_dev?schema=public";
export function apiError(code: string, message: string, details: unknown = {}): StructuredApiError {
return {
code,
message,
requestId: null,
details: details ?? {}
};
}
function requestIdFrom(request: HttpRequestLike): string | null {
const value = request.headers?.["x-request-id"];
if (typeof value === "string" && value.trim() !== "") return value;
if (Array.isArray(value) && typeof value[0] === "string" && value[0].trim() !== "") return value[0];
return null;
}
function isNamedDomainError(exception: unknown, name: string): boolean {
return exception instanceof Error && exception.name === name;
}
function errorCodeOf(exception: unknown): string | undefined {
if (typeof exception === "object" && exception !== null && "code" in exception && typeof exception.code === "string") {
return exception.code;
}
return undefined;
}
function reasonCodeOf(exception: unknown): string | undefined {
if (typeof exception === "object" && exception !== null && "reasonCode" in exception && typeof exception.reasonCode === "string") {
return exception.reasonCode;
}
return undefined;
}
function mappedBody(code: string, message: string, requestId: string | null, details: unknown = {}): StructuredApiError {
return { code, message, requestId, details: details ?? {} };
}
function mapJobExecutionError(exception: unknown, requestId: string | null): MappedHttpError | null {
if (!isNamedDomainError(exception, "JobExecutionError")) return null;
const code = errorCodeOf(exception) ?? "JOB_EXECUTION_ERROR";
const status =
code === "TARGET_NOT_FOUND" || code === "JOB_NOT_FOUND"
? HttpStatus.NOT_FOUND
: code === "TARGET_PROJECT_MISMATCH"
? HttpStatus.UNPROCESSABLE_ENTITY
: code === "JOB_ENQUEUE_REQUIRES_ROOT_CLIENT"
? HttpStatus.INTERNAL_SERVER_ERROR
: HttpStatus.CONFLICT;
return {
status,
body: mappedBody(code, "Job execution boundary rejected the request", requestId, { code })
};
}
export function mapExceptionToHttpError(exception: unknown, requestId: string | null): MappedHttpError {
if (exception instanceof HttpException) {
const response = exception.getResponse();
if (typeof response === "object" && response !== null && "code" in response) {
const body = response as Partial<StructuredApiError>;
return {
status: exception.getStatus(),
body: {
code: typeof body.code === "string" ? body.code : "HTTP_ERROR",
message: typeof body.message === "string" ? body.message : exception.message,
requestId,
details: body.details ?? {}
}
};
}
return {
status: exception.getStatus(),
body: mappedBody(exception.getStatus() === HttpStatus.NOT_FOUND ? "NOT_FOUND" : "HTTP_ERROR", exception.message, requestId)
};
}
if (exception instanceof Prisma.PrismaClientKnownRequestError) {
if (exception.code === "P2002") {
return {
status: HttpStatus.CONFLICT,
body: mappedBody("CONFLICT", "Unique constraint conflict", requestId, { prismaCode: exception.code, meta: exception.meta })
};
}
if (exception.code === "P2025") {
return {
status: HttpStatus.NOT_FOUND,
body: mappedBody("NOT_FOUND", "Requested record was not found", requestId, { prismaCode: exception.code })
};
}
}
if (isNamedDomainError(exception, "QueueUnavailableError") || errorCodeOf(exception) === "QUEUE_UNAVAILABLE") {
return {
status: HttpStatus.SERVICE_UNAVAILABLE,
body: mappedBody("QUEUE_UNAVAILABLE", "Queue is unavailable", requestId, { queue: true })
};
}
const jobError = mapJobExecutionError(exception, requestId);
if (jobError) return jobError;
if (isNamedDomainError(exception, "StateTransitionDomainError")) {
const reasonCode = reasonCodeOf(exception) ?? "HARNESS_INVALID_OUTPUT";
return {
status: HttpStatus.UNPROCESSABLE_ENTITY,
body: mappedBody("STATE_TRANSITION_REJECTED", "State transition rejected by harness", requestId, { reasonCode })
};
}
return {
status: HttpStatus.INTERNAL_SERVER_ERROR,
body: mappedBody("INTERNAL_ERROR", "Internal server error", requestId)
};
}
@Catch()
export class StructuredApiExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const http = host.switchToHttp();
const request = http.getRequest<HttpRequestLike>();
const response = http.getResponse<HttpResponseLike>();
const mapped = mapExceptionToHttpError(exception, requestIdFrom(request));
response.status(mapped.status).json(mapped.body);
}
}
@Injectable()
export class S1PrismaClient extends PrismaClient {
constructor() {
super({ adapter: new PrismaPg({ connectionString: databaseUrl }) });
}
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
}
}
@Global()
@Module({
providers: [
S1PrismaClient,
{
provide: APP_FILTER,
useClass: StructuredApiExceptionFilter
}
],
exports: [S1PrismaClient]
})
export class S1ApiRuntimeModule {}

View File

@ -0,0 +1,284 @@
import { randomUUID } from "node:crypto";
import {
BadRequestException,
Body,
Controller,
ForbiddenException,
Get,
Injectable,
Module,
NotFoundException,
Param,
Post,
Req,
UseGuards
} from "@nestjs/common";
import { Prisma, type GameProject, type GameVersion } from "../../generated/prisma/client.js";
import { AuthGuard, AuthModule, type AuthActor } from "../auth/index.js";
import { AuditService } from "../audit/index.js";
import { canReadReviewFoundationData, creatorOwnsResource, hasRole } from "../rbac/index.js";
import { StateTransitionService } from "../state-transition/index.js";
import { apiError, S1ApiRuntimeModule, S1PrismaClient } from "./api-runtime.js";
type RequestWithActor = {
readonly actor: AuthActor;
};
type CreateProjectBody = {
readonly title?: unknown;
readonly slug?: unknown;
};
type CreateVersionBody = {
readonly configJson?: unknown;
};
export type ProjectDto = {
readonly id: string;
readonly ownerId: string;
readonly slug: string;
readonly title: string;
readonly status: string;
readonly createdAt: string;
readonly updatedAt: string;
};
export type VersionDto = {
readonly id: string;
readonly projectId: string;
readonly versionNumber: number;
readonly status: string;
readonly configJson: unknown;
readonly createdAt: string;
readonly updatedAt: string;
};
function projectDto(project: GameProject): ProjectDto {
return {
id: project.id,
ownerId: project.ownerId,
slug: project.slug,
title: project.title,
status: project.status,
createdAt: project.createdAt.toISOString(),
updatedAt: project.updatedAt.toISOString()
};
}
function versionDto(version: GameVersion): VersionDto {
return {
id: version.id,
projectId: version.projectId,
versionNumber: version.versionNumber,
status: version.status,
configJson: version.configJson,
createdAt: version.createdAt.toISOString(),
updatedAt: version.updatedAt.toISOString()
};
}
function requireNonEmptyString(value: unknown, field: string): string {
if (typeof value !== "string" || value.trim() === "") {
throw new BadRequestException(apiError("INVALID_REQUEST", `${field} must be a non-empty string`, { field }));
}
return value.trim();
}
function optionalSafeSlug(value: unknown, fallbackId: string): string {
if (value === undefined) return `project-${fallbackId.slice(0, 8)}`;
const slug = requireNonEmptyString(value, "slug");
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,80}$/.test(slug)) {
throw new BadRequestException(apiError("INVALID_REQUEST", "slug contains unsafe characters", { field: "slug" }));
}
return slug;
}
function inputJson(value: unknown): Prisma.InputJsonValue {
if (value === undefined) return {};
if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw new BadRequestException(apiError("INVALID_REQUEST", "configJson must be a JSON object", { field: "configJson" }));
}
return value as Prisma.InputJsonObject;
}
@Injectable()
export class ProjectsApiService {
constructor(private readonly db: S1PrismaClient) {}
async createProject(actor: AuthActor, body: CreateProjectBody): Promise<ProjectDto> {
if (!hasRole(actor, "creator")) {
// S1 只有 creator 可以创建项目operator/admin 在 Task 8 仅有 foundation read不是管理 CRUD。
throw new ForbiddenException(apiError("FORBIDDEN", "Creator role is required to create a project", { requiredRole: "creator" }));
}
const title = requireNonEmptyString(body.title, "title");
const id = randomUUID();
const slug = optionalSafeSlug(body.slug, id);
const project = await this.db.$transaction(async (tx) => {
await this.ensureActorUser(tx, actor);
const created = await tx.gameProject.create({
data: {
id,
ownerId: actor.id,
slug,
title
}
});
await new AuditService({ db: tx }).append({
id: randomUUID(),
actorId: actor.id,
action: "project.created",
targetType: "GameProject",
targetId: created.id,
eventJson: {
// 项目创建改变 ownership 边界,审计记录只写事实,不包含可变业务状态。
ownerId: actor.id,
slug: created.slug
}
});
return created;
});
return projectDto(project);
}
async listProjects(actor: AuthActor): Promise<ProjectDto[]> {
const where = canReadReviewFoundationData(actor, "project") ? {} : { ownerId: actor.id };
const projects = await this.db.gameProject.findMany({
where,
orderBy: [{ createdAt: "asc" }, { id: "asc" }]
});
return projects.map(projectDto);
}
async getProject(actor: AuthActor, projectId: string): Promise<ProjectDto> {
const project = await this.requireReadableProject(actor, projectId, "project");
return projectDto(project);
}
async createDraftVersion(actor: AuthActor, projectId: string, body: CreateVersionBody): Promise<VersionDto> {
const project = await this.requireReadableProject(actor, projectId, "project");
if (!creatorOwnsResource(actor, project)) {
// 版本创建是 owner 写路径operator/admin 在 S1 只有 foundation read没有管理写权限。
throw new ForbiddenException(apiError("FORBIDDEN", "Only the owning creator can create a draft version", { projectId }));
}
const configJson = inputJson(body.configJson);
const version = await this.db.$transaction(async (tx) => {
const latest = await tx.gameVersion.findFirst({
where: { projectId },
orderBy: { versionNumber: "desc" }
});
const service = new StateTransitionService({
db: tx,
blockedAuditDb: tx,
harnessGate: {
validateContract: async () => ({ ok: true, reasonCode: null }),
validateTransition: async () => ({ ok: true, reasonCode: null })
}
});
const created = await service.createDraftVersion({
id: randomUUID(),
projectId,
versionNumber: (latest?.versionNumber ?? 0) + 1,
configJson
});
await new AuditService({ db: tx }).append({
id: randomUUID(),
actorId: actor.id,
action: "version.created",
targetType: "GameVersion",
targetId: created.id,
eventJson: {
// draft 状态写入必须走 StateTransitionService审计只记录边界调用事实。
projectId,
versionNumber: created.versionNumber,
status: created.status
}
});
return created;
});
return versionDto(version);
}
async listVersions(actor: AuthActor, projectId: string): Promise<VersionDto[]> {
await this.requireReadableProject(actor, projectId, "version");
const versions = await this.db.gameVersion.findMany({
where: { projectId },
orderBy: [{ versionNumber: "asc" }, { id: "asc" }]
});
return versions.map(versionDto);
}
private async requireReadableProject(
actor: AuthActor,
projectId: string,
category: "project" | "version"
): Promise<GameProject> {
const project = await this.db.gameProject.findUnique({ where: { id: projectId } });
if (!project) throw new NotFoundException(apiError("NOT_FOUND", "Project not found", { projectId }));
if (creatorOwnsResource(actor, project) || canReadReviewFoundationData(actor, category)) return project;
throw new ForbiddenException(apiError("FORBIDDEN", "Actor cannot access this project", { projectId }));
}
private async ensureActorUser(tx: Prisma.TransactionClient, actor: AuthActor): Promise<void> {
await tx.user.upsert({
where: { id: actor.id },
update: {},
create: {
id: actor.id,
email: actor.email,
displayName: actor.displayName
}
});
}
}
@Controller()
@UseGuards(AuthGuard)
export class ProjectsController {
constructor(private readonly service: ProjectsApiService) {}
@Post("projects")
createProject(@Req() request: RequestWithActor, @Body() body: CreateProjectBody): Promise<ProjectDto> {
return this.service.createProject(request.actor, body);
}
@Get("projects")
listProjects(@Req() request: RequestWithActor): Promise<ProjectDto[]> {
return this.service.listProjects(request.actor);
}
@Get("projects/:projectId")
getProject(@Req() request: RequestWithActor, @Param("projectId") projectId: string): Promise<ProjectDto> {
return this.service.getProject(request.actor, projectId);
}
@Post("projects/:projectId/versions")
createDraftVersion(
@Req() request: RequestWithActor,
@Param("projectId") projectId: string,
@Body() body: CreateVersionBody
): Promise<VersionDto> {
return this.service.createDraftVersion(request.actor, projectId, body);
}
@Get("projects/:projectId/versions")
listVersions(@Req() request: RequestWithActor, @Param("projectId") projectId: string): Promise<VersionDto[]> {
return this.service.listVersions(request.actor, projectId);
}
}
@Module({
imports: [S1ApiRuntimeModule, AuthModule],
controllers: [ProjectsController],
providers: [ProjectsApiService],
exports: [ProjectsApiService]
})
export class ProjectsModule {}

View File

@ -0,0 +1,323 @@
import { type INestApplication } from "@nestjs/common";
import { Test } from "@nestjs/testing";
import { PrismaPg } from "@prisma/adapter-pg";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AppModule } from "../../app.module.js";
import { Prisma, PrismaClient } from "../../generated/prisma/client.js";
import { StateTransitionService } from "../state-transition/index.js";
const databaseUrl = process.env.DATABASE_URL ?? "postgresql://huijing:huijing@localhost:5432/huijing_dev?schema=public";
const adapter = new PrismaPg({ connectionString: databaseUrl });
const prisma = new PrismaClient({ adapter });
const runId = `task8-projects-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
type DbClient = Prisma.TransactionClient;
type TestApp = {
readonly app: INestApplication;
readonly baseUrl: string;
};
async function createTestApp(): Promise<TestApp> {
const moduleRef = await Test.createTestingModule({
imports: [AppModule]
}).compile();
const app = moduleRef.createNestApplication();
await app.init();
const server = await app.listen(0);
const address = server.address();
if (address === null || typeof address === "string") throw new Error("Nest test server did not expose a TCP port");
return { app, baseUrl: `http://127.0.0.1:${address.port}` };
}
async function requestJson(testApp: TestApp, path: string, init: RequestInit = {}) {
const response = await fetch(`${testApp.baseUrl}${path}`, {
...init,
headers: {
"content-type": "application/json",
...init.headers
}
});
const body = (await response.json().catch(() => null)) as unknown;
return { response, body };
}
async function login(testApp: TestApp, email: string): Promise<string> {
const response = await requestJson(testApp, "/auth/login", {
method: "POST",
body: JSON.stringify({ email })
});
expect(response.response.status).toBe(201);
return (response.body as { token: string }).token;
}
async function ensureSeedUsers(db: DbClient): Promise<void> {
await db.user.upsert({
where: { id: "seed-creator" },
update: {},
create: { id: "seed-creator", email: "creator@example.test", displayName: "Seed Creator" }
});
await db.user.upsert({
where: { id: "seed-creator-other" },
update: {},
create: { id: "seed-creator-other", email: "creator-other@example.test", displayName: "Seed Other Creator" }
});
await db.user.upsert({
where: { id: "seed-operator" },
update: {},
create: { id: "seed-operator", email: "operator@example.test", displayName: "Seed Operator" }
});
await db.user.upsert({
where: { id: "seed-admin" },
update: {},
create: { id: "seed-admin", email: "admin@example.test", displayName: "Seed Admin" }
});
await db.user.upsert({
where: { id: "seed-player" },
update: {},
create: { id: "seed-player", email: "player@example.test", displayName: "Seed Player" }
});
}
async function seedProject(db: DbClient, suffix: string, ownerId = "seed-creator") {
return db.gameProject.create({
data: {
id: `${runId}-${suffix}-project`,
ownerId,
slug: `${runId}-${suffix}`,
title: `${suffix} project`
}
});
}
async function seedDraftVersion(db: DbClient, projectId: string, suffix: string, versionNumber = 1) {
const service = new StateTransitionService({
db,
blockedAuditDb: db,
harnessGate: {
validateContract: async () => ({ ok: true, reasonCode: null }),
validateTransition: async () => ({ ok: true, reasonCode: null })
}
});
return service.createDraftVersion({
id: `${runId}-${suffix}-version`,
projectId,
versionNumber,
configJson: { suffix }
});
}
async function cleanup(): Promise<void> {
await prisma.job.deleteMany({ where: { id: { startsWith: runId } } });
await prisma.gameProject.deleteMany({ where: { OR: [{ id: { startsWith: runId } }, { slug: { startsWith: runId } }] } });
}
describe("Project and version HTTP APIs", () => {
let testApp: TestApp | undefined;
beforeEach(async () => {
await cleanup();
await ensureSeedUsers(prisma);
testApp = await createTestApp();
});
afterEach(async () => {
await testApp?.app.close();
testApp = undefined;
await cleanup();
});
it("POST /projects creates a creator-owned project and appends an audit fact", async () => {
const token = await login(testApp!, "creator@example.test");
const created = await requestJson(testApp!, "/projects", {
method: "POST",
headers: { authorization: `Bearer ${token}` },
body: JSON.stringify({ title: "Task 8 Project", slug: `${runId}-created` })
});
expect(created.response.status).toBe(201);
expect(created.body).toMatchObject({
slug: `${runId}-created`,
title: "Task 8 Project",
ownerId: "seed-creator",
status: "active"
});
const projectId = (created.body as { id: string }).id;
await expect(prisma.gameProject.findUnique({ where: { id: projectId } })).resolves.toMatchObject({
ownerId: "seed-creator"
});
await expect(prisma.auditLog.findFirst({ where: { targetId: projectId, action: "project.created" } })).resolves.toMatchObject({
actorId: "seed-creator",
targetType: "GameProject"
});
});
it.each([
["operator", "operator@example.test", "seed-operator"],
["admin", "admin@example.test", "seed-admin"],
["player", "player@example.test", "seed-player"]
])("POST /projects rejects authenticated non-creator %s without project or audit mutation", async (_label, email, actorId) => {
const token = await login(testApp!, email);
const slug = `${runId}-non-creator-${actorId}`;
const beforeAuditCount = await prisma.auditLog.count({
where: { actorId, action: "project.created", targetType: "GameProject" }
});
const denied = await requestJson(testApp!, "/projects", {
method: "POST",
headers: { authorization: `Bearer ${token}` },
body: JSON.stringify({ title: "Blocked Project", slug })
});
expect(denied.response.status).toBe(403);
expect(denied.body).toEqual({
code: "FORBIDDEN",
message: expect.any(String),
requestId: null,
details: expect.anything()
});
await expect(prisma.gameProject.count({ where: { slug } })).resolves.toBe(0);
await expect(
prisma.auditLog.count({ where: { actorId, action: "project.created", targetType: "GameProject" } })
).resolves.toBe(beforeAuditCount);
});
it("POST /projects rejects anonymous callers with structured 403 before mutation", async () => {
const slug = `${runId}-anonymous-create`;
const beforeAuditCount = await prisma.auditLog.count({ where: { action: "project.created", targetType: "GameProject" } });
const denied = await requestJson(testApp!, "/projects", {
method: "POST",
body: JSON.stringify({ title: "Blocked Anonymous Project", slug })
});
expect(denied.response.status).toBe(403);
expect(denied.body).toEqual({
code: "FORBIDDEN",
message: expect.any(String),
requestId: null,
details: expect.anything()
});
await expect(prisma.gameProject.count({ where: { slug } })).resolves.toBe(0);
await expect(prisma.auditLog.count({ where: { action: "project.created", targetType: "GameProject" } })).resolves.toBe(
beforeAuditCount
);
});
it("GET /projects returns only owned projects for creators", async () => {
await seedProject(prisma, "owned", "seed-creator");
await seedProject(prisma, "foreign", "seed-creator-other");
const token = await login(testApp!, "creator@example.test");
const listed = await requestJson(testApp!, "/projects", {
headers: { authorization: `Bearer ${token}` }
});
expect(listed.response.status).toBe(200);
const projects = listed.body as Array<{ id: string; ownerId: string }>;
expect(projects).toContainEqual(expect.objectContaining({ id: `${runId}-owned-project`, ownerId: "seed-creator" }));
expect(projects).not.toContainEqual(expect.objectContaining({ id: `${runId}-foreign-project` }));
expect(projects.every((project) => project.ownerId === "seed-creator")).toBe(true);
});
it("GET /projects/:projectId rejects foreign creators and allows operator/admin foundation reads", async () => {
const foreignProject = await seedProject(prisma, "foreign-detail", "seed-creator-other");
const creatorToken = await login(testApp!, "creator@example.test");
const operatorToken = await login(testApp!, "operator@example.test");
const adminToken = await login(testApp!, "admin@example.test");
const denied = await requestJson(testApp!, `/projects/${foreignProject.id}`, {
headers: { authorization: `Bearer ${creatorToken}` }
});
expect(denied.response.status).toBe(403);
expect(denied.body).toEqual({
code: "FORBIDDEN",
message: expect.any(String),
requestId: null,
details: expect.anything()
});
const operatorRead = await requestJson(testApp!, `/projects/${foreignProject.id}`, {
headers: { authorization: `Bearer ${operatorToken}` }
});
expect(operatorRead.response.status).toBe(200);
expect(operatorRead.body).toMatchObject({ id: foreignProject.id, ownerId: "seed-creator-other" });
const adminRead = await requestJson(testApp!, `/projects/${foreignProject.id}`, {
headers: { authorization: `Bearer ${adminToken}` }
});
expect(adminRead.response.status).toBe(200);
expect(adminRead.body).toMatchObject({ id: foreignProject.id, ownerId: "seed-creator-other" });
});
it("POST /projects/:projectId/versions creates a draft version for the owner through the state boundary", async () => {
const project = await seedProject(prisma, "version-owner", "seed-creator");
const token = await login(testApp!, "creator@example.test");
const created = await requestJson(testApp!, `/projects/${project.id}/versions`, {
method: "POST",
headers: { authorization: `Bearer ${token}` },
body: JSON.stringify({ configJson: { source: "task8" } })
});
expect(created.response.status).toBe(201);
expect(created.body).toMatchObject({
projectId: project.id,
versionNumber: 1,
status: "draft",
configJson: { source: "task8" }
});
await expect(
prisma.auditLog.findFirst({ where: { targetId: (created.body as { id: string }).id, action: "version.created" } })
).resolves.toMatchObject({ actorId: "seed-creator", targetType: "GameVersion" });
});
it("POST /projects/:projectId/versions rejects foreign creators without mutation", async () => {
const project = await seedProject(prisma, "version-foreign", "seed-creator-other");
const token = await login(testApp!, "creator@example.test");
const denied = await requestJson(testApp!, `/projects/${project.id}/versions`, {
method: "POST",
headers: { authorization: `Bearer ${token}` },
body: JSON.stringify({ configJson: { blocked: true } })
});
expect(denied.response.status).toBe(403);
expect(denied.body).toMatchObject({ code: "FORBIDDEN", requestId: null, details: expect.anything() });
await expect(prisma.gameVersion.count({ where: { projectId: project.id } })).resolves.toBe(0);
await expect(prisma.auditLog.count({ where: { targetId: project.id } })).resolves.toBe(0);
});
it("GET /projects/:projectId/versions returns only authorized versions and allows operator/admin reads", async () => {
const ownedProject = await seedProject(prisma, "versions-owned", "seed-creator");
const foreignProject = await seedProject(prisma, "versions-foreign", "seed-creator-other");
await prisma.$transaction(async (tx) => {
await seedDraftVersion(tx, ownedProject.id, "versions-owned", 1);
await seedDraftVersion(tx, foreignProject.id, "versions-foreign", 1);
});
const creatorToken = await login(testApp!, "creator@example.test");
const operatorToken = await login(testApp!, "operator@example.test");
const owned = await requestJson(testApp!, `/projects/${ownedProject.id}/versions`, {
headers: { authorization: `Bearer ${creatorToken}` }
});
expect(owned.response.status).toBe(200);
expect((owned.body as Array<{ id: string }>).map((version) => version.id)).toEqual([`${runId}-versions-owned-version`]);
const denied = await requestJson(testApp!, `/projects/${foreignProject.id}/versions`, {
headers: { authorization: `Bearer ${creatorToken}` }
});
expect(denied.response.status).toBe(403);
expect(denied.body).toMatchObject({ code: "FORBIDDEN", message: expect.any(String), requestId: null });
const operatorRead = await requestJson(testApp!, `/projects/${foreignProject.id}/versions`, {
headers: { authorization: `Bearer ${operatorToken}` }
});
expect(operatorRead.response.status).toBe(200);
expect(operatorRead.body).toEqual([
expect.objectContaining({ id: `${runId}-versions-foreign-version`, projectId: foreignProject.id, status: "draft" })
]);
});
});

View File

@ -0,0 +1,210 @@
import { EventEmitter } from "node:events";
export type QueueMessage = {
readonly jobId: string;
readonly type: string;
readonly payload: unknown;
};
export type QueueHandler = (message: QueueMessage) => Promise<void> | void;
export interface QueueAdapter {
enqueue(message: QueueMessage): Promise<void>;
process(handler: QueueHandler): Promise<void>;
close(): Promise<void>;
}
export class QueueUnavailableError extends Error {
readonly code = "QUEUE_UNAVAILABLE";
constructor(message = "Queue is unavailable") {
super(message);
this.name = "QueueUnavailableError";
}
}
export class InMemoryQueueAdapter implements QueueAdapter {
private readonly messages: QueueMessage[] = [];
private closed = false;
async enqueue(message: QueueMessage): Promise<void> {
if (this.closed) throw new QueueUnavailableError("In-memory queue is closed");
this.messages.push(message);
}
async process(handler: QueueHandler): Promise<void> {
if (this.closed) throw new QueueUnavailableError("In-memory queue is closed");
while (this.messages.length > 0) {
const message = this.messages.shift();
if (message) await handler(message);
}
}
async close(): Promise<void> {
this.closed = true;
this.messages.length = 0;
}
}
export type BullMqQueueAdapterOptions = {
readonly queueName: string;
readonly redisUrl: string;
readonly connectionTimeoutMs?: number;
};
type BullMqQueueInstance = {
add(name: string, data: QueueMessage): Promise<unknown>;
close(): Promise<void>;
};
type BullMqWorkerInstance = {
close(): Promise<void>;
on(event: string, listener: (error: Error) => void): unknown;
};
type BullMqModule = {
readonly Queue: new (name: string, options: { connection: unknown }) => BullMqQueueInstance;
readonly Worker: new (
name: string,
handler: (job: { data: QueueMessage }) => Promise<void>,
options: { connection: unknown }
) => BullMqWorkerInstance;
};
type IoredisModule = {
readonly default: new (
url: string,
options: { maxRetriesPerRequest: null; enableReadyCheck: boolean; lazyConnect: boolean; connectTimeout: number }
) => {
connect(): Promise<void>;
disconnect(): void;
};
};
function timeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new QueueUnavailableError()), timeoutMs);
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error: unknown) => {
clearTimeout(timer);
reject(error);
}
);
});
}
async function importBullMq(): Promise<BullMqModule> {
return (await import("bullmq")) as BullMqModule;
}
async function importIoredis(): Promise<IoredisModule> {
return (await import("ioredis")) as unknown as IoredisModule;
}
export class BullMqQueueAdapter implements QueueAdapter {
private readonly queueName: string;
private readonly redisUrl: string;
private readonly connectionTimeoutMs: number;
private queue: BullMqQueueInstance | undefined;
private worker: BullMqWorkerInstance | undefined;
private connection:
| {
connect(): Promise<void>;
disconnect(): void;
}
| undefined;
constructor(options: BullMqQueueAdapterOptions) {
this.queueName = options.queueName;
this.redisUrl = options.redisUrl;
this.connectionTimeoutMs = options.connectionTimeoutMs ?? 1000;
}
async enqueue(message: QueueMessage): Promise<void> {
const queue = await this.getQueue();
try {
await timeout(queue.add(message.type, message), this.connectionTimeoutMs);
} catch (error) {
throw this.toUnavailable(error);
}
}
async process(handler: QueueHandler): Promise<void> {
try {
const [{ Worker }, connection] = await Promise.all([importBullMq(), this.getConnection()]);
this.worker = new Worker(
this.queueName,
async (job) => {
await handler(job.data);
},
{ connection }
);
this.worker.on("error", () => undefined);
} catch (error) {
throw this.toUnavailable(error);
}
}
async close(): Promise<void> {
await this.worker?.close().catch(() => undefined);
await this.queue?.close().catch(() => undefined);
this.connection?.disconnect();
}
private async getQueue(): Promise<BullMqQueueInstance> {
if (this.queue) return this.queue;
try {
const [{ Queue }, connection] = await Promise.all([importBullMq(), this.getConnection()]);
this.queue = new Queue(this.queueName, { connection });
return this.queue;
} catch (error) {
throw this.toUnavailable(error);
}
}
private async getConnection(): Promise<NonNullable<BullMqQueueAdapter["connection"]>> {
if (this.connection) return this.connection;
try {
const { default: Redis } = await importIoredis();
const connection = new Redis(this.redisUrl, {
maxRetriesPerRequest: null,
enableReadyCheck: false,
lazyConnect: true,
connectTimeout: this.connectionTimeoutMs
});
await timeout(connection.connect(), this.connectionTimeoutMs);
this.connection = connection;
return connection;
} catch (error) {
throw this.toUnavailable(error);
}
}
private toUnavailable(error: unknown): QueueUnavailableError {
if (error instanceof QueueUnavailableError) return error;
const message = error instanceof Error ? error.message : "Queue is unavailable";
return new QueueUnavailableError(message);
}
}
export class NoopQueueProcessor extends EventEmitter {
private processed = 0;
async handle(message: QueueMessage): Promise<void> {
// S1 worker 只消费 no-op 消息,不在 worker 内 claim/complete DB row避免越过 API-owned JobExecutionStore。
if (message.type !== "noop") throw new QueueUnavailableError(`Unsupported S1 job type: ${message.type}`);
this.processed += 1;
this.emit("processed", message);
}
getProcessedCount(): number {
return this.processed;
}
}

View File

@ -0,0 +1,32 @@
import { BullMqQueueAdapter, InMemoryQueueAdapter, QueueUnavailableError } from "./index.js";
import { describe, expect, it } from "vitest";
describe("QueueAdapter", () => {
it("memory adapter processes a no-op job in tests", async () => {
const adapter = new InMemoryQueueAdapter();
const processed: string[] = [];
await adapter.enqueue({ jobId: "job-memory-1", type: "noop", payload: { ok: true } });
await adapter.process(async (message) => {
processed.push(message.jobId);
});
expect(processed).toEqual(["job-memory-1"]);
await adapter.close();
});
it("BullMQ adapter reports QUEUE_UNAVAILABLE when Redis is unavailable", async () => {
const adapter = new BullMqQueueAdapter({
queueName: "task7-unavailable",
redisUrl: "redis://127.0.0.1:1",
connectionTimeoutMs: 50
});
await expect(adapter.enqueue({ jobId: "job-bullmq-1", type: "noop", payload: {} })).rejects.toMatchObject<
QueueUnavailableError
>({
code: "QUEUE_UNAVAILABLE"
});
await adapter.close();
});
});

View File

@ -0,0 +1,95 @@
export type UserRoleName = "admin" | "operator" | "creator" | "player";
export type ActorRoleName = UserRoleName | "anonymous";
export type AuthenticatedActor = {
readonly id: string;
readonly roles: readonly UserRoleName[];
};
export type AnonymousActor = {
readonly id: null;
readonly roles: readonly ["anonymous"];
};
export type RbacActor = AuthenticatedActor | AnonymousActor;
export type ReviewFoundationDataCategory =
| "project"
| "version"
| "audit-log"
| "review-record"
| "lifecycle-event"
| "management-crud";
const knownRoles = new Set<ActorRoleName>(["admin", "operator", "creator", "player", "anonymous"]);
const reviewFoundationReadCategories = new Set<ReviewFoundationDataCategory>([
"project",
"version",
"audit-log",
"review-record",
"lifecycle-event"
]);
export class RbacForbiddenError extends Error {
readonly statusCode = 403;
readonly code = "FORBIDDEN";
constructor(message: string) {
super(message);
this.name = "RbacForbiddenError";
}
}
export function makeAnonymousActor(): AnonymousActor {
return { id: null, roles: ["anonymous"] };
}
export function isKnownRole(role: unknown): role is ActorRoleName {
return typeof role === "string" && knownRoles.has(role as ActorRoleName);
}
export function hasRole(actor: RbacActor, role: UserRoleName): boolean {
return actor.roles.some((actorRole) => actorRole === role);
}
export function isAuthenticated(actor: RbacActor): actor is AuthenticatedActor {
return actor.id !== null;
}
export function requireAuthenticated(actor: RbacActor): AuthenticatedActor {
if (!isAuthenticated(actor)) {
throw new Error("FORBIDDEN: anonymous actor is outside the S1 protected-route boundary");
}
return actor;
}
export function creatorOwnsResource(
actor: RbacActor,
resource: { readonly ownerId?: string; readonly creatorId?: string; readonly actorId?: string }
): boolean {
if (!isAuthenticated(actor) || !hasRole(actor, "creator")) return false;
// direct-object 授权必须 fail closed资源对象没有明确归属字段时不推断项目/版本关系。
return resource.ownerId === actor.id || resource.creatorId === actor.id || resource.actorId === actor.id;
}
export function requireCreatorResourceOwnership(
actor: RbacActor,
resource: { readonly ownerId?: string; readonly creatorId?: string; readonly actorId?: string }
): AuthenticatedActor {
if (!creatorOwnsResource(actor, resource)) {
// 直接对象访问必须先授权再执行写入;缺归属、跨 owner、匿名访问都统一 403不做补偿式推断。
throw new RbacForbiddenError("Creator cannot access this resource");
}
return requireAuthenticated(actor);
}
export function canReadReviewFoundationData(actor: RbacActor, category: ReviewFoundationDataCategory): boolean {
if (!isAuthenticated(actor)) return false;
if (!reviewFoundationReadCategories.has(category)) return false;
// S1 只开放审核所需基础读权限;后台管理 CRUD 留给后续明确端点单独授权。
return hasRole(actor, "admin") || hasRole(actor, "operator");
}

View File

@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import {
canReadReviewFoundationData,
creatorOwnsResource,
isAuthenticated,
isKnownRole,
makeAnonymousActor,
requireAuthenticated,
requireCreatorResourceOwnership
} from "./index.js";
describe("RBAC policy boundary", () => {
it("recognizes admin/operator/creator/player/anonymous roles", () => {
expect(isKnownRole("admin")).toBe(true);
expect(isKnownRole("operator")).toBe(true);
expect(isKnownRole("creator")).toBe(true);
expect(isKnownRole("player")).toBe(true);
expect(isKnownRole("anonymous")).toBe(true);
expect(isKnownRole("owner")).toBe(false);
});
it("treats anonymous as unauthenticated until the S6 public feed behavior exists", () => {
const anonymous = makeAnonymousActor();
expect(isAuthenticated(anonymous)).toBe(false);
expect(() => requireAuthenticated(anonymous)).toThrow(/FORBIDDEN/);
});
it("allows creator ownership on own resource-like objects and rejects foreign objects fail-closed", () => {
const creator = { id: "creator-1", roles: ["creator"] as const };
expect(creatorOwnsResource(creator, { ownerId: "creator-1" })).toBe(true);
expect(creatorOwnsResource(creator, { creatorId: "creator-1" })).toBe(true);
expect(creatorOwnsResource(creator, { actorId: "creator-1" })).toBe(true);
expect(creatorOwnsResource(creator, { ownerId: "creator-2" })).toBe(false);
expect(creatorOwnsResource(creator, { projectId: "project-without-owner" })).toBe(false);
expect(creatorOwnsResource(makeAnonymousActor(), { ownerId: "creator-1" })).toBe(false);
});
it("fails direct-object access closed with 403 before mutation", () => {
const creator = { id: "creator-1", roles: ["creator"] as const };
const mutation = { applied: false };
let caught: unknown;
try {
requireCreatorResourceOwnership(creator, { ownerId: "creator-2" });
mutation.applied = true;
} catch (error) {
caught = error;
}
expect(caught).toMatchObject({ code: "FORBIDDEN", statusCode: 403 });
expect(mutation.applied).toBe(false);
});
it("allows only operator/admin to read review-oriented foundation data categories", () => {
expect(canReadReviewFoundationData({ id: "admin-1", roles: ["admin"] }, "project")).toBe(true);
expect(canReadReviewFoundationData({ id: "operator-1", roles: ["operator"] }, "version")).toBe(true);
expect(canReadReviewFoundationData({ id: "operator-1", roles: ["operator"] }, "audit-log")).toBe(true);
expect(canReadReviewFoundationData({ id: "creator-1", roles: ["creator"] }, "project")).toBe(false);
expect(canReadReviewFoundationData({ id: "player-1", roles: ["player"] }, "version")).toBe(false);
expect(canReadReviewFoundationData(makeAnonymousActor(), "audit-log")).toBe(false);
expect(canReadReviewFoundationData({ id: "operator-1", roles: ["operator"] }, "management-crud")).toBe(false);
});
});

View File

@ -0,0 +1,396 @@
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { Prisma, type GameVersion, type LifecycleEvent } from "../../generated/prisma/client.js";
import type { HarnessClient, HarnessGateResult } from "../harness-gate/index.js";
export type ReviewDecisionEvent = "review_approved" | "review_rejected";
export type LifecycleEventDto = {
readonly eventId: string;
readonly event: ReviewDecisionEvent;
readonly from: "pending_review";
readonly to: "publish_ready" | "rejected";
readonly actor: Prisma.InputJsonValue;
readonly requiredRole: "operator";
readonly requiredRecordRefs: Prisma.InputJsonValue;
readonly auditEvent: "lifecycle.review_approved" | "lifecycle.review_rejected";
readonly reasonCode: string;
readonly occurredAt: string;
readonly approval: "operator";
readonly requiredRecords: Prisma.InputJsonValue;
};
export type StateTransitionServiceOptions = {
readonly db: Prisma.TransactionClient;
// 失败审计必须能跨调用方事务回滚持久化,因此由 API composition root 注入非事务客户端。
readonly blockedAuditDb: Pick<Prisma.TransactionClient, "auditLog">;
readonly harnessGate: HarnessClient;
readonly now?: () => Date;
};
export type CreateDraftVersionInput = {
readonly id: string;
readonly projectId: string;
readonly versionNumber: number;
readonly configJson: Prisma.InputJsonValue;
};
export type ApplyReviewDecisionInput = {
readonly event: ReviewDecisionEvent;
readonly gameVersionId: string;
readonly reviewRecordId: string;
readonly lifecycleEventId: string;
readonly actorId: string;
readonly actor: Prisma.InputJsonValue;
readonly reasonCode: string;
};
export type ApplyReviewDecisionResult = {
readonly lifecycleEvent: LifecycleEventDto;
};
export type StateBoundaryViolationReason =
| "GUARDED_PRISMA_WRITE_OUTSIDE_STATE_TRANSITION"
| "STATE_GUARD_SETTER_OUTSIDE_STATE_TRANSITION"
| "WORKER_DB_IMPORT_FORBIDDEN"
| "WORKER_STATE_TRANSITION_IMPORT_FORBIDDEN";
export type StateBoundaryViolation = {
readonly path: string;
readonly reason: StateBoundaryViolationReason;
};
type ScanFile = {
readonly path: string;
readonly content: string;
};
const stateTransitionGuardSql = "SELECT set_config('app.state_transition_guard', 'on', true)";
const stateTransitionModulePrefix = "apps/api/src/modules/state-transition/";
const generatedPrismaPrefix = "apps/api/src/generated/prisma/";
const prismaMigrationPrefix = "apps/api/prisma/migrations/";
const historicalTestFiles = new Set(["apps/api/src/prisma.schema.spec.ts"]);
const ignoredScanSegments = new Set(["node_modules", "dist", ".vite", "coverage", ".next"]);
export class StateTransitionDomainError extends Error {
readonly statusCode = 422;
readonly reasonCode: string;
constructor(reasonCode: string) {
super(`State transition rejected by harness: ${reasonCode}`);
this.name = "StateTransitionDomainError";
this.reasonCode = reasonCode;
}
}
function eventSpec(event: ReviewDecisionEvent) {
if (event === "review_approved") {
return {
auditEvent: "lifecycle.review_approved" as const,
decision: "approved" as const,
status: "approved" as const,
to: "publish_ready" as const,
requiredRecords: ["ReviewRecord.decision=approved", "ReviewRecord.reasonCode"]
};
}
return {
auditEvent: "lifecycle.review_rejected" as const,
decision: "rejected" as const,
status: "rejected" as const,
to: "rejected" as const,
requiredRecords: ["ReviewRecord.decision=rejected", "ReviewRecord.reasonCode"]
};
}
export function projectLifecycleEvent(row: Pick<
LifecycleEvent,
| "eventId"
| "event"
| "from"
| "to"
| "actorJson"
| "requiredRole"
| "requiredRecordRefsJson"
| "auditEvent"
| "reasonCode"
| "occurredAt"
| "approval"
| "requiredRecordsJson"
>): LifecycleEventDto {
return {
eventId: row.eventId,
event: row.event as ReviewDecisionEvent,
from: row.from as "pending_review",
to: row.to as "publish_ready" | "rejected",
actor: row.actorJson as Prisma.InputJsonValue,
requiredRole: row.requiredRole as "operator",
requiredRecordRefs: row.requiredRecordRefsJson as Prisma.InputJsonValue,
auditEvent: row.auditEvent as "lifecycle.review_approved" | "lifecycle.review_rejected",
reasonCode: row.reasonCode,
occurredAt: row.occurredAt.toISOString(),
approval: row.approval as "operator",
requiredRecords: row.requiredRecordsJson as Prisma.InputJsonValue
};
}
export class StateTransitionService {
private readonly db: Prisma.TransactionClient;
private readonly blockedAuditDb: Pick<Prisma.TransactionClient, "auditLog">;
private readonly harnessGate: HarnessClient;
private readonly now: () => Date;
constructor(options: StateTransitionServiceOptions) {
this.db = options.db;
this.blockedAuditDb = options.blockedAuditDb;
this.harnessGate = options.harnessGate;
this.now = options.now ?? (() => new Date());
}
async createDraftVersion(input: CreateDraftVersionInput): Promise<GameVersion> {
return this.withStateTransitionGuard(() =>
this.db.gameVersion.create({
data: {
id: input.id,
projectId: input.projectId,
versionNumber: input.versionNumber,
status: "draft",
configJson: input.configJson
}
})
);
}
async applyReviewDecision(input: ApplyReviewDecisionInput): Promise<ApplyReviewDecisionResult> {
const spec = eventSpec(input.event);
const occurredAt = this.now();
const lifecycleEvent: LifecycleEventDto = {
eventId: input.lifecycleEventId,
event: input.event,
from: "pending_review",
to: spec.to,
actor: input.actor,
requiredRole: "operator",
requiredRecordRefs: [input.reviewRecordId],
auditEvent: spec.auditEvent,
reasonCode: input.reasonCode,
occurredAt: occurredAt.toISOString(),
approval: "operator",
requiredRecords: spec.requiredRecords
};
const gate = await this.harnessGate.validateTransition(input.event, lifecycleEvent);
if (!gate.ok) {
await this.writeBlockedAudit(input, spec.auditEvent, gate);
throw new StateTransitionDomainError(gate.reasonCode ?? "HARNESS_INVALID_OUTPUT");
}
const contractGate = await this.harnessGate.validateContract("LifecycleEvent", lifecycleEvent);
if (!contractGate.ok) {
await this.writeBlockedAudit(input, spec.auditEvent, contractGate);
throw new StateTransitionDomainError(contractGate.reasonCode ?? "HARNESS_INVALID_OUTPUT");
}
await this.withStateTransitionGuard(async () => {
await this.db.reviewRecord.create({
data: {
id: input.reviewRecordId,
gameVersionId: input.gameVersionId,
status: spec.status,
decision: spec.decision,
reasonCode: input.reasonCode,
decidedById: input.actorId,
decidedAt: occurredAt
}
});
await this.db.lifecycleEvent.create({
data: {
eventId: input.lifecycleEventId,
gameVersionId: input.gameVersionId,
event: input.event,
from: lifecycleEvent.from,
to: lifecycleEvent.to,
actorJson: lifecycleEvent.actor,
requiredRole: lifecycleEvent.requiredRole,
requiredRecordRefsJson: lifecycleEvent.requiredRecordRefs,
auditEvent: lifecycleEvent.auditEvent,
reasonCode: lifecycleEvent.reasonCode,
occurredAt,
approval: lifecycleEvent.approval,
requiredRecordsJson: lifecycleEvent.requiredRecords
}
});
});
await this.db.auditLog.create({
data: {
id: `${input.lifecycleEventId}-audit`,
actorId: input.actorId,
action: spec.auditEvent,
targetType: "GameVersion",
targetId: input.gameVersionId,
eventJson: {
lifecycleEventId: input.lifecycleEventId,
reviewRecordId: input.reviewRecordId,
harness: gate
}
}
});
return { lifecycleEvent };
}
private async withStateTransitionGuard<T>(callback: () => Promise<T>): Promise<T> {
await this.db.$executeRawUnsafe(stateTransitionGuardSql);
try {
const result = await callback();
await this.db.$executeRawUnsafe("SELECT set_config('app.state_transition_guard', '', true)");
return result;
} catch (error) {
try {
await this.db.$executeRawUnsafe("SELECT set_config('app.state_transition_guard', '', true)");
} catch {
// 保留原始 DB/业务错误优先级;事务结束时 transaction-local guard 会释放。
}
throw error;
}
}
private async writeBlockedAudit(
input: ApplyReviewDecisionInput,
action: string,
gate: HarnessGateResult
): Promise<void> {
await this.blockedAuditDb.auditLog.create({
data: {
id: `${input.lifecycleEventId}-blocked-audit`,
actorId: input.actorId,
action: `${action}.blocked`,
targetType: "GameVersion",
targetId: input.gameVersionId,
eventJson: {
lifecycleEventId: input.lifecycleEventId,
reviewRecordId: input.reviewRecordId,
reasonCode: gate.reasonCode
}
}
});
}
}
async function collectFiles(root: string, relativeDir: string): Promise<ScanFile[]> {
const absoluteDir = path.join(root, relativeDir);
const entries = await readdir(absoluteDir, { withFileTypes: true }).catch(() => []);
const files: ScanFile[] = [];
for (const entry of entries) {
if (entry.isDirectory() && ignoredScanSegments.has(entry.name)) continue;
const relativePath = path.join(relativeDir, entry.name);
const absolutePath = path.join(root, relativePath);
if (entry.isDirectory()) files.push(...(await collectFiles(root, relativePath)));
if (entry.isFile() && /\.(?:ts|tsx|js|mjs|cjs|json|sql)$/.test(entry.name)) {
files.push({
path: relativePath,
content: await readFile(absolutePath, "utf8")
});
}
}
return files;
}
function isAllowedStateTransitionFile(filePath: string): boolean {
return filePath.startsWith(stateTransitionModulePrefix);
}
function isHistoricalTestFile(filePath: string): boolean {
return historicalTestFiles.has(filePath);
}
function isGeneratedPrismaFile(filePath: string): boolean {
return filePath.startsWith(generatedPrismaPrefix);
}
function isIgnoredScanPath(filePath: string): boolean {
return filePath.split("/").some((segment) => ignoredScanSegments.has(segment));
}
function hasGuardedPrismaWrite(content: string): boolean {
const guardedModels = ["gameVersion", "reviewRecord", "lifecycleEvent"];
const hasPrismaWrite = guardedModels.some((model) => {
const createOrUpdate = new RegExp(`\\.${model}\\.(?:create|update|upsert|createMany|updateMany|delete|deleteMany)\\s*\\(`);
return createOrUpdate.test(content);
});
const hasSqlWrite = /\b(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+"?(?:GameVersion|ReviewRecord|LifecycleEvent)"?/i.test(content);
return hasPrismaWrite || hasSqlWrite;
}
function hasRuntimeGuardSetter(content: string): boolean {
return /app\.state_transition_guard|set_config\s*\(|SET\s+LOCAL/i.test(content);
}
function stripSqlComments(content: string): string {
return content
.replace(/\/\*[\s\S]*?\*\//g, "")
.split("\n")
.map((line) => line.replace(/--.*$/, ""))
.join("\n");
}
function hasForbiddenMigrationGuardSetter(content: string): boolean {
const sql = stripSqlComments(content);
return /set_config\s*\(|SET\s+LOCAL/i.test(sql);
}
export function findStateBoundaryViolations(files: readonly ScanFile[]): StateBoundaryViolation[] {
const violations: StateBoundaryViolation[] = [];
for (const file of files) {
const normalizedPath = file.path.split(path.sep).join("/");
if (isIgnoredScanPath(normalizedPath)) continue;
if (isHistoricalTestFile(normalizedPath)) continue;
if (isGeneratedPrismaFile(normalizedPath)) continue;
if (normalizedPath.startsWith("apps/worker/")) {
if (/@prisma\/client|generated\/prisma|PrismaClient/.test(file.content)) {
violations.push({ path: normalizedPath, reason: "WORKER_DB_IMPORT_FORBIDDEN" });
}
if (/state-transition|StateTransitionService|app\.state_transition_guard|set_config\s*\(|SET\s+LOCAL/i.test(file.content)) {
violations.push({ path: normalizedPath, reason: "WORKER_STATE_TRANSITION_IMPORT_FORBIDDEN" });
}
continue;
}
if (isAllowedStateTransitionFile(normalizedPath)) continue;
if (normalizedPath.startsWith(prismaMigrationPrefix)) {
if (hasForbiddenMigrationGuardSetter(file.content)) {
violations.push({ path: normalizedPath, reason: "STATE_GUARD_SETTER_OUTSIDE_STATE_TRANSITION" });
}
if (hasGuardedPrismaWrite(file.content)) {
violations.push({ path: normalizedPath, reason: "GUARDED_PRISMA_WRITE_OUTSIDE_STATE_TRANSITION" });
}
continue;
}
if (hasGuardedPrismaWrite(file.content)) {
violations.push({ path: normalizedPath, reason: "GUARDED_PRISMA_WRITE_OUTSIDE_STATE_TRANSITION" });
}
if (hasRuntimeGuardSetter(file.content)) {
violations.push({ path: normalizedPath, reason: "STATE_GUARD_SETTER_OUTSIDE_STATE_TRANSITION" });
}
}
return violations;
}
export async function scanStateTransitionBoundary(repoRoot: string): Promise<StateBoundaryViolation[]> {
const files = [
...(await collectFiles(repoRoot, "apps/api/src")),
...(await collectFiles(repoRoot, "apps/api/prisma/migrations")),
...(await collectFiles(repoRoot, "apps/worker"))
];
return findStateBoundaryViolations(files);
}

View File

@ -0,0 +1,456 @@
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { PrismaPg } from "@prisma/adapter-pg";
import { Prisma, PrismaClient } from "../../generated/prisma/client.js";
import { HarnessGateService } from "../harness-gate/index.js";
import {
StateTransitionDomainError,
StateTransitionService,
findStateBoundaryViolations,
projectLifecycleEvent,
scanStateTransitionBoundary
} from "./index.js";
import { afterAll, describe, expect, it } from "vitest";
const execFileAsync = promisify(execFile);
const databaseUrl = process.env.DATABASE_URL ?? "postgresql://huijing:huijing@localhost:5432/huijing_dev?schema=public";
const adapter = new PrismaPg({ connectionString: databaseUrl });
const prisma = new PrismaClient({ adapter });
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../../..");
const harnessCli = path.join(repoRoot, "harness", "scripts", "validate-harness.mjs");
const rollbackToken = Symbol("state transition rollback");
const runId = `task5-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const occurredAt = new Date("2026-06-01T06:00:00.000Z");
type DbClient = Prisma.TransactionClient;
let savepointSequence = 0;
class OrdinaryVersionService {
constructor(private readonly db: DbClient) {}
async forceStatus(versionId: string) {
return this.db.gameVersion.update({
where: { id: versionId },
data: { status: "active" }
});
}
}
async function withRollback(callback: (tx: DbClient) => Promise<void>): Promise<void> {
try {
await prisma.$transaction(async (tx) => {
await callback(tx);
throw rollbackToken;
});
} catch (error) {
if (error !== rollbackToken) throw error;
}
}
async function expectPrismaRejectedInSavepoint(
db: DbClient,
action: () => Promise<unknown>,
pattern: RegExp
): Promise<void> {
const savepointName = `task5_sp_${++savepointSequence}`;
await db.$executeRawUnsafe(`SAVEPOINT ${savepointName}`);
let caught: unknown;
try {
await action();
} catch (error) {
caught = error;
}
await db.$executeRawUnsafe(`ROLLBACK TO SAVEPOINT ${savepointName}`);
await db.$executeRawUnsafe(`RELEASE SAVEPOINT ${savepointName}`);
if (caught === undefined) throw new Error(`Expected Prisma action to reject with ${pattern}`);
expect(() => {
throw caught;
}).toThrow(pattern);
}
async function withTempJson<T>(value: unknown, callback: (payloadPath: string) => Promise<T>): Promise<T> {
const dir = await mkdtemp(path.join(os.tmpdir(), "huijing-state-transition-"));
const payloadPath = path.join(dir, "payload.json");
try {
await writeFile(payloadPath, JSON.stringify(value), "utf8");
return await callback(payloadPath);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
async function expectHarnessContract(contract: string, value: unknown): Promise<void> {
await withTempJson(value, async (payloadPath) => {
const result = await execFileAsync("node", [harnessCli, "--contract", contract, "--input", payloadPath], {
cwd: repoRoot
});
expect(JSON.parse(result.stdout)).toEqual({ ok: true, reasonCode: null });
});
}
async function expectHarnessTransition(transition: string, value: unknown): Promise<void> {
await withTempJson(value, async (payloadPath) => {
const result = await execFileAsync("node", [harnessCli, "--transition", transition, "--input", payloadPath], {
cwd: repoRoot
});
expect(JSON.parse(result.stdout)).toEqual({ ok: true, reasonCode: null });
});
}
async function seedProjectSet(db: DbClient, suffix: string) {
const creator = await db.user.create({
data: {
id: `${runId}-${suffix}-creator`,
email: `${runId}-${suffix}-creator@example.test`,
displayName: `${suffix} creator`
}
});
const operator = await db.user.create({
data: {
id: `${runId}-${suffix}-operator`,
email: `${runId}-${suffix}-operator@example.test`,
displayName: `${suffix} operator`
}
});
const project = await db.gameProject.create({
data: {
id: `${runId}-${suffix}-project`,
ownerId: creator.id,
slug: `${runId}-${suffix}-project`,
title: `${suffix} project`
}
});
const service = new StateTransitionService({ db, blockedAuditDb: db, harnessGate: new HarnessGateService() });
const version = await service.createDraftVersion({
id: `${runId}-${suffix}-version`,
projectId: project.id,
versionNumber: 1,
configJson: { seed: suffix }
});
return { creator, operator, project, version };
}
describe("StateTransitionService", () => {
afterAll(async () => {
await prisma.$disconnect();
});
it("harness PASS allows review_approved and writes review, lifecycle, and audit facts", async () => {
await withRollback(async (tx) => {
const seeded = await seedProjectSet(tx, "approve-pass");
const service = new StateTransitionService({
db: tx,
blockedAuditDb: prisma,
harnessGate: new HarnessGateService(),
now: () => occurredAt
});
const result = await service.applyReviewDecision({
event: "review_approved",
gameVersionId: seeded.version.id,
reviewRecordId: `${runId}-approve-pass-review`,
lifecycleEventId: `${runId}-approve-pass-lifecycle`,
actorId: seeded.operator.id,
actor: { role: "operator", operatorId: seeded.operator.id },
reasonCode: "approved_for_publish"
});
expect(result.lifecycleEvent).toEqual({
eventId: `${runId}-approve-pass-lifecycle`,
event: "review_approved",
from: "pending_review",
to: "publish_ready",
actor: { role: "operator", operatorId: seeded.operator.id },
requiredRole: "operator",
requiredRecordRefs: [`${runId}-approve-pass-review`],
auditEvent: "lifecycle.review_approved",
reasonCode: "approved_for_publish",
occurredAt: occurredAt.toISOString(),
approval: "operator",
requiredRecords: ["ReviewRecord.decision=approved", "ReviewRecord.reasonCode"]
});
const review = await tx.reviewRecord.findUniqueOrThrow({ where: { id: `${runId}-approve-pass-review` } });
expect(review.status).toBe("approved");
expect(review.decision).toBe("approved");
expect(review.reasonCode).toBe("approved_for_publish");
const lifecycle = await tx.lifecycleEvent.findUniqueOrThrow({
where: { eventId: `${runId}-approve-pass-lifecycle` }
});
const projected = projectLifecycleEvent(lifecycle);
await expectHarnessContract("LifecycleEvent", projected);
await expectHarnessTransition("review_approved", projected);
const audit = await tx.auditLog.findFirstOrThrow({
where: { action: "lifecycle.review_approved", targetId: seeded.version.id }
});
expect(audit.eventJson).toMatchObject({
lifecycleEventId: `${runId}-approve-pass-lifecycle`,
reviewRecordId: `${runId}-approve-pass-review`,
harness: { ok: true, reasonCode: null }
});
});
});
it("harness FAIL bubbles across the transaction boundary and persists the blocked audit fact", async () => {
const seeded = await prisma.$transaction((tx) => seedProjectSet(tx, "approve-fail"));
await expect(
prisma.$transaction(async (tx) => {
const service = new StateTransitionService({
db: tx,
blockedAuditDb: prisma,
harnessGate: {
validateContract: async () => ({ ok: true, reasonCode: null }),
validateTransition: async () => ({ ok: false, reasonCode: "TRANSITION_REQUIRED_RECORD_MISMATCH" })
},
now: () => occurredAt
});
await service.applyReviewDecision({
event: "review_approved",
gameVersionId: seeded.version.id,
reviewRecordId: `${runId}-approve-fail-review`,
lifecycleEventId: `${runId}-approve-fail-lifecycle`,
actorId: seeded.operator.id,
actor: { role: "operator", operatorId: seeded.operator.id },
reasonCode: "approved_for_publish"
});
})
).rejects.toMatchObject<StateTransitionDomainError>({
statusCode: 422,
reasonCode: "TRANSITION_REQUIRED_RECORD_MISMATCH"
});
await expect(prisma.reviewRecord.findUnique({ where: { id: `${runId}-approve-fail-review` } })).resolves.toBeNull();
await expect(
prisma.lifecycleEvent.findUnique({ where: { eventId: `${runId}-approve-fail-lifecycle` } })
).resolves.toBeNull();
const audit = await prisma.auditLog.findUniqueOrThrow({
where: { id: `${runId}-approve-fail-lifecycle-blocked-audit` }
});
expect(audit).toMatchObject({
action: "lifecycle.review_approved.blocked",
targetId: seeded.version.id
});
expect(audit.eventJson).toMatchObject({
reasonCode: "TRANSITION_REQUIRED_RECORD_MISMATCH",
lifecycleEventId: `${runId}-approve-fail-lifecycle`,
reviewRecordId: `${runId}-approve-fail-review`
});
});
it("ordinary services and direct Prisma cannot bypass guarded status or lifecycle writes", async () => {
await withRollback(async (tx) => {
const seeded = await seedProjectSet(tx, "bypass");
const ordinary = new OrdinaryVersionService(tx);
await expectPrismaRejectedInSavepoint(tx, () => ordinary.forceStatus(seeded.version.id), /state transition guard is required/);
await expectPrismaRejectedInSavepoint(
tx,
() =>
tx.gameVersion.create({
data: {
id: `${runId}-bypass-direct-version`,
projectId: seeded.project.id,
versionNumber: 2,
status: "draft",
configJson: {}
}
}),
/state transition guard is required/
);
await expectPrismaRejectedInSavepoint(
tx,
() =>
tx.reviewRecord.create({
data: {
id: `${runId}-bypass-direct-review`,
gameVersionId: seeded.version.id,
status: "pending_review"
}
}),
/state transition guard is required/
);
await expectPrismaRejectedInSavepoint(
tx,
() =>
tx.lifecycleEvent.create({
data: {
eventId: `${runId}-bypass-direct-lifecycle`,
gameVersionId: seeded.version.id,
event: "review_rejected",
from: "pending_review",
to: "rejected",
actorJson: { role: "operator", operatorId: seeded.operator.id },
requiredRole: "operator",
requiredRecordRefsJson: [`${runId}-bypass-direct-review`],
auditEvent: "lifecycle.review_rejected",
reasonCode: "needs_design_changes",
occurredAt,
approval: "operator",
requiredRecordsJson: ["ReviewRecord.decision=rejected", "ReviewRecord.reasonCode"]
}
}),
/state transition guard is required/
);
});
});
it("DB review/lifecycle facts project to S0 review_rejected transition input and LifecycleEvent contract", async () => {
await withRollback(async (tx) => {
const seeded = await seedProjectSet(tx, "reject-pass");
const service = new StateTransitionService({
db: tx,
blockedAuditDb: prisma,
harnessGate: new HarnessGateService(),
now: () => occurredAt
});
await service.applyReviewDecision({
event: "review_rejected",
gameVersionId: seeded.version.id,
reviewRecordId: `${runId}-reject-pass-review`,
lifecycleEventId: `${runId}-reject-pass-lifecycle`,
actorId: seeded.operator.id,
actor: { role: "operator", operatorId: seeded.operator.id },
reasonCode: "needs_design_changes"
});
const review = await tx.reviewRecord.findUniqueOrThrow({ where: { id: `${runId}-reject-pass-review` } });
expect(review.status).toBe("rejected");
expect(review.decision).toBe("rejected");
const lifecycle = await tx.lifecycleEvent.findUniqueOrThrow({
where: { eventId: `${runId}-reject-pass-lifecycle` }
});
const projected = projectLifecycleEvent(lifecycle);
await expectHarnessContract("LifecycleEvent", projected);
await expectHarnessTransition("review_rejected", projected);
});
});
it("static scan enforces runtime state-transition and worker DB boundaries without scanning historical Task 4 tests", async () => {
await expect(scanStateTransitionBoundary(repoRoot)).resolves.toEqual([]);
expect(
findStateBoundaryViolations([
{
path: "apps/api/src/modules/projects/project.service.ts",
content: "await prisma.gameVersion.update({ where: { id }, data: { status: 'active' } });"
},
{
path: "apps/api/src/modules/reviews/review.service.ts",
content: "await db.$executeRawUnsafe(\"SELECT set_config('app.state_transition_guard', 'on', true)\");"
},
{
path: "apps/worker/src/index.ts",
content: "import { PrismaClient } from '@prisma/client';"
},
{
path: "apps/worker/src/review-worker.ts",
content: "import { StateTransitionService } from '../../api/src/modules/state-transition/index.js';"
},
{
path: "apps/worker/package.json",
content: "{\"dependencies\":{\"@prisma/client\":\"7.8.0\"}}"
}
])
).toEqual([
{
path: "apps/api/src/modules/projects/project.service.ts",
reason: "GUARDED_PRISMA_WRITE_OUTSIDE_STATE_TRANSITION"
},
{
path: "apps/api/src/modules/reviews/review.service.ts",
reason: "STATE_GUARD_SETTER_OUTSIDE_STATE_TRANSITION"
},
{
path: "apps/worker/src/index.ts",
reason: "WORKER_DB_IMPORT_FORBIDDEN"
},
{
path: "apps/worker/src/review-worker.ts",
reason: "WORKER_STATE_TRANSITION_IMPORT_FORBIDDEN"
},
{
path: "apps/worker/package.json",
reason: "WORKER_DB_IMPORT_FORBIDDEN"
}
]);
expect(
findStateBoundaryViolations([
{
path: "apps/api/src/modules/state-transition/index.ts",
content:
"await tx.$executeRawUnsafe(\"SELECT set_config('app.state_transition_guard', 'on', true)\"); await tx.lifecycleEvent.create({ data });"
},
{
path: "apps/api/prisma/migrations/20260601040253_s1_app_foundation/migration.sql",
content: await readFile(path.join(repoRoot, "apps/api/prisma/migrations/20260601040253_s1_app_foundation/migration.sql"), "utf8")
}
])
).toEqual([]);
expect(
findStateBoundaryViolations([
{
path: "apps/api/prisma/migrations/20260601999999_bad/migration.sql",
content: "SELECT set_config('app.state_transition_guard', 'on', true);"
},
{
path: "apps/api/prisma/migrations/20260601999999_bad/migration.sql",
content: "UPDATE \"GameVersion\" SET \"status\" = 'active';"
},
{
path: "apps/api/prisma/migrations/20260601999999_bad/migration.sql",
content: "INSERT INTO \"LifecycleEvent\" (\"eventId\") VALUES ('x');"
}
])
).toEqual([
{
path: "apps/api/prisma/migrations/20260601999999_bad/migration.sql",
reason: "STATE_GUARD_SETTER_OUTSIDE_STATE_TRANSITION"
},
{
path: "apps/api/prisma/migrations/20260601999999_bad/migration.sql",
reason: "GUARDED_PRISMA_WRITE_OUTSIDE_STATE_TRANSITION"
},
{
path: "apps/api/prisma/migrations/20260601999999_bad/migration.sql",
reason: "GUARDED_PRISMA_WRITE_OUTSIDE_STATE_TRANSITION"
}
]);
expect(
findStateBoundaryViolations([
{
path: "apps/api/src/prisma.schema.spec.ts",
content: "await db.$executeRawUnsafe(\"SELECT set_config('app.state_transition_guard', 'on', true)\");"
},
{
path: "apps/worker/dist/index.js",
content: "const { PrismaClient } = require('@prisma/client');"
}
])
).toEqual([]);
});
});

View File

@ -0,0 +1,190 @@
import { createHash, randomUUID } from "node:crypto";
import { lstat, mkdir, realpath, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";
export type PutObjectInput = {
readonly ownerId: string;
readonly projectId: string;
readonly objectName: string;
readonly mimeType: string;
readonly bytes: Buffer | Uint8Array;
readonly expectedSha256: string;
};
export type PutObjectResult = {
readonly storageKey: string;
readonly absolutePath: string;
readonly byteSize: number;
readonly sha256: string;
readonly mimeType: string;
};
export type PlanObjectInput = Omit<PutObjectInput, "bytes"> & {
readonly byteSize: number;
};
export type PlanObjectResult = Omit<PutObjectResult, "absolutePath">;
export type LocalStorageAdapterOptions = {
readonly root: string;
readonly allowedMimeTypes: readonly string[];
readonly maxBytes: number;
};
export type StorageBoundaryErrorCode =
| "INVALID_STORAGE_KEY"
| "STORAGE_ROOT_ESCAPE"
| "MIME_NOT_ALLOWED"
| "FILE_TOO_LARGE"
| "CHECKSUM_MISMATCH";
export class StorageBoundaryError extends Error {
readonly code: StorageBoundaryErrorCode;
constructor(code: StorageBoundaryErrorCode, message = code) {
super(message);
this.name = "StorageBoundaryError";
this.code = code;
}
}
function sha256(bytes: Buffer | Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
function isSafeSegment(segment: string): boolean {
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(segment) && segment !== "." && segment !== "..";
}
function rejectUnsafeValue(value: string): void {
if (value.includes("\u0000")) throw new StorageBoundaryError("INVALID_STORAGE_KEY");
if (/%(?:2f|5c|00|2e)/i.test(value)) throw new StorageBoundaryError("INVALID_STORAGE_KEY");
}
function normalizeObjectName(objectName: string): string {
rejectUnsafeValue(objectName);
if (path.isAbsolute(objectName)) throw new StorageBoundaryError("INVALID_STORAGE_KEY");
const normalized = path.posix.normalize(objectName);
if (normalized.startsWith("../") || normalized === ".." || normalized.includes("/../")) {
throw new StorageBoundaryError("INVALID_STORAGE_KEY");
}
const segments = normalized.split("/");
if (segments.some((segment) => !isSafeSegment(segment))) throw new StorageBoundaryError("INVALID_STORAGE_KEY");
return segments.join("/");
}
async function assertInsideRoot(rootReal: string, candidate: string): Promise<void> {
const parentReal = await realpath(path.dirname(candidate));
const relative = path.relative(rootReal, parentReal);
if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) return;
throw new StorageBoundaryError("STORAGE_ROOT_ESCAPE");
}
function isNotFound(error: unknown): boolean {
return error instanceof Error && "code" in error && error.code === "ENOENT";
}
function assertRealPathInsideRoot(rootReal: string, candidateReal: string): void {
const relative = path.relative(rootReal, candidateReal);
if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) return;
throw new StorageBoundaryError("STORAGE_ROOT_ESCAPE");
}
async function assertSafeExistingDirectory(rootReal: string, candidate: string): Promise<void> {
const stat = await lstat(candidate);
if (stat.isSymbolicLink()) throw new StorageBoundaryError("STORAGE_ROOT_ESCAPE");
if (!stat.isDirectory()) throw new StorageBoundaryError("INVALID_STORAGE_KEY");
assertRealPathInsideRoot(rootReal, await realpath(candidate));
}
async function ensureSafeDirectory(root: string, rootReal: string, relativeSegments: readonly string[]): Promise<void> {
let current = root;
for (const segment of relativeSegments) {
current = path.join(current, segment);
try {
await assertSafeExistingDirectory(rootReal, current);
} catch (error) {
if (!isNotFound(error)) throw error;
// 逐级创建目录前已确认父级不是 symlink 且 realpath 仍在 root 内,避免 recursive mkdir 穿过 root 内 symlink 污染外部目录。
await mkdir(current);
await assertSafeExistingDirectory(rootReal, current);
}
}
}
export class LocalStorageAdapter {
private readonly root: string;
private readonly allowedMimeTypes: Set<string>;
private readonly maxBytes: number;
constructor(options: LocalStorageAdapterOptions) {
this.root = path.resolve(options.root);
this.allowedMimeTypes = new Set(options.allowedMimeTypes);
this.maxBytes = options.maxBytes;
}
planObject(input: PlanObjectInput): PlanObjectResult {
if (!this.allowedMimeTypes.has(input.mimeType)) throw new StorageBoundaryError("MIME_NOT_ALLOWED");
if (input.byteSize > this.maxBytes) throw new StorageBoundaryError("FILE_TOO_LARGE");
if (!Number.isInteger(input.byteSize) || input.byteSize <= 0) throw new StorageBoundaryError("FILE_TOO_LARGE");
rejectUnsafeValue(input.ownerId);
rejectUnsafeValue(input.projectId);
if (!isSafeSegment(input.ownerId) || !isSafeSegment(input.projectId)) throw new StorageBoundaryError("INVALID_STORAGE_KEY");
const expected = input.expectedSha256.toLowerCase();
if (!/^[a-f0-9]{64}$/.test(expected)) throw new StorageBoundaryError("CHECKSUM_MISMATCH");
return {
// presign 和实际 putObject 共用 adapter-owned key 构造,避免 API 层复制 path/MIME/size 规则后漂移。
storageKey: `owners/${input.ownerId}/projects/${input.projectId}/${normalizeObjectName(input.objectName)}`,
byteSize: input.byteSize,
sha256: expected,
mimeType: input.mimeType
};
}
async putObject(input: PutObjectInput): Promise<PutObjectResult> {
const planned = this.planObject({
ownerId: input.ownerId,
projectId: input.projectId,
objectName: input.objectName,
mimeType: input.mimeType,
byteSize: input.bytes.byteLength,
expectedSha256: input.expectedSha256
});
const expected = input.expectedSha256.toLowerCase();
const actual = sha256(input.bytes);
if (actual !== expected) throw new StorageBoundaryError("CHECKSUM_MISMATCH");
await mkdir(this.root, { recursive: true });
const rootReal = await realpath(this.root);
const finalPath = path.join(this.root, planned.storageKey);
const finalDirSegments = planned.storageKey.split("/").slice(0, -1);
const tempPath = path.join(this.root, `.upload-${randomUUID()}.tmp`);
try {
await assertSafeExistingDirectory(rootReal, this.root);
await ensureSafeDirectory(this.root, rootReal, finalDirSegments);
await assertInsideRoot(rootReal, finalPath);
await writeFile(tempPath, input.bytes);
await assertInsideRoot(rootReal, tempPath);
await rename(tempPath, finalPath);
await assertInsideRoot(rootReal, finalPath);
return {
storageKey: planned.storageKey,
absolutePath: finalPath,
byteSize: input.bytes.byteLength,
sha256: actual,
mimeType: input.mimeType
};
} catch (error) {
await rm(tempPath, { force: true }).catch(() => undefined);
if (error instanceof StorageBoundaryError) throw error;
throw error;
}
}
}

View File

@ -0,0 +1,132 @@
import { mkdir, readdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { createHash } from "node:crypto";
import { LocalStorageAdapter, StorageBoundaryError } from "./index.js";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
let root: string;
let outside: string;
function sha256(buffer: Buffer): string {
return createHash("sha256").update(buffer).digest("hex");
}
function adapter(options: { maxBytes?: number; mimeTypes?: string[] } = {}) {
return new LocalStorageAdapter({
root,
allowedMimeTypes: options.mimeTypes ?? ["text/plain", "image/png"],
maxBytes: options.maxBytes ?? 1024
});
}
beforeEach(async () => {
root = path.join(os.tmpdir(), `huijing-storage-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await mkdir(root, { recursive: true });
outside = path.join(os.tmpdir(), `huijing-storage-outside-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await mkdir(outside, { recursive: true });
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
await rm(outside, { recursive: true, force: true });
});
describe("LocalStorageAdapter", () => {
it("builds owner/project scoped object keys and stores uploads under S1_STORAGE_ROOT", async () => {
const data = Buffer.from("hello", "utf8");
const stored = await adapter().putObject({
ownerId: "creator-1",
projectId: "project-1",
objectName: "cover.txt",
mimeType: "text/plain",
bytes: data,
expectedSha256: sha256(data)
});
expect(stored.storageKey).toBe("owners/creator-1/projects/project-1/cover.txt");
expect(stored.absolutePath.startsWith(root)).toBe(true);
await expect(readFile(stored.absolutePath, "utf8")).resolves.toBe("hello");
});
it.each([
["path traversal", "../escape.txt"],
["absolute paths", "/tmp/escape.txt"],
["encoded separators", "folder%2Fescape.txt"],
["encoded traversal", "%2e%2e/escape.txt"],
["NUL bytes", "evil\u0000name.txt"]
])("rejects %s", async (_label, objectName) => {
const data = Buffer.from("blocked", "utf8");
await expect(
adapter().putObject({
ownerId: "creator-1",
projectId: "project-1",
objectName,
mimeType: "text/plain",
bytes: data,
expectedSha256: sha256(data)
})
).rejects.toMatchObject<StorageBoundaryError>({ code: "INVALID_STORAGE_KEY" });
});
it("rejects symlink and realpath escapes outside S1_STORAGE_ROOT", async () => {
await mkdir(path.join(outside, "target"), { recursive: true });
await symlink(path.join(outside, "target"), path.join(root, "owners"));
const data = Buffer.from("escape", "utf8");
await expect(
adapter().putObject({
ownerId: "creator-1",
projectId: "project-1",
objectName: "escape.txt",
mimeType: "text/plain",
bytes: data,
expectedSha256: sha256(data)
})
).rejects.toMatchObject<StorageBoundaryError>({ code: "STORAGE_ROOT_ESCAPE" });
await expect(readdir(path.join(outside, "target"))).resolves.toEqual([]);
});
it("enforces MIME allowlist, max size, checksum validation, and temporary cleanup", async () => {
const data = Buffer.from("hello", "utf8");
await expect(
adapter({ mimeTypes: ["image/png"] }).putObject({
ownerId: "creator-1",
projectId: "project-1",
objectName: "file.txt",
mimeType: "text/plain",
bytes: data,
expectedSha256: sha256(data)
})
).rejects.toMatchObject<StorageBoundaryError>({ code: "MIME_NOT_ALLOWED" });
await expect(
adapter({ maxBytes: 2 }).putObject({
ownerId: "creator-1",
projectId: "project-1",
objectName: "large.txt",
mimeType: "text/plain",
bytes: data,
expectedSha256: sha256(data)
})
).rejects.toMatchObject<StorageBoundaryError>({ code: "FILE_TOO_LARGE" });
await expect(
adapter().putObject({
ownerId: "creator-1",
projectId: "project-1",
objectName: "bad-checksum.txt",
mimeType: "text/plain",
bytes: data,
expectedSha256: "0".repeat(64)
})
).rejects.toMatchObject<StorageBoundaryError>({ code: "CHECKSUM_MISMATCH" });
await expect(readFile(path.join(root, "owners/creator-1/projects/project-1/bad-checksum.txt"))).rejects.toThrow();
await writeFile(path.join(root, "leftover.tmp"), "manual");
await expect(readFile(path.join(root, "leftover.tmp"), "utf8")).resolves.toBe("manual");
});
});

View File

@ -0,0 +1,800 @@
import { execFile } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { PrismaPg } from "@prisma/adapter-pg";
import { Prisma, PrismaClient } from "./generated/prisma/client.js";
import { afterAll, describe, expect, it } from "vitest";
const execFileAsync = promisify(execFile);
const databaseUrl = process.env.DATABASE_URL ?? "postgresql://huijing:huijing@localhost:5432/huijing_dev?schema=public";
const adapter = new PrismaPg({ connectionString: databaseUrl });
const prisma = new PrismaClient({ adapter });
const runId = `task4-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const rollbackToken = Symbol("rollback test transaction");
type DbClient = Prisma.TransactionClient;
let savepointSequence = 0;
const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const harnessCli = path.join(workspaceRoot, "harness/scripts/validate-harness.mjs");
// S0 harness 的 AgentTask 校验依赖 fixture context这里使用 fixture 兼容 ID 单独验证投影。
const s0SessionSeed = {
id: "session-main-creation-001",
creatorId: "creator-demo-001",
projectId: "project-s0-simulation-001",
versionId: "version-s0-simulation-001",
status: "routing_internal_tasks",
contextSummary: "Creator asked for a lightweight simulation game with short sessions and mobile-first controls.",
createdAt: new Date("2026-06-01T01:55:00.000Z"),
updatedAt: new Date("2026-06-01T02:00:00.000Z")
};
const s0TaskSeed = {
id: "agent-task-requirement-clarifier-001",
sessionId: "session-main-creation-001",
taskType: "requirement_clarifier",
subagentId: "subagent-requirement-clarifier-001",
inputRef: "prompt-record-s0-001",
outputRef: "requirement-brief-simulation-001",
status: "succeeded",
timeoutAt: new Date("2026-06-01T02:10:00.000Z"),
errorCode: null,
auditLogId: "audit-agent-task-requirement-clarifier-001",
createdAt: new Date("2026-06-01T01:56:00.000Z"),
updatedAt: new Date("2026-06-01T01:58:00.000Z")
};
function iso(value: Date): string {
return value.toISOString();
}
// Prisma row 不能直接作为 S0 DTO 暴露;投影函数只输出 S0 schema 允许字段。
function projectSession(row: {
id: string;
creatorId: string;
projectId: string;
versionId: string;
status: string;
contextSummary: string;
createdAt: Date;
updatedAt: Date;
}) {
return {
id: row.id,
creatorId: row.creatorId,
projectId: row.projectId,
versionId: row.versionId,
status: row.status,
contextSummary: row.contextSummary,
createdAt: iso(row.createdAt),
updatedAt: iso(row.updatedAt)
};
}
function projectTask(row: {
id: string;
sessionId: string;
taskType: string;
subagentId: string;
inputRef: string;
outputRef: string | null;
status: string;
timeoutAt: Date;
errorCode: string | null;
auditLogId: string;
createdAt: Date;
updatedAt: Date;
}) {
return {
id: row.id,
sessionId: row.sessionId,
taskType: row.taskType,
subagentId: row.subagentId,
inputRef: row.inputRef,
outputRef: row.outputRef,
status: row.status,
timeoutAt: iso(row.timeoutAt),
errorCode: row.errorCode,
auditLogId: row.auditLogId,
createdAt: iso(row.createdAt),
updatedAt: iso(row.updatedAt)
};
}
function projectLifecycle(row: {
eventId: string;
event: string;
from: string;
to: string;
actorJson: Prisma.JsonValue;
requiredRole: string;
requiredRecordRefsJson: Prisma.JsonValue;
auditEvent: string;
reasonCode: string;
occurredAt: Date;
approval: string;
requiredRecordsJson: Prisma.JsonValue;
}) {
return {
eventId: row.eventId,
event: row.event,
from: row.from,
to: row.to,
actor: row.actorJson,
requiredRole: row.requiredRole,
requiredRecordRefs: row.requiredRecordRefsJson,
auditEvent: row.auditEvent,
reasonCode: row.reasonCode,
occurredAt: iso(row.occurredAt),
approval: row.approval,
requiredRecords: row.requiredRecordsJson
};
}
async function withTempJson<T>(value: unknown, run: (file: string) => Promise<T>): Promise<T> {
const dir = await mkdtemp(path.join(os.tmpdir(), "huijing-prisma-"));
const file = path.join(dir, "payload.json");
await writeFile(file, JSON.stringify(value, null, 2), "utf8");
try {
return await run(file);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
async function expectHarnessContract(contract: string, value: unknown): Promise<void> {
await withTempJson(value, async (file) => {
const result = await execFileAsync("node", [harnessCli, "--contract", contract, "--input", file], {
cwd: workspaceRoot
});
expect(JSON.parse(result.stdout)).toEqual({ ok: true, reasonCode: null });
});
}
async function expectHarnessTransition(transition: string, value: unknown): Promise<void> {
await withTempJson(value, async (file) => {
const result = await execFileAsync("node", [harnessCli, "--transition", transition, "--input", file], {
cwd: workspaceRoot
});
expect(JSON.parse(result.stdout)).toEqual({ ok: true, reasonCode: null });
});
}
async function expectPrismaRejectedInSavepoint(
db: DbClient,
action: () => Promise<unknown>,
pattern: RegExp
): Promise<void> {
const savepointName = `task4_sp_${++savepointSequence}`;
await db.$executeRawUnsafe(`SAVEPOINT ${savepointName}`);
let caught: unknown;
try {
await action();
} catch (error) {
caught = error;
}
await db.$executeRawUnsafe(`ROLLBACK TO SAVEPOINT ${savepointName}`);
await db.$executeRawUnsafe(`RELEASE SAVEPOINT ${savepointName}`);
if (caught === undefined) {
throw new Error(`Expected Prisma action to reject with ${pattern}`);
}
expect(() => {
throw caught;
}).toThrow(pattern);
}
// Task 4 只允许测试 setup 使用事务本地 guard不提供生产 state-transition helper。
async function withStateGuard<T>(db: DbClient, callback: () => Promise<T>): Promise<T> {
await db.$executeRawUnsafe("SELECT set_config('app.state_transition_guard', 'on', true)");
const result = await callback();
await db.$executeRawUnsafe("SELECT set_config('app.state_transition_guard', '', true)");
return result;
}
async function withRollback(callback: (tx: DbClient) => Promise<void>): Promise<void> {
try {
await prisma.$transaction(async (tx) => {
await callback(tx);
throw rollbackToken;
});
} catch (error) {
if (error !== rollbackToken) throw error;
}
}
async function seedProjectSet(db: DbClient, prefix: string) {
const scopedPrefix = `${runId}-${prefix}`;
const creator = await db.user.create({
data: {
id: `${scopedPrefix}-creator`,
email: `${scopedPrefix}@example.test`,
displayName: `${prefix} creator`
}
});
const operator = await db.user.create({
data: {
id: `${scopedPrefix}-operator`,
email: `${scopedPrefix}-operator@example.test`,
displayName: `${prefix} operator`
}
});
const project = await db.gameProject.create({
data: {
id: `${scopedPrefix}-project`,
ownerId: creator.id,
slug: `${scopedPrefix}-project`,
title: `${prefix} project`
}
});
const version = await insertGameVersion(db, project.id, `${scopedPrefix}-version`, 1);
return { creator, operator, project, version };
}
async function seedS0FixtureProjectSet(db: DbClient) {
const creator = await db.user.create({
data: {
id: s0SessionSeed.creatorId,
email: `${runId}-creator-demo-001@example.test`,
displayName: "S0 fixture creator"
}
});
const project = await db.gameProject.create({
data: {
id: s0SessionSeed.projectId,
ownerId: creator.id,
slug: `${runId}-${s0SessionSeed.projectId}`,
title: "S0 simulation project"
}
});
const version = await insertGameVersion(db, project.id, s0SessionSeed.versionId, 1);
return { creator, project, version };
}
async function insertGameVersion(db: DbClient, projectId: string, id: string, versionNumber: number) {
return withStateGuard(db, () =>
db.gameVersion.create({
data: {
id,
projectId,
versionNumber,
status: "draft",
configJson: { seed: id }
}
})
);
}
describe("Prisma core database model", () => {
afterAll(async () => {
await prisma.$disconnect();
});
it("stores S0-compatible session and task anchors and passes the S0 CLI projection checks", async () => {
await withRollback(async (tx) => {
await seedS0FixtureProjectSet(tx);
const session = await tx.mainCreationAgentSession.create({
data: {
...s0SessionSeed
}
});
const task = await tx.agentTask.create({
data: {
...s0TaskSeed,
sessionId: session.id
}
});
await expectHarnessContract("MainCreationAgentSession", projectSession(session));
await expectHarnessContract("AgentTask", projectTask(task));
});
});
it("anchors arbitrary DB session and task rows to their actual owner, project, version, and parent session", async () => {
await withRollback(async (tx) => {
const seeded = await seedProjectSet(tx, "relationship-integrity");
const session = await tx.mainCreationAgentSession.create({
data: {
id: `${runId}-relationship-integrity-session`,
creatorId: seeded.creator.id,
projectId: seeded.project.id,
versionId: seeded.version.id,
status: "routing_internal_tasks",
contextSummary: "Arbitrary relationship integrity anchor"
}
});
const task = await tx.agentTask.create({
data: {
id: `${runId}-relationship-integrity-task`,
sessionId: session.id,
taskType: "requirement_clarifier",
subagentId: "subagent-requirement-clarifier-001",
inputRef: "relationship-integrity-input",
outputRef: null,
status: "queued",
timeoutAt: new Date("2026-06-01T04:00:00.000Z"),
auditLogId: `${runId}-audit-relationship-integrity-task`
}
});
const anchoredSession = await tx.mainCreationAgentSession.findUniqueOrThrow({
where: { id: session.id },
include: {
creator: true,
project: true,
version: true,
tasks: true
}
});
expect(anchoredSession.creator.id).toBe(seeded.creator.id);
expect(anchoredSession.project.id).toBe(seeded.project.id);
expect(anchoredSession.version.id).toBe(seeded.version.id);
expect(anchoredSession.tasks.map((item) => item.id)).toEqual([task.id]);
});
});
it("rejects session anchors when projectId and versionId point at different projects", async () => {
await withRollback(async (tx) => {
const first = await seedProjectSet(tx, "session-first");
const second = await seedProjectSet(tx, "session-second");
await expectPrismaRejectedInSavepoint(
tx,
() =>
tx.mainCreationAgentSession.create({
data: {
id: `${runId}-session-project-mismatch`,
creatorId: first.creator.id,
projectId: first.project.id,
versionId: second.version.id,
status: "routing_internal_tasks",
contextSummary: "Mismatched session anchor"
}
}),
/Foreign key constraint violated/
);
});
});
it("enforces enums, task allowlist, timeout, failure, and audit anchor fields at the DB boundary", async () => {
await withRollback(async (tx) => {
const seeded = await seedProjectSet(tx, "task-policy");
const session = await tx.mainCreationAgentSession.create({
data: {
id: `${runId}-task-policy-session`,
creatorId: seeded.creator.id,
projectId: seeded.project.id,
versionId: seeded.version.id,
status: "routing_internal_tasks",
contextSummary: "Task policy anchor"
}
});
const failedTask = await tx.agentTask.create({
data: {
id: `${runId}-task-policy-failed-task`,
sessionId: session.id,
taskType: "game_design_draft_generator",
subagentId: "subagent-game-draft-001",
inputRef: "requirement-brief-simulation-001",
outputRef: null,
status: "failed",
timeoutAt: new Date("2026-06-01T04:30:00.000Z"),
errorCode: "SUBAGENT_TIMEOUT",
auditLogId: `${runId}-audit-task-policy-failed`
}
});
expect(failedTask.errorCode).toBe("SUBAGENT_TIMEOUT");
expect(failedTask.auditLogId).toBe(`${runId}-audit-task-policy-failed`);
expect(failedTask.timeoutAt.toISOString()).toBe("2026-06-01T04:30:00.000Z");
await expectPrismaRejectedInSavepoint(
tx,
() =>
tx.agentTask.create({
data: {
id: `${runId}-task-policy-invalid-pair`,
sessionId: session.id,
taskType: "requirement_clarifier",
subagentId: "subagent-game-draft-001",
inputRef: "prompt-record-s0-001",
outputRef: null,
status: "queued",
timeoutAt: new Date("2026-06-01T04:40:00.000Z"),
auditLogId: `${runId}-audit-task-policy-invalid`
}
}),
/AgentTask_task_subagent_allowlist_check/
);
await expectPrismaRejectedInSavepoint(
tx,
() =>
tx.$executeRawUnsafe(
`INSERT INTO "GameVersion" ("id", "projectId", "versionNumber", "status", "configJson")
VALUES ('${runId}-task-policy-bad-status', '${seeded.project.id}', 99, 'pending_review', '{}'::jsonb)`
),
/invalid input value for enum/
);
});
});
it("rejects invalid Job target shapes and enforces scoped idempotency", async () => {
await withRollback(async (tx) => {
const first = await seedProjectSet(tx, "job-first");
const second = await seedProjectSet(tx, "job-second");
await tx.job.create({
data: {
id: `${runId}-job-project-valid`,
actorId: first.creator.id,
projectId: first.project.id,
type: "build_project",
idempotencyKey: "idem-1",
targetType: "project",
targetId: first.project.id,
targetScopeKey: `project:${first.project.id}`,
gameProjectId: first.project.id,
payloadJson: { ok: true }
}
});
await expectPrismaRejectedInSavepoint(
tx,
() =>
tx.job.create({
data: {
id: `${runId}-job-project-duplicate`,
actorId: first.creator.id,
projectId: first.project.id,
type: "build_project",
idempotencyKey: "idem-1",
targetType: "project",
targetId: first.project.id,
targetScopeKey: `project:${first.project.id}`,
gameProjectId: first.project.id,
payloadJson: { ok: true }
}
}),
/Unique constraint failed/
);
await tx.job.create({
data: {
id: `${runId}-job-project-same-key-other-actor`,
actorId: second.creator.id,
projectId: first.project.id,
type: "build_project",
idempotencyKey: "idem-1",
targetType: "project",
targetId: first.project.id,
targetScopeKey: `project:${first.project.id}`,
gameProjectId: first.project.id,
payloadJson: { ok: true }
}
});
await expectPrismaRejectedInSavepoint(
tx,
() =>
tx.job.create({
data: {
id: `${runId}-job-project-xor-invalid`,
actorId: first.creator.id,
projectId: first.project.id,
type: "build_project",
idempotencyKey: "idem-xor",
targetType: "project",
targetId: first.project.id,
targetScopeKey: `project:${first.project.id}`,
gameProjectId: first.project.id,
gameVersionId: first.version.id,
payloadJson: { invalid: true }
}
}),
/Job_target_xor_check/
);
await expectPrismaRejectedInSavepoint(
tx,
() =>
tx.job.create({
data: {
id: `${runId}-job-project-targetid-invalid`,
actorId: first.creator.id,
projectId: first.project.id,
type: "build_project",
idempotencyKey: "idem-targetid",
targetType: "project",
targetId: second.project.id,
targetScopeKey: `project:${first.project.id}`,
gameProjectId: first.project.id,
payloadJson: { invalid: true }
}
}),
/Job_target_id_matches_check/
);
await expectPrismaRejectedInSavepoint(
tx,
() =>
tx.job.create({
data: {
id: `${runId}-job-project-scope-invalid`,
actorId: first.creator.id,
projectId: second.project.id,
type: "build_project",
idempotencyKey: "idem-scope",
targetType: "project",
targetId: first.project.id,
targetScopeKey: `project:${first.project.id}`,
gameProjectId: first.project.id,
payloadJson: { invalid: true }
}
}),
/Job_project_scope_matches_check/
);
await expectPrismaRejectedInSavepoint(
tx,
() =>
tx.job.create({
data: {
id: `${runId}-job-version-scope-invalid`,
actorId: first.creator.id,
projectId: second.project.id,
type: "build_version",
idempotencyKey: "idem-version-scope",
targetType: "version",
targetId: first.version.id,
targetScopeKey: `version:${first.version.id}`,
gameVersionId: first.version.id,
payloadJson: { invalid: true }
}
}),
/Foreign key constraint violated/
);
});
});
it("enforces ReviewRecord decision evidence for approved, rejected, pending, and canceled states", async () => {
await withRollback(async (tx) => {
const seeded = await seedProjectSet(tx, "review-evidence");
await expectPrismaRejectedInSavepoint(
tx,
() =>
withStateGuard(tx, () =>
tx.reviewRecord.create({
data: {
id: `${runId}-review-approved-missing-decision`,
gameVersionId: seeded.version.id,
status: "approved",
reasonCode: "approved_for_publish",
decidedById: seeded.operator.id,
decidedAt: new Date("2026-06-01T05:01:00.000Z")
}
})
),
/ReviewRecord_decision_reason_check/
);
await expectPrismaRejectedInSavepoint(
tx,
() =>
withStateGuard(tx, () =>
tx.reviewRecord.create({
data: {
id: `${runId}-review-rejected-missing-evidence`,
gameVersionId: seeded.version.id,
status: "rejected",
decision: "rejected",
reasonCode: " ",
decidedById: seeded.operator.id,
decidedAt: new Date("2026-06-01T05:02:00.000Z")
}
})
),
/ReviewRecord_decision_reason_check/
);
await expectPrismaRejectedInSavepoint(
tx,
() =>
withStateGuard(tx, () =>
tx.reviewRecord.create({
data: {
id: `${runId}-review-pending-with-decision`,
gameVersionId: seeded.version.id,
status: "pending_review",
decision: "approved",
reasonCode: "approved_for_publish",
decidedById: seeded.operator.id,
decidedAt: new Date("2026-06-01T05:03:00.000Z")
}
})
),
/ReviewRecord_decision_reason_check/
);
await expectPrismaRejectedInSavepoint(
tx,
() =>
withStateGuard(tx, () =>
tx.reviewRecord.create({
data: {
id: `${runId}-review-canceled-with-decision`,
gameVersionId: seeded.version.id,
status: "canceled",
decision: "rejected",
reasonCode: "needs_design_changes",
decidedById: seeded.operator.id,
decidedAt: new Date("2026-06-01T05:04:00.000Z")
}
})
),
/ReviewRecord_decision_reason_check/
);
});
});
it("blocks direct guarded state writes, lifecycle deletes, and audit mutations without the transaction guard", async () => {
await withRollback(async (tx) => {
const seeded = await seedProjectSet(tx, "guard-policy");
await expectPrismaRejectedInSavepoint(
tx,
() =>
tx.gameVersion.create({
data: {
id: `${runId}-guard-policy-direct-version`,
projectId: seeded.project.id,
versionNumber: 2,
status: "draft",
configJson: {}
}
}),
/state transition guard is required/
);
await expectPrismaRejectedInSavepoint(
tx,
() =>
tx.gameVersion.update({
where: { id: seeded.version.id },
data: { status: "candidate" }
}),
/state transition guard is required/
);
const review = await withStateGuard(tx, () =>
tx.reviewRecord.create({
data: {
id: `${runId}-guard-policy-review-approved`,
gameVersionId: seeded.version.id,
status: "approved",
decision: "approved",
reasonCode: "approved_for_publish",
decidedById: seeded.operator.id,
decidedAt: new Date("2026-06-01T05:00:00.000Z")
}
})
);
expect(review.reasonCode).toBe("approved_for_publish");
await expectPrismaRejectedInSavepoint(
tx,
() =>
tx.reviewRecord.update({
where: { id: review.id },
data: { status: "rejected", decision: "rejected", reasonCode: "needs_fix" }
}),
/state transition guard is required/
);
await expectPrismaRejectedInSavepoint(
tx,
() =>
withStateGuard(tx, () =>
tx.reviewRecord.create({
data: {
id: `${runId}-guard-policy-review-missing-reason`,
gameVersionId: seeded.version.id,
status: "approved",
decision: "approved",
reasonCode: null,
decidedById: seeded.operator.id,
decidedAt: new Date("2026-06-01T05:01:00.000Z")
}
})
),
/ReviewRecord_decision_reason_check/
);
const lifecycle = await withStateGuard(tx, () =>
tx.lifecycleEvent.create({
data: {
eventId: `${runId}-guard-policy-review-approved-event`,
gameVersionId: seeded.version.id,
event: "review_approved",
from: "pending_review",
to: "publish_ready",
actorJson: { role: "operator", operatorId: seeded.operator.id },
requiredRole: "operator",
requiredRecordRefsJson: [review.id],
auditEvent: "lifecycle.review_approved",
reasonCode: "approved_for_publish",
approval: "operator",
requiredRecordsJson: ["ReviewRecord.decision=approved", "ReviewRecord.reasonCode"],
occurredAt: new Date("2026-06-01T05:02:00.000Z")
}
})
);
await expectHarnessContract("LifecycleEvent", projectLifecycle(lifecycle));
await expectHarnessTransition("review_approved", projectLifecycle(lifecycle));
const rejectedLifecycle = await withStateGuard(tx, () =>
tx.lifecycleEvent.create({
data: {
eventId: `${runId}-guard-policy-review-rejected-event`,
gameVersionId: seeded.version.id,
event: "review_rejected",
from: "pending_review",
to: "rejected",
actorJson: { role: "operator", operatorId: seeded.operator.id },
requiredRole: "operator",
requiredRecordRefsJson: [`${runId}-review-rejected-001`],
auditEvent: "lifecycle.review_rejected",
reasonCode: "needs_design_changes",
approval: "operator",
requiredRecordsJson: ["ReviewRecord.decision=rejected", "ReviewRecord.reasonCode"],
occurredAt: new Date("2026-06-01T05:03:00.000Z")
}
})
);
await expectHarnessTransition("review_rejected", projectLifecycle(rejectedLifecycle));
await expectPrismaRejectedInSavepoint(
tx,
() => tx.lifecycleEvent.delete({ where: { eventId: lifecycle.eventId } }),
/state transition guard is required/
);
const audit = await tx.auditLog.create({
data: {
id: `${runId}-guard-policy-audit`,
actorId: seeded.operator.id,
action: "lifecycle.review_approved",
targetType: "GameVersion",
targetId: seeded.version.id,
eventJson: { reviewRecordId: review.id }
}
});
await expectPrismaRejectedInSavepoint(
tx,
() => tx.auditLog.update({ where: { id: audit.id }, data: { action: "changed" } }),
/AuditLog is append-only/
);
await expectPrismaRejectedInSavepoint(tx, () => tx.auditLog.delete({ where: { id: audit.id } }), /AuditLog is append-only/);
});
});
});

14
apps/api/tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "src/**/*.spec.ts"]
}

6
apps/web/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

5
apps/web/next.config.mjs Normal file
View File

@ -0,0 +1,5 @@
const nextConfig = {
transpilePackages: ["@huijing/shared-contracts"]
};
export default nextConfig;

25
apps/web/package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "@huijing/web",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"build": "next build",
"dev:smoke": "next build"
},
"dependencies": {
"@huijing/shared-contracts": "workspace:*",
"next": "^16.2.6",
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
"devDependencies": {
"@types/node": "^25.9.1",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"vitest": "^4.1.7"
}
}

View File

@ -0,0 +1,11 @@
import type { ReactNode } from "react";
import { createElement } from "react";
export const metadata = {
description: "Huijing AI app foundation",
title: "Huijing AI"
};
export default function RootLayout({ children }: { readonly children: ReactNode }) {
return createElement("html", { lang: "en" }, createElement("body", null, children));
}

11
apps/web/src/app/page.tsx Normal file
View File

@ -0,0 +1,11 @@
import { createElement } from "react";
import { S1_PACKAGE_NAMES } from "@huijing/shared-contracts";
export default function HomePage() {
return createElement(
"main",
{ "data-package": S1_PACKAGE_NAMES.web },
createElement("h1", null, "Huijing AI App Foundation"),
createElement("p", null, `${S1_PACKAGE_NAMES.web} is ready.`)
);
}

View File

@ -0,0 +1,25 @@
import type { ReactElement, ReactNode } from "react";
import { describe, expect, it } from "vitest";
import RootLayout from "./app/layout";
import HomePage from "./app/page";
describe("@huijing/web smoke", () => {
it("returns an App Router root layout with html and body elements", () => {
const layout = RootLayout({ children: "content" }) as unknown as ReactElement<{
children: ReactElement<{ children: ReactNode }>;
lang: string;
}>;
expect(layout.type).toBe("html");
expect(layout.props.lang).toBe("en");
expect(layout.props.children.type).toBe("body");
expect(layout.props.children.props.children).toBe("content");
});
it("renders the web package identity on the home page", () => {
const page = HomePage() as ReactElement<{ "data-package": string }>;
expect(page.type).toBe("main");
expect(page.props["data-package"]).toBe("@huijing/web");
});
});

13
apps/web/tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"allowJs": false,
"jsx": "preserve",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"noEmit": true,
"plugins": [{ "name": "next" }],
"types": ["node", "react", "react-dom"]
},
"include": ["next-env.d.ts", ".next/types/**/*.ts", "src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules"]
}

5
apps/worker/.env.example Normal file
View File

@ -0,0 +1,5 @@
DATABASE_URL=postgresql://huijing:huijing@localhost:5432/huijing_dev?schema=public
REDIS_URL=redis://localhost:6379
QUEUE_ADAPTER=bullmq
S1_STORAGE_ROOT=.local/storage
HARNESS_CLI_TIMEOUT_MS=5000

16
apps/worker/package.json Normal file
View File

@ -0,0 +1,16 @@
{
"name": "@huijing/worker",
"private": true,
"type": "module",
"scripts": {
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"build": "tsc -p tsconfig.json",
"dev:smoke": "vitest run"
},
"devDependencies": {
"@types/node": "^25.9.1",
"vitest": "^4.1.7"
}
}

40
apps/worker/src/index.ts Normal file
View File

@ -0,0 +1,40 @@
export interface WorkerSmokeStatus {
readonly packageName: "@huijing/worker";
readonly queue: "none" | "memory";
readonly status: "idle";
}
export interface WorkerQueueMessage {
readonly jobId: string;
readonly type: "noop";
readonly payload: unknown;
}
export interface WorkerNoopSmokeResult {
readonly processed: number;
readonly jobTypes: readonly string[];
}
export function getWorkerSmokeStatus(): WorkerSmokeStatus {
return {
packageName: "@huijing/worker",
queue: "none",
status: "idle"
};
}
export async function runNoopWorkerSmoke(): Promise<WorkerNoopSmokeResult> {
const messages: WorkerQueueMessage[] = [{ jobId: "worker-smoke-noop", type: "noop", payload: {} }];
const processed: string[] = [];
for (const message of messages) {
// S1 worker smoke 只证明 no-op queue handler 可运行DB claim/complete 由 API JobExecutionStore 统一拥有。
if (message.type !== "noop") continue;
processed.push(message.type);
}
return {
processed: processed.length,
jobTypes: processed
};
}

View File

@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { getWorkerSmokeStatus, runNoopWorkerSmoke } from "./index";
describe("@huijing/worker smoke", () => {
it("reports an idle no-op worker status", () => {
expect(getWorkerSmokeStatus()).toEqual({
packageName: "@huijing/worker",
queue: "none",
status: "idle"
});
});
it("processes one no-op queue message without importing Prisma or mutating guarded state", async () => {
await expect(runNoopWorkerSmoke()).resolves.toEqual({
processed: 1,
jobTypes: ["noop"]
});
});
});

10
apps/worker/tsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "src/**/*.spec.ts"]
}

View File

@ -0,0 +1,81 @@
# 2026-06-01 S1 Task 1 工作区初始化门禁
## 结论
S1 Task 1 `Initialize Workspace` 已完成实现,并通过两轮 fresh review gate
- spec compliance reviewPASS无 blocking finding。
- quality / feasibility reviewPASS无 blocking finding。
可以进入 S1 Task 2。
## 前置基线
Task 1 开始前,工作树已有 `.idea/``docs-design/``docs/``harness/` 变更或未跟踪内容。它们是前置 dirty baseline不属于 Task 1 deltaTask 1 未回退、清理或重写这些内容。
## 实现范围
Task 1 delta 仅包含:
- `.gitignore`
- `package.json`
- `pnpm-workspace.yaml`
- `tsconfig.base.json`
- `eslint.config.mjs`
- `scripts/check-workspace-scripts.mjs`
- `pnpm-lock.yaml`
未创建:
- `apps/**`
- `packages/**`
- `scripts/check-s1-scope.mjs`
## Review Gate
| Gate | Reviewer ID | Result | Blocking findings |
| --- | --- | --- | --- |
| implementation | `019e80cd-cec9-7631-b131-7f93954b2808` | DONE | 无 |
| spec compliance initial | `019e80d3-9538-7a32-9ec9-c231ed27e478` | FAIL | 误把前置 dirty baseline 计入 Task 1 scope |
| spec compliance re-review | `019e80d9-075d-70e3-bd45-dad329715740` | PASS | 无 |
| quality / feasibility | `019e80de-5705-74f0-a04d-e29d0de4e659` | PASS | 无 |
初次 spec review 的 Critical 是证据上下文问题,不是实现缺陷;补充 Task 1 前置 `git status --short` baseline 后fresh re-review 判定 Task 1 delta 合规。
## Controller Verification
已运行:
```bash
node --check scripts/check-workspace-scripts.mjs
pnpm check:workspace-scripts
pnpm install --frozen-lockfile
pnpm exec eslint --print-config apps/example.ts >/dev/null
test ! -e scripts/check-s1-scope.mjs && test ! -d apps && test ! -d packages
```
结果:
- `node --check scripts/check-workspace-scripts.mjs`PASS。
- `pnpm check:workspace-scripts`PASS当前没有 workspace packages。
- `pnpm install --frozen-lockfile`PASSlockfile up to date。
- `pnpm exec eslint --print-config apps/example.ts >/dev/null`PASSflat config 可解析。
- forbidden path checkPASS`apps/``packages/``scripts/check-s1-scope.mjs` 不存在。
未运行完整 `pnpm lint``pnpm typecheck``pnpm test``pnpm build`,因为 Task 2 才创建 workspace packages 和占位测试。
## SHA-256
| Path | SHA-256 |
| --- | --- |
| `.gitignore` | `6dcd1666bf43e6f3abb664ad4aa511502a89f3500904fd059912dec73e516ae6` |
| `package.json` | `0e3d6edb70fe2317497ce883710d961eaa3df9b5b37cd0e2b53873633dabe92b` |
| `pnpm-workspace.yaml` | `08d75840c97ab0e72d1d9b5b84a17e47a2e06cb159a5fbec5ee0a6a56682dad7` |
| `tsconfig.base.json` | `0080d9633209d16b5328cfad0c9a4d4b63de70ffd65115bf5ef89a3c185f34c8` |
| `eslint.config.mjs` | `d055a4c012addf1b74db19811630b5a51d453dcf10d5366faf4d4aa0499c7a11` |
| `scripts/check-workspace-scripts.mjs` | `c46c16a1c07d8afbce8c4afd6bcee9804413298651729966f46f9f5109742eb7` |
| `pnpm-lock.yaml` | `3410b404ec3eab910cf0c7fb6aa74b6b63296f634fce2762e0ec3ce908b38eb9` |
## 当前限制
`check:s1-scope` 仍指向未来的 `scripts/check-s1-scope.mjs`;这是 S1 plan 的预期安排Task 9 才创建并验证该脚本。

View File

@ -0,0 +1,135 @@
# 2026-06-01 S1 Task 2 应用骨架门禁
## 结论
S1 Task 2 `Scaffold Web, API, Worker, Shared Packages` 已完成实现,并通过两轮 fresh review gate
- spec compliance reviewPASS无 blocking finding。
- quality / feasibility review首次 FAIL修复后 PASS无 blocking finding。
可以进入 S1 Task 3。
## 前置基线
Task 2 开始前,工作树已有:
- `.idea/``docs-design/``docs/``harness/` 前置 dirty baseline。
- S1 Task 1 根 workspace 文件。
- `docs/memorys/2026-06-01-S1Task1工作区初始化门禁.md`
这些不属于 Task 2 deltaTask 2 未回退、清理或重写这些内容。
## 实现范围
Task 2 delta 包含:
- `apps/web/**`
- `apps/api/**`
- `apps/worker/**`
- `packages/shared-contracts/**`
- `packages/harness-client/**`
- `pnpm-lock.yaml`
保留的生成文件:
- `apps/web/next-env.d.ts`Next TypeScript build 支持文件。
未创建:
- DB / Prisma schema。
- Docker / dev infra。
- auth / project modules。
- worker queue implementation。
- harness CLI integration。
- `scripts/check-s1-scope.mjs`
- game generation / runtime / WebPlatformAdapter / storage / RBAC 实现。
## Review Gate
| Gate | Reviewer ID | Result | Blocking findings |
| --- | --- | --- | --- |
| implementation | `019e80e5-9503-7d31-aecc-f9cb4232db24` | DONE_WITH_CONCERNS -> DONE | pnpm ignored build scripts 作为后续 Prisma 风险记录 |
| spec compliance initial | `019e80fb-c82b-7a21-98c0-9a557bae20c0` | PASS | 无 |
| quality / feasibility initial | `019e8102-eadf-7673-ba2f-c47372a13216` | FAIL | API ESM build artifact 不能被 Node load |
| spec compliance re-review | `019e8112-3f05-7de3-95ed-96ce7e8ec144` | PASS | 无 |
| quality / feasibility re-review | `019e8118-6796-74d0-ae4e-f24b4083b6d6` | PASS | 无 |
## 关键修复
初次 quality review 发现 `@huijing/api` 在 ESM + extensionless import 下,`pnpm build` 能成功但 `node apps/api/dist/main.js` 失败,报 `ERR_MODULE_NOT_FOUND`
已修复:
- `apps/api/tsconfig.json` 使用 `module: "NodeNext"``moduleResolution: "NodeNext"`
- API 相对 import 改为 `.js` specifier。
- `apps/api/src/main.ts` 只在直接执行时启动 Nest server被 import 时不会启动长运行进程。
- `apps/api/package.json``build` 加入 `node -e "await import('./dist/main.js')"`,确保 API 编译产物可被 Node bounded load。
## Controller Verification
控制器在临时镜像 `/tmp/games-s1-task2-controller.*` 中运行完整验证,避免在原工作区留下 build artifacts
```bash
pnpm install --frozen-lockfile
pnpm check:workspace-scripts
pnpm lint
pnpm typecheck
pnpm test
pnpm build
pnpm dev:smoke
cd apps/api && node -e "await import('./dist/main.js')"
```
结果:
- `pnpm install --frozen-lockfile`PASS。
- `pnpm check:workspace-scripts`PASS。
- `pnpm lint`PASS。
- `pnpm typecheck`PASS。
- `pnpm test`PASS5 个 test files / 7 个 tests。
- `pnpm build`PASS包含 API dist import-load check 和 Next build。
- `pnpm dev:smoke`PASSbounded Vitest / Next build smoke。
- `node -e "await import('./dist/main.js')"`PASS。
安装时 pnpm 仍提示 ignored build scripts`@nestjs/core``@prisma/engines``prisma``sharp`。Task 2 review 判定这不是当前阻塞Task 4/Prisma 接入前必须显式验证 Prisma CLI/engine 行为。
原工作区 build hygiene
- `apps/**` / `packages/**` 下无 `dist``.next``coverage``.turbo``tsconfig.tsbuildinfo` 残留。
- `node_modules` 存在于 pnpm 安装面,但已被 `.gitignore` 忽略。
## SHA-256
| Path | SHA-256 |
| --- | --- |
| `apps/api/package.json` | `c196bf57ce0620678bba85bb120560ac57c328ce939feab81b2c20fe2236cece` |
| `apps/api/tsconfig.json` | `3bd816db2ad470383908e05caeaa6d09f70a442faa048f5fa37a048a3f833dba` |
| `apps/api/src/main.ts` | `fcefc7c057d211b11f890477a9a0017ea4a3d4ed41c34e93dc1be271f8212d58` |
| `apps/api/src/app.module.ts` | `801f76ed55d6a68c16b602ab1de2383102d3360ebc0a6d4187381e7972475056` |
| `apps/api/src/health.controller.ts` | `148a766072bdc8fd82196d0e12a570d819ef73e3790e486b8382423820f12640` |
| `apps/api/src/health.controller.spec.ts` | `55951223829eee0337b7f2cdbcdbc59e91dc3d16e7f5c8b2cb23bff5d6a31818` |
| `apps/web/package.json` | `8f98a78d0ae0568784eb5d2030d969245e5557726716ff4b754bf475a6548e28` |
| `apps/web/tsconfig.json` | `aea53dc64ec13851d4ae402eecdc72877e1dbe49683b0f766eb6b22418b0725f` |
| `apps/web/next.config.mjs` | `bbd5d4d14444fa08710ea17b600f194281abc880700bfd66cf088477a7f54c16` |
| `apps/web/next-env.d.ts` | `7b550dda9686c16f36a17bf9051d5dbf31e98555b30d114ac49fc49a1e712651` |
| `apps/web/src/app/layout.tsx` | `d895c441c61e964e0c59d68cfb6f6d596bd80b0430676496b2c4dfd1d77e97ba` |
| `apps/web/src/app/page.tsx` | `476da146bb7fa7164404af6bf24d6b4b65bdcd1a8c92a7b9a9488a6a59fdfb61` |
| `apps/web/src/smoke.test.ts` | `0c26e88a463147d8542f2ab59485907a8b396ea53a9c7349b99ed22a7c17fd87` |
| `apps/worker/package.json` | `5094f400f8a169a4375c392802402454fcc333576a026d491d58b5f79a8c1cf2` |
| `apps/worker/tsconfig.json` | `2f4be30e3b5a16e3912d3da11f257c8cce21c77af144655ce8cc20e17400cd25` |
| `apps/worker/src/index.ts` | `66e0389a61773e34d6b33b75dc24646785a5da16e73b30b49f065cd09cf24360` |
| `apps/worker/src/worker.smoke.spec.ts` | `081a4ad6d4a4ca2941faa895c0c4afd4a5cff5a2e66ba9656dd9c41824c3b91a` |
| `packages/shared-contracts/package.json` | `3f4b7503422a02075111877f69a62d5683c3c051acba744bc724a0600c6987bf` |
| `packages/shared-contracts/tsconfig.json` | `2f4be30e3b5a16e3912d3da11f257c8cce21c77af144655ce8cc20e17400cd25` |
| `packages/shared-contracts/src/index.ts` | `bacedef4146ed83fb306d52184d1a6373b121fc9aa1bb5a20ed1fbc398b23758` |
| `packages/shared-contracts/src/index.spec.ts` | `05fdb16cb210d7c5a03f8af530b902bf9522ffcccc4cc7892bbdd1a2407cb6a2` |
| `packages/harness-client/package.json` | `dae5f12bde880da0400c9023a6aa44519b96a33c2c011a6c38ed6e01d251e8bb` |
| `packages/harness-client/tsconfig.json` | `2f4be30e3b5a16e3912d3da11f257c8cce21c77af144655ce8cc20e17400cd25` |
| `packages/harness-client/src/index.ts` | `aa435bdb7f9027241968e6e04fc095cb73c32ee78ddd527f88193895eb5654f8` |
| `packages/harness-client/src/index.spec.ts` | `13c651235c0fe2571d6ed9209fcb15c55a37d98c94817652b5994f509364994a` |
| `pnpm-lock.yaml` | `9182ae05154e589711137d21b1a58afa54a28ecde5e0e7cb6d23cac3db69a4ab` |
## 后续注意
- Task 4/Prisma 前必须处理或验证 pnpm ignored build scripts 对 Prisma engine 的影响。
- `@huijing/harness-client` 当前是 fail-closed placeholderTask 5 才接入 S0 CLI不得提前把 placeholder 当作真实 validator。

View File

@ -0,0 +1,78 @@
# 2026-06-01 S1 Task 3 开发基础设施门禁
## 结论
S1 Task 3 `Add Development Infrastructure` 已完成实现,并通过两轮 fresh review gate
- spec compliance reviewPASS无 blocking finding。
- quality / feasibility reviewPASS无 blocking finding。
可以进入 S1 Task 4。
## 前置基线
Task 3 开始前,工作树已有:
- `.idea/``docs-design/``docs/``harness/` 前置 dirty baseline。
- S1 Task 1 / Task 2 workspace 与 app skeleton 文件。
- S1 Task 1 / Task 2 memory 留痕。
这些不属于 Task 3 deltaTask 3 未回退、清理或重写这些内容。
## 实现范围
Task 3 delta 仅包含:
- `infra/docker-compose.dev.yml`
- `apps/api/.env.example`
- `apps/worker/.env.example`
未创建:
- DB schema / Prisma migration。
- dev scripts / README / docs。
- app code / queue / storage implementation。
- 业务模块。
## Review Gate
| Gate | Reviewer ID | Result | Blocking findings |
| --- | --- | --- | --- |
| implementation | `019e812c-3746-7e42-ad95-5be412def299` | DONE | 无 |
| spec compliance | `019e8132-4244-7f43-a275-63ab2e84b113` | PASS | 无 |
| quality / feasibility | `019e8134-d6d2-7d90-ae33-f27a642a0e1e` | PASS | 无 |
## Controller Verification
已运行:
```bash
docker compose -f infra/docker-compose.dev.yml ps
docker compose -f infra/docker-compose.dev.yml exec -T postgres pg_isready -U huijing -d huijing_dev
docker compose -f infra/docker-compose.dev.yml exec -T postgres psql -U huijing -d huijing_dev -tAc 'select 1'
docker compose -f infra/docker-compose.dev.yml exec -T redis redis-cli ping
POSTGRES_PORT=15432 REDIS_PORT=16379 docker compose -f infra/docker-compose.dev.yml config
```
结果:
- `infra-postgres-1``Up ... (healthy)`,端口 `0.0.0.0:5432->5432/tcp`
- `infra-redis-1``Up ... (healthy)`,端口 `0.0.0.0:6379->6379/tcp`
- `pg_isready``/var/run/postgresql:5432 - accepting connections`
- `psql select 1`:返回 `1`
- `redis-cli ping`:返回 `PONG`
- 端口覆盖配置可解析:`POSTGRES_PORT=15432` 映射到 target `5432``REDIS_PORT=16379` 映射到 target `6379`
容器保持运行,供 Task 4 Prisma migration 使用。
## SHA-256
| Path | SHA-256 |
| --- | --- |
| `infra/docker-compose.dev.yml` | `0bd44a47ed724c2a80ed0fc6e6923e2ed9d8800461027ee1261e6df0d5ebc67f` |
| `apps/api/.env.example` | `7a3101a09f29d3be684bdc2f7990c98b4d7e737af6055993d9a536bd028344ef` |
| `apps/worker/.env.example` | `7a3101a09f29d3be684bdc2f7990c98b4d7e737af6055993d9a536bd028344ef` |
## 后续注意
Task 4 可直接复用当前运行中的 Postgres / Redis。若本机端口后续冲突按 plan 使用 `POSTGRES_PORT` / `REDIS_PORT` 覆盖并记录验证证据。

View File

@ -0,0 +1,131 @@
# 2026-06-01 S1 Task 4 数据库模型门禁
## 结论
S1 Task 4 `Implement Core Database Model` 已完成实现,并通过两轮 fresh review gate
- spec compliance review最终 PASS无 blocking finding。
- quality / feasibility review最终 PASS无 blocking finding。
可以进入 S1 Task 5。
## 前置基线
Task 4 开始前,工作树已有:
- `.idea/``docs-design/``docs/``harness/` 前置 dirty baseline。
- S1 Task 1-3 workspace / app / infra 文件。
- S1 Task 4 plan amendment`docs/superpowers/plans/2026-05-31-mvp-S1-app-foundation.md`
- S1 Task 4 plan amendment memory`docs/memorys/2026-06-01-S1Task4计划修正门禁.md`
这些不属于 Task 4 implementation deltaTask 4 未回退、清理或重写这些内容。
## 实现范围
Task 4 delta 包含:
- `apps/api/prisma/schema.prisma`
- `apps/api/prisma.config.ts`
- `apps/api/prisma/migrations/20260601040253_s1_app_foundation/migration.sql`
- `apps/api/prisma/migrations/20260601050132_s1_task4_quality_fixes/migration.sql`
- `apps/api/prisma/migrations/migration_lock.toml`
- `apps/api/src/generated/prisma/**`
- `apps/api/src/prisma.schema.spec.ts`
- `apps/api/package.json`
- `pnpm-lock.yaml`
未创建:
- service / API endpoint。
- JobExecutionStore。
- state-transition service / helper module。
- worker Prisma import / DB writer。
- auth / project / storage / queue business module。
- game generation / prompt routing / subagent execution / GameIR compilation。
## Review Gate
| Gate | Reviewer ID | Result | Blocking findings |
| --- | --- | --- | --- |
| implementation attempt | `019e813b-ad1b-7003-8899-80d6508f18a5` | NEEDS_CONTEXT | Prisma 7 plan 缺口 |
| plan amendment re-review coherence | `019e814b-1fbf-7263-a5ee-764e8efd6ee0` | PASS | 无 |
| plan amendment re-review feasibility | `019e814b-2053-70f3-bceb-ab84ecf4bd3f` | PASS | 无 |
| implementation | `019e8151-c119-7812-a7fd-848549897826` | DONE_WITH_CONCERNS -> DONE | pg warning 非阻塞 |
| spec compliance initial | `019e8166-b443-7270-83b4-52287f806636` | FAIL | Session S0 投影测试覆盖了 DB row 字段 |
| spec compliance re-review | `019e817b-0403-7013-a3d7-57ef40549ef6` | PASS | 无 |
| quality initial | `019e8180-6923-7912-8e36-1d6a700150bd` | FAIL | 普通测试 destructiveReviewRecord CHECK 不完整 |
| spec compliance after quality fixes | `019e8192-7d94-7791-ba83-816a5f6e91f1` | PASS | 无 |
| quality re-review | `019e8196-d916-7591-a6ff-e0d535ce9836` | FAIL | unused helper 导致 lint 失败 |
| final spec compliance | `019e81a2-2ce0-7db1-bbcb-561839c2d4a7` | PASS | 无 |
| final quality / feasibility | `019e81a6-01a7-7ce3-8f40-c2592d194343` | PASS | 无 |
## 关键修复
- Prisma 7 plan 修正:
- 新增 `apps/api/prisma.config.ts`
- 使用 `prisma-client` generatoroutput 为 `apps/api/src/generated/prisma`
- direct tests 通过 `@prisma/adapter-pg` / `pg` 连接 Postgres。
- Session S0 projection
- S0 fixture ID 真实写入 DB 关系。
- `MainCreationAgentSession` CLI 校验直接使用 `projectSession(session)`,不再覆盖 `creatorId/projectId/versionId`
- 另有 arbitrary relationship integrity 测试。
- Test safety
- 移除 `TRUNCATE` / `RESTART IDENTITY` / broad reset。
- 普通 Prisma tests 使用 transaction rollback + savepoint。
- ReviewRecord invariant
- `pending_review` / `canceled` 必须没有 decision evidence。
- `approved` / `rejected` 必须有 matching `decision`、非空 `reasonCode``decidedById``decidedAt`
- Session/version same-project
- `MainCreationAgentSession(versionId, projectId)` 复合 FK 指向 `GameVersion(id, projectId)`
## Controller Verification
已运行:
```bash
pnpm --filter @huijing/api exec prisma validate --schema prisma/schema.prisma
pnpm --filter @huijing/api exec prisma migrate status --schema prisma/schema.prisma
pnpm --filter @huijing/api lint
pnpm --filter @huijing/api test -- prisma
pnpm --filter @huijing/api build
docker compose -f infra/docker-compose.dev.yml ps
```
结果:
- Prisma schemavalid。
- Prisma migrate status2 migrations founddatabase schema up to date。
- API lintPASS。
- API Prisma testsPASS2 files / 8 tests。
- API buildPASS。
- Docker
- `infra-postgres-1` healthy on `5432`
- `infra-redis-1` healthy on `6379`
- Build outputs 已清理,`apps/**` / `packages/**` 下无 `dist``.next``coverage``tsconfig.tsbuildinfo` 残留。
- destructive scan普通 app/package/script surface 中未发现 `TRUNCATE``RESTART IDENTITY``migrate reset``DROP DATABASE``DROP TABLE``DELETE FROM`
## Residual Non-blocking Notes
- `pnpm test` 已在 quality review 中通过;`check:s1-scope` 仍指向未来的 `scripts/check-s1-scope.mjs`,这是 Task 9 预期交付,不是 Task 4 blocker。
- Prisma test 当前会输出 `pg` deprecation warning现有 `pg` range 仍在 v8命令 exit 0。升级 Prisma adapter / `pg` 时需复查 savepoint 测试方式。
- pnpm ignored build scripts 仍可能列出 `@prisma/engines` / `prisma`,但本轮 validate / migrate / generate / tests / build 均通过。
## SHA-256
| Path | SHA-256 |
| --- | --- |
| `apps/api/package.json` | `678617fe3796b710a24f72230df9805f47879df2ca0f3f2b907bd68b1941bc17` |
| `apps/api/prisma.config.ts` | `eb72dc13ebfd7bf6e74036ac3c2ecc7add69be52e374b2c47411307b0c9381be` |
| `apps/api/prisma/schema.prisma` | `c418f59a6bcd10a87e7f68af336bffdedace5ba506a793ac7f2fb82f220996d9` |
| `apps/api/prisma/migrations/20260601040253_s1_app_foundation/migration.sql` | `00ce17a7f11a9c041679977a3c81cc5d8f4d0aece6681d793cb9acae4bad500a` |
| `apps/api/prisma/migrations/20260601050132_s1_task4_quality_fixes/migration.sql` | `666807cdfe9f549a34638f60c07cf1631f8ccd1ca43b796f346db2122e965e3b` |
| `apps/api/prisma/migrations/migration_lock.toml` | `99836963713b4f5b269ad49af0ed3d7b0b2e336115c2f92dc9ac683d139d0900` |
| `apps/api/src/prisma.schema.spec.ts` | `b98d3f82d0c500930c1a95f6648ce0c3c2894c3414da8c3067a612754b67a9a2` |
| `pnpm-lock.yaml` | `c85b03575467a508b0e85dbe81a7ab51c0ea6f754e122567b0671c2f3b12b576` |
| `docs/superpowers/plans/2026-05-31-mvp-S1-app-foundation.md` | `868dc63fee64e254cf267d63db472aa46abf15977f1999785cfaea6fa1956a0a` |
Generated Prisma client output under `apps/api/src/generated/prisma/**` is part of Task 4 output; individual file hashes were computed during controller verification and can be regenerated with:
```bash
find apps/api/src/generated/prisma -type f | sort | xargs shasum -a 256
```

View File

@ -0,0 +1,63 @@
# 2026-06-01 S1 Task 4 计划修正门禁
## 结论
S1 Task 4 在实现前发现 Prisma 7 工具链与原计划 allowed files 不匹配。已修正 Task 4 plan并通过 fresh amendment review gate。
可以重新派 fresh implementation subagent 执行 S1 Task 4。
## 阻塞事实
原 Task 4 plan 只允许创建 `apps/api/prisma/schema.prisma``apps/api/prisma/migrations/**` 和测试相关 `apps/api/src/**` 文件。实现子代理在进入实现前验证到:
- `pnpm --filter @huijing/api exec prisma --version`Prisma CLI 为 `7.8.0``@prisma/client: Not found`
- Prisma 7 不允许 `schema.prisma` 使用 `datasource.url = env("DATABASE_URL")`
- `migrate dev --schema prisma/schema.prisma` 需要 `prisma.config.ts` 提供 datasource URL。
- 直接 Prisma tests / `prisma generate` 需要允许安装 Prisma 7 client/runtime 依赖并更新 lockfile。
因此,原计划在不越权的情况下不可实现。
## Plan Amendment
已修改 `docs/superpowers/plans/2026-05-31-mvp-S1-app-foundation.md`
- Task 4 allowed files 增加:
- `apps/api/prisma.config.ts`
- `apps/api/package.json`
- `pnpm-lock.yaml`
- Dependency Matrix 中 `@huijing/api` 增加:
- runtime`@prisma/client``@prisma/adapter-pg``pg`
- dev`@types/pg`
- Task 4 Step 2 明确:
- Prisma 7 datasource URL 放在 `apps/api/prisma.config.ts`
- `schema.prisma` 不写 `url = env("DATABASE_URL")`
- 使用 Prisma 7 `prisma-client` generator。
- generated client explicit output 为 `apps/api/src/generated/prisma`
- direct Prisma tests 从该 output path import generated client并使用 PostgreSQL driver adapter。
- 不依赖 install-time postinstall仍以显式 `prisma generate` 作为验收证据。
未修改 S1 DB 合同、guarded-state/audit/job target 约束,也未把 Task 4 范围扩大到 service/API/Task 5+。
## Review Gate
| Gate | Reviewer ID | Result | Blocking findings |
| --- | --- | --- | --- |
| implementation attempt | `019e813b-ad1b-7003-8899-80d6508f18a5` | NEEDS_CONTEXT | Prisma 7 config/client scope 缺口 |
| amendment review 1 | `019e8143-d916-7351-a413-c59a2e1c05eb` | FAIL | package/lockfile scope 与 client strategy 不足 |
| amendment review 2 | `019e8143-d992-71b3-ad37-caab35c72e0d` | FAIL | `@prisma/client` / generator strategy / lockfile scope 缺口 |
| amendment re-review coherence/scope | `019e814b-1fbf-7263-a5ee-764e8efd6ee0` | PASS | 无 |
| amendment re-review feasibility | `019e814b-2053-70f3-bceb-ab84ecf4bd3f` | PASS | 无 |
## SHA-256
| Path | SHA-256 |
| --- | --- |
| `docs/superpowers/plans/2026-05-31-mvp-S1-app-foundation.md` | `868dc63fee64e254cf267d63db472aa46abf15977f1999785cfaea6fa1956a0a` |
| `docs/superpowers/specs/2026-05-31-mvp-S1-app-foundation-design.md` | `f514de72c36c5fd1ee6f3e87999b911f33d392549dbb76c6ee5d538dac774cdb` |
| `docs/superpowers/plans/2026-06-01-implementation-review-gate-protocol.md` | `c8726acc5457ab87fb8f26c2e439e1d1b98df0255af78e4d6e8d9636dc518478` |
## 后续执行要求
- S1 Task 4 必须使用新的 fresh implementation subagent不复用已返回 `NEEDS_CONTEXT` 的子代理。
- Task 4 review 必须检查 `prisma.config.ts`、generated client output、PostgreSQL adapter、`@prisma/client` 依赖、`pnpm-lock.yaml`、迁移 SQL 约束和 direct Prisma tests。
- pnpm ignored build scripts 仍存在;只有在 migrate/generate 实际失败时,才引入 `pnpm.onlyBuiltDependencies` / rebuild 作为 scoped fix。

View File

@ -0,0 +1,143 @@
# 2026-06-01 S1 Task 5 状态边界门禁
## 结论
S1 Task 5 `Implement Harness Client And State Transition Boundary` 已完成实现,并通过 mandatory fresh review gate
- spec compliance review最终 PASS无 Critical / Important / Minor finding。
- quality / feasibility review最终 PASS无 Critical / Important剩余 1 个 Minor 风险,不阻塞进入 Task 6。
可以进入 S1 Task 6。
## 实现范围
Task 5 delta 包含:
- `packages/harness-client/package.json`
- `packages/harness-client/src/index.ts`
- `packages/harness-client/src/internal.ts`
- `packages/harness-client/src/index.spec.ts`
- `apps/api/src/modules/harness-gate/index.ts`
- `apps/api/src/modules/harness-gate/harness-gate.spec.ts`
- `apps/api/src/modules/state-transition/index.ts`
- `apps/api/src/modules/state-transition/state-transition.spec.ts`
- `apps/api/package.json`
- `pnpm-lock.yaml`
未实现:
- Task 6 auth / RBAC。
- Task 7 queue / worker / storage / audit service。
- Task 8 project / version / review endpoint。
- Task 9 `scripts/check-s1-scope.mjs`
- game generation / prompt routing / asset pipeline / publish pipeline。
## 关键实现事实
- `@huijing/harness-client` package root 只导出 Task 5 指定的两个 type
- `HarnessGateResult`
- `HarnessClient`
- `@huijing/harness-client/runtime` 是受控 runtime subpath供 API 复用同一套 S0 CLI shell-out 实现,避免 API 复制子进程、临时文件、JSON parse 和 timeout 逻辑。
- Harness client runtime
- shell out 到 `node harness/scripts/validate-harness.mjs`
- 从 repo root resolve S0 CLI path不依赖当前 cwd。
- payload 写入临时 JSON成功、失败、timeout 后清理。
- 默认 timeout 为 `5000ms`API 侧通过 `HARNESS_CLI_TIMEOUT_MS` 解析注入。
- stdout / stderr 有捕获上限。
- strict JSON-only parse不使用 substring 判断。
- timeout 返回 `HARNESS_TIMEOUT`
- invalid output / spawn ambiguity 返回 `HARNESS_INVALID_OUTPUT`
- S0 CLI 返回 `{ ok:false, reasonCode }` 时保留 S0 reasonCode。
- `HarnessGateService`
- 通过 `@huijing/harness-client/runtime` 创建 runtime client。
- 只作为 API 内部 S0 gate facade不复制 harness runtime 核心逻辑。
- `StateTransitionService`
- 是 S1 当前唯一 API-owned guarded state transition boundary。
- 允许 S1 draft version creation但不把 draft creation 标记为 S0 high-risk transition。
- `review_approved` / `review_rejected` 会先过 S0 transition 和 `LifecycleEvent` contract gate。
- PASS 路径设置事务本地 guard写入 `ReviewRecord``LifecycleEvent``AuditLog`
- FAIL 路径返回 422-style `StateTransitionDomainError(reasonCode)`,不写 guarded state。
- FAIL blocked audit 使用独立 `blockedAuditDb` 写入通道;测试已覆盖外层 transaction rollback 后 blocked audit 仍持久化。
- guard cleanup 保留原始错误优先级,不让 cleanup failure 覆盖业务 / DB 原错。
- 静态边界扫描:
- 拦截 state-transition module 外的 guarded Prisma writes。
- 拦截 state-transition module 外的 runtime guard setter。
- migration 不再整体 allowlist只允许 trigger / current_setting 定义,不允许 guard setter 或 protected table DML。
- 扫描 `apps/worker/**`,忽略 `node_modules``dist``.vite``coverage``.next` 等生成目录。
- 明确不误伤 Task 4 `apps/api/src/prisma.schema.spec.ts` 的 test-only guard helper。
## Review Gate
| Gate | Agent ID | Result | Blocking findings |
| --- | --- | --- | --- |
| implementation | `019e81b2-b8fd-7c00-983b-04ff43dcaabe` | DONE | 初版完成 |
| spec compliance initial | `019e81cc-2106-7541-a604-c4e8b413f2c3` | FAIL | public contract 外泄API timeout 未接 env configscanner allowlist 过宽 |
| implementation fix 1 | `019e81b2-b8fd-7c00-983b-04ff43dcaabe` | DONE | 修复 spec findings |
| spec compliance re-review | `019e81f0-52e0-76d2-8ca9-5f4b4db015f1` | PASS | 无 |
| quality / feasibility initial | `019e81fb-497e-7230-9ede-5aa017d3757a` | FAIL | blocked audit 可随 transaction rollback 丢失API 复制 harness runtime |
| implementation fix 2 | `019e81b2-b8fd-7c00-983b-04ff43dcaabe` | DONE | 修复 quality findings |
| spec compliance safety recheck | `019e8215-c68e-7361-97ca-f8a2ca107c07` | PASS | 无 |
| quality / feasibility re-review | `019e821b-2026-77f3-8563-97515277b471` | PASS | 无 Critical / Important1 个 Minor |
## Controller Verification
已运行:
```bash
pnpm --filter @huijing/harness-client test
pnpm --filter @huijing/api test -- harness-gate state-transition
pnpm --filter @huijing/harness-client lint
pnpm --filter @huijing/harness-client typecheck
pnpm --filter @huijing/api lint
pnpm --filter @huijing/api typecheck
pnpm --filter @huijing/harness-client build
pnpm --filter @huijing/api build
pnpm --filter @huijing/api exec prisma validate --schema prisma/schema.prisma
pnpm --filter @huijing/api exec prisma migrate status --schema prisma/schema.prisma
node harness/scripts/validate-harness.mjs
pnpm check:workspace-scripts
pnpm --filter @huijing/api test -- prisma
docker compose -f infra/docker-compose.dev.yml ps
```
结果:
- Harness client testsPASS1 file / 10 tests。
- API harness-gate / state-transition testsPASS4 files / 19 tests。
- Harness client lint / typecheck / buildPASS。
- API lint / typecheck / buildPASS。
- Prisma validatePASS。
- Prisma migrate status2 migrations founddatabase schema up to date。
- S0 full harness validationPASSchecks=1259validFixtures=45invalidFixtures=51invalidCoverage=51/51。
- Workspace script checkPASS。
- API Prisma testsPASS4 files / 19 tests。
- Docker
- `infra-postgres-1` healthy on `5432`
- `infra-redis-1` healthy on `6379`
- Build outputs 已清理;`apps/**` / `packages/**` 下无 `dist``.next``coverage``tsconfig.tsbuildinfo` 残留。
## Residual Non-blocking Notes
- Quality reviewer 记录 1 个 Minor同一 `lifecycleEventId` 的 blocked transition 如果重试,固定 `blocked-audit` id 可能撞主键,原始 harness reasonCode 可能被 Prisma unique error 覆盖。后续 API composition root 接入时建议用 `createMany skipDuplicates``upsert`,或显式复用已有 blocked audit 后继续返回 `StateTransitionDomainError`
- `StateTransitionServiceOptions.blockedAuditDb` 依赖调用方注入非事务客户端;当前注释和测试表达了边界,后续 composition root 应用工厂封装,避免业务方直接 `new StateTransitionService` 时误传 transaction client。
- API / Prisma tests 仍输出既有 `pg` deprecation warning命令 exit 0。升级 `pg@9` 前需要复查 Prisma adapter / 并发测试方式。
- `check:s1-scope` 仍指向未来的 `scripts/check-s1-scope.mjs`,这是 Task 9 交付项,不是 Task 5 blocker。
- 当前 git status 仍包含 `.idea/``docs-design/` 和大量 untracked workspace surface这是前置工作面Task 5 未回退或清理这些内容。
## SHA-256
| Path | SHA-256 |
| --- | --- |
| `packages/harness-client/package.json` | `94bb54022628ded5c934816f2a0f5c9b47129e6090d5770718821ba5b3c6a733` |
| `packages/harness-client/src/index.ts` | `9936030f93e145baab3b701609442834be871b7f6177117ff2de3bf20464fa80` |
| `packages/harness-client/src/internal.ts` | `54deaba53a418a1a3b8d76366cf3f9c1f071c808489e1481c26525dc63cd8d59` |
| `packages/harness-client/src/index.spec.ts` | `d6b150b00098e4efceaf9ac9a8ab6d29887e6a50a169ac0ef53fc3f8f5da8ef1` |
| `apps/api/src/modules/harness-gate/index.ts` | `dfb683ce8b5e500aae271a8daa8e97592fdc2e2e39e50544d86c2fecc72de673` |
| `apps/api/src/modules/harness-gate/harness-gate.spec.ts` | `7f936d954add5a40c755abae987fdfd845c120b1ba281362afe174781aada3ac` |
| `apps/api/src/modules/state-transition/index.ts` | `c3c08bf59728f6e07160d821772a3a37201005ab38710583b6cab45e0938c065` |
| `apps/api/src/modules/state-transition/state-transition.spec.ts` | `cf8ae70448541185980ac716cc8118c3f6b84a1c0b1109e1fb4f36898b327dd2` |
| `apps/api/package.json` | `6697372caa5a2ae69c45995ca1ad66b92c656b548d581c1d4276f7e0fcd5902a` |
| `pnpm-lock.yaml` | `d1e6463b4b56b7f63ed35b4df7bf5ab43fd28a1df31adb21beb608c2f834ed26` |
| `docs/superpowers/plans/2026-05-31-mvp-S1-app-foundation.md` | `868dc63fee64e254cf267d63db472aa46abf15977f1999785cfaea6fa1956a0a` |
| `docs/superpowers/specs/2026-05-31-mvp-S1-app-foundation-design.md` | `f514de72c36c5fd1ee6f3e87999b911f33d392549dbb76c6ee5d538dac774cdb` |
| `docs/superpowers/plans/2026-06-01-implementation-review-gate-protocol.md` | `c8726acc5457ab87fb8f26c2e439e1d1b98df0255af78e4d6e8d9636dc518478` |

View File

@ -0,0 +1,99 @@
# 2026-06-01 S1 Task 6 权限边界门禁
## 结论
S1 Task 6 `Implement Auth And RBAC Boundary` 已完成实现,并通过 mandatory fresh review gate
- spec compliance reviewPASS无 Critical / Important / Minor finding。
- quality / feasibility reviewPASS无 Critical / Important剩余 3 个 Minor 风险,不阻塞进入 Task 7。
可以进入 S1 Task 7。
## 实现范围
Task 6 delta 包含:
- `apps/api/src/app.module.ts`
- `apps/api/src/modules/auth/index.ts`
- `apps/api/src/modules/auth/auth.spec.ts`
- `apps/api/src/modules/rbac/index.ts`
- `apps/api/src/modules/rbac/rbac.spec.ts`
未实现:
- project / version / asset / job / audit endpoints。
- queue / worker / storage / audit service。
- Prisma schema / migration 变更。
- 外部 auth provider、OAuth、复杂 JWT。
- `scripts/check-s1-scope.mjs`
## 关键实现事实
- `AuthModule` 已注册到 `AppModule`
- `AuthController` 暴露:
- `POST /auth/login`
- `POST /auth/logout`
- `GET /me`
- S1 auth 使用本地内存 session store 和 deterministic seed users满足 local MVP / test boundary不作为生产多实例认证方案。
- token 使用 Node `crypto.randomBytes` 生成,不是固定 token。
- `AuthGuard` 在服务端解析 Bearer token并对缺失 / 未知 token fail closed。
- logout 会删除当前 token同 token 后续 `GET /me` 失败。
- RBAC policy 覆盖:
- `admin`
- `operator`
- `creator`
- `player`
- `anonymous`
- `creatorOwnsResource` 作为 resource-like ownership predicate不调用 project/version API。
- `canReadReviewFoundationData` 允许 operator/admin 读审核基础数据类别,不创建管理 CRUD。
- `assertDirectObjectAccess` 对 anonymous / 非 owner fail closed返回 403-style error。
- Task 6 没有提前创建 project/version/asset/job/audit HTTP endpointendpoint-level RBAC 留给 Task 8。
## Review Gate
| Gate | Agent ID | Result | Blocking findings |
| --- | --- | --- | --- |
| implementation | `019e8234-9c8d-7280-85d3-f817b9796ebe` | DONE | 无 |
| spec compliance review | `019e823f-89e9-7380-b2fb-ce259be9e8e7` | PASS | 无 |
| quality / feasibility review | `019e8245-5205-7ec3-b37b-4b1e686c17f8` | PASS | 无 Critical / Important3 个 Minor |
## Controller Verification
已运行:
```bash
pnpm --filter @huijing/api test -- auth rbac
pnpm --filter @huijing/api lint
pnpm --filter @huijing/api typecheck
pnpm --filter @huijing/api build
find apps packages -maxdepth 4 \( -name dist -o -name .next -o -name tsconfig.tsbuildinfo -o -name coverage \) -print
```
结果:
- API auth/RBAC testsPASS6 files / 27 tests。
- API lintPASS。
- API typecheckPASS。
- API buildPASS。
- Build outputs 已清理;`apps/**` / `packages/**` 下无 `dist``.next``coverage``tsconfig.tsbuildinfo` 残留。
## Residual Non-blocking Notes
- Quality reviewer 记录 MinorAuthGuard protected-route test 只覆盖 anonymous rejection尚未覆盖 authenticated token success path 和 `request.actor` 注入。Task 8 接真实 endpoint 前建议补一条 Bearer token 成功访问用例。
- Quality reviewer 记录 Minorseed users 位于 runtime code符合 local MVP但后续真实认证前应加 local-only / env-gated provider避免演示环境误认为生产认证。
- Quality reviewer 记录 Minor`creatorOwnsResource` 接受 `ownerId` / `creatorId` / `actorId`,后续 Task 8 endpoint 接入时应优先使用明确 owner field只有 job 这类 `actorId` 语义等于请求方资源时才使用 `actorId`
- API tests 仍输出既有 `pg` deprecation warning命令 exit 0。升级 `pg@9` 前需要复查 Prisma adapter / 并发测试方式。
- 当前 git status 仍包含 `.idea/``docs-design/` 和大量 untracked workspace surface这是前置工作面Task 6 未回退或清理这些内容。
## SHA-256
| Path | SHA-256 |
| --- | --- |
| `apps/api/src/app.module.ts` | `6c4072e3f6e65e6759e9b55ee6ba5acc43e0dc4b3a2351fafdf5fd0fc80fa1b8` |
| `apps/api/src/modules/auth/index.ts` | `63597418c1e2402957f448dfbfaa221ab87a93d550ad8c3361b04ca62420c160` |
| `apps/api/src/modules/auth/auth.spec.ts` | `69a7e7df869116ffb0d6eff51fa8503c791867aea9f6358d6640f1120a522ebc` |
| `apps/api/src/modules/rbac/index.ts` | `cf923d0f787dd5ea73ac3e9408f757e30c80be9fe84ef0f83ccd9972b5c984b1` |
| `apps/api/src/modules/rbac/rbac.spec.ts` | `8f58871b6ce1ed8b12999215953a4129de62edebd1b1342e5c20a128ddc84bac` |
| `docs/superpowers/plans/2026-05-31-mvp-S1-app-foundation.md` | `868dc63fee64e254cf267d63db472aa46abf15977f1999785cfaea6fa1956a0a` |
| `docs/superpowers/specs/2026-05-31-mvp-S1-app-foundation-design.md` | `f514de72c36c5fd1ee6f3e87999b911f33d392549dbb76c6ee5d538dac774cdb` |
| `docs/superpowers/plans/2026-06-01-implementation-review-gate-protocol.md` | `c8726acc5457ab87fb8f26c2e439e1d1b98df0255af78e4d6e8d9636dc518478` |

View File

@ -0,0 +1,168 @@
# 2026-06-01 S1 Task 7 队列存储审计门禁
## 结论
S1 Task 7 `Implement Queue, Worker, Storage, And Audit Boundaries` 已完成实现,并通过 mandatory fresh review gate
- spec compliance review最终 PASS无 Critical / Important。
- quality / feasibility review最终 PASS无 Critical / Important。
可以进入 S1 Task 8。
## 实现范围
Task 7 delta 包含:
- `apps/api/prisma/schema.prisma`
- `apps/api/prisma/migrations/20260601165239_s1_task7_job_lease_fields/migration.sql`
- `apps/api/src/generated/prisma/**`
- `apps/api/src/modules/jobs/index.ts`
- `apps/api/src/modules/jobs/jobs.spec.ts`
- `apps/api/src/modules/queue/index.ts`
- `apps/api/src/modules/queue/queue.spec.ts`
- `apps/api/src/modules/storage/index.ts`
- `apps/api/src/modules/storage/storage.spec.ts`
- `apps/api/src/modules/audit/index.ts`
- `apps/api/src/modules/audit/audit.spec.ts`
- `apps/api/package.json`
- `apps/worker/package.json`
- `apps/worker/src/index.ts`
- `apps/worker/src/worker.smoke.spec.ts`
- `pnpm-lock.yaml`
未实现:
- Project / version / asset / job / audit HTTP endpoints。
- Audit admin UI。
- Real build / conversion / generation worker business。
- Cloud storage SDK。
- `scripts/check-s1-scope.mjs`
## 关键实现事实
- `Job` 新增持久运行态字段:
- `leaseToken`
- `leasedBy`
- `leaseExpiresAt`
- `lockVersion`
- Task 7 migration 增加:
- Job lease fields。
- `Job_status_nextRetryAt_createdAt_idx`
- `Job_leaseToken_idx`
- `Job_running_lease_check`
- `Job_lock_version_check`
- `JobExecutionStore` 是 API-owned Job 状态写入口,覆盖:
- `enqueueScopedJob`
- `claimNext`
- `markSucceeded`
- `markFailedOrRetry`
- `markTimedOut`
- `cancelQueued`
- `enqueueScopedJob` 必须使用 root Prisma client如果用外层 transaction client 调用,会抛 `JOB_ENQUEUE_REQUIRES_ROOT_CLIENT`,并且不会执行 `mutateTarget`、不会 enqueue、不会持久化 Job。
- 同 scope/idempotencyKey 并发请求只有 winner 会执行 `mutateTarget`
- `queue.enqueue` 已移出 DB transaction在 Job commit 后执行。
- `queue.enqueue` 失败后Job 会进入 `pending_retry`,写入 `QUEUE_ENQUEUE_FAILED``nextRetryAt`,避免保持普通 `queued`
- `markTimedOut` 同时支持 persisted `timeoutAt <= now``leaseExpiresAt <= now`
- `QueueAdapter` 包含 `enqueue(job)``process(handler)``close()`
- `InMemoryQueueAdapter` 用于测试 / explicit local fallback。
- `BullMqQueueAdapter` 在 Redis 不可用时显式返回 `QUEUE_UNAVAILABLE`,不静默 fallback 到 memory。
- Worker 仍是 S1 no-op smoke不 import Prisma不写 DB不更新 guarded state。
- Local storage adapter
- 使用 `S1_STORAGE_ROOT` root isolation。
- 生成 owner/project scoped asset keys。
- 拒绝 path traversal、absolute path、encoded separators、NUL bytes。
- 逐级 `lstat` / `realpath` 拒绝 symlink escape拒绝后不会在 root 外创建目录或文件。
- 校验 MIME、size、sha256 checksum。
- 失败路径清理临时文件。
- Audit service
- 只提供 append create path。
- service-layer update/delete attempt 被拒绝。
- direct Prisma auditLog update/delete 仍由 DB trigger 拒绝。
- static scan 会发现 S1 app code 中的 `auditLog.update/delete`
- high-risk blocked facts 可写 audit不变更 target state。
- Task 7 没有引入 Task 8 HTTP endpoints当前生产 controller 仍只有 health 和 auth。
## Review Gate
| Gate | Agent ID | Result | Blocking findings |
| --- | --- | --- | --- |
| implementation | `019e8252-50f2-7311-8ddd-235f17d52a31` | DONE_WITH_CONCERNS | BullMQ happy path 未测;既有 pg warning |
| spec compliance initial | `019e8270-5e5e-72e3-8d5c-ca9745de85bc` | FAIL | storage symlink escape 先污染 root 外目录Job timeout 未使用 persisted `timeoutAt` |
| implementation fix 1 | `019e8252-50f2-7311-8ddd-235f17d52a31` | DONE | 修复 storage / timeout findings |
| spec compliance re-review | `019e828b-3b2c-78d3-baa1-e5989d5191b1` | PASS | 无 Critical / Important2 个 Minor |
| quality / feasibility initial | `019e8297-bf65-7302-9321-a7dded6147fd` | FAIL | duplicate idempotency 可重复 `mutateTarget``queue.enqueue` 在 DB transaction 内 |
| implementation fix 2 | `019e8252-50f2-7311-8ddd-235f17d52a31` | DONE | 修复 winner-only mutation 和 post-commit enqueue |
| spec compliance safety recheck | `019e82a9-1aeb-74d3-ba0c-919e788dba34` | PASS | 无 |
| quality / feasibility re-review | `019e82b2-e49e-7b60-89b1-db47760b1551` | FAIL | 外层 transaction client 仍可导致 queue before outer commit |
| implementation fix 3 | `019e8252-50f2-7311-8ddd-235f17d52a31` | DONE | `enqueueScopedJob` 要求 root Prisma client |
| spec compliance safety recheck 2 | `019e82cc-459e-7d90-977f-c06894022a28` | PASS | 无 |
| quality / feasibility re-review 2 | `019e82f2-2382-7dc0-9cc3-8912b42c07ef` | PASS | 无 Critical / Important |
## Controller Verification
已运行:
```bash
pnpm --filter @huijing/api test -- jobs queue storage audit
pnpm --filter @huijing/worker test
pnpm --filter @huijing/api lint
pnpm --filter @huijing/api typecheck
pnpm --filter @huijing/worker lint
pnpm --filter @huijing/worker typecheck
pnpm --filter @huijing/api exec prisma validate --schema prisma/schema.prisma
pnpm --filter @huijing/api exec prisma migrate status --schema prisma/schema.prisma
pnpm --filter @huijing/api exec prisma generate --schema prisma/schema.prisma
pnpm --filter @huijing/api build
pnpm --filter @huijing/worker build
docker compose -f infra/docker-compose.dev.yml ps
find apps packages -maxdepth 4 \( -name dist -o -name .next -o -name tsconfig.tsbuildinfo -o -name coverage \) -print
```
结果:
- API jobs / queue / storage / audit testsPASS10 files / 53 tests。
- Worker testsPASS1 file / 2 tests。
- API lint / typecheck / buildPASS。
- Worker lint / typecheck / buildPASS。
- Prisma validatePASS。
- Prisma migrate status3 migrations founddatabase schema up to date。
- Prisma generatePASS。
- Docker
- `infra-postgres-1` healthy on `5432`
- `infra-redis-1` healthy on `6379`
- Build outputs 已清理;`apps/**` / `packages/**` 下无 `dist``.next``coverage``tsconfig.tsbuildinfo` 残留。
## Residual Non-blocking Notes
- `pnpm check:s1-scope` 仍失败,因为 `scripts/check-s1-scope.mjs` 不存在;这是 Task 9 交付项,不是 Task 7 blocker。
- Storage cleanup 测试仍主要覆盖 pre-write failurespec re-review 记录 Minor可后续补一个写入 temp 后让 rename 失败的测试,断言 `.upload-*` 不残留。
- Worker package 未声明 dependency matrix 中的 `bullmq``ioredis``zod``@huijing/shared-contracts`;当前 queue runtime 依赖在 API packageworker 保持 no-op smoke。后续若 worker 真正消费 queue再补 worker runtime dependencies。
- BullMQ worker runtime error signal 目前较弱;后续真实 worker 接入时应提供 `onError` / logger / status hook。
- Storage symlink / realpath 检查不是原子 nofollow 策略S1 本地单进程 root 下可接受。后续上传 API 接入前可补目录替换并发测试或更强 nofollow/open/rename 校验。
- `mutateTarget` 当前在 enqueue 成功后执行S1 worker 仍是 no-op。后续真实 worker 接入时,需要明确 worker 只以 committed job / DB claim 为准,不把 target mutation 当作已完成业务前置条件。
- `pending_retry` recovery 当前在 `JobExecutionStore.claimNext` 层清晰,但还没有真实 retry scheduler / DB polling worker 端到端链路;符合 S1 no-op worker 范围。
- API tests 仍输出既有 `pg` deprecation warning命令 exit 0。升级 `pg@9` 前需要复查 Prisma adapter / 并发测试方式。
- 当前 git status 仍包含 `.idea/``docs-design/` 和大量 untracked workspace surface这是前置工作面Task 7 未回退或清理这些内容。
## SHA-256
| Path | SHA-256 |
| --- | --- |
| `apps/api/prisma/schema.prisma` | `e0a536208fe789d0c83ca89c081381a906d8e8520f63625335816c7df6b82c06` |
| `apps/api/prisma/migrations/20260601165239_s1_task7_job_lease_fields/migration.sql` | `389d32966131c98520493bac8ee6938217275133a6c4e5d5fd4083616936277a` |
| `apps/api/src/modules/jobs/index.ts` | `b2e4dd3709a053537e24884aa40f77d140d446a5df66d8f746339d760a1d3175` |
| `apps/api/src/modules/jobs/jobs.spec.ts` | `c236eb5d78a68d111e68c328af4b26ec8cebae02aa85ddaa063020c02eef2d29` |
| `apps/api/src/modules/queue/index.ts` | `de86c64863bf6d9ffd4c963b70d24f3e8478f80b41440f2cc4fed7c8c4a4c6c4` |
| `apps/api/src/modules/queue/queue.spec.ts` | `9eb1cc2fb1052e19f3f2d38c6a04193bb4da6b043aa015205bc7b16605bb44ce` |
| `apps/api/src/modules/storage/index.ts` | `efba0aa32fae28b0b21de6c842ef8ee19274a6c77f27ffcbcdbb2fbd7aede34b` |
| `apps/api/src/modules/storage/storage.spec.ts` | `117c21d29afbba42213b0bf90b7b781baf5e3e3ae6a4df21d08750233e07241c` |
| `apps/api/src/modules/audit/index.ts` | `42382d6d233d58e54b4535499d43f94f2a2ef561264a5ee3ba7b8da6c96c93bb` |
| `apps/api/src/modules/audit/audit.spec.ts` | `b5f8bf06014c29bff2604369fcc0f9a9ed0c0a896b15c591f8287971b705e458` |
| `apps/api/package.json` | `5ac20498af83cd24527da598bfee8e28dda9e6a2a93012a29759b135c2d98b63` |
| `apps/worker/package.json` | `5094f400f8a169a4375c392802402454fcc333576a026d491d58b5f79a8c1cf2` |
| `apps/worker/src/index.ts` | `23f99cb50abbd66599d10e6851473f9c03ae47fc24f3cd66c05902fcf134e980` |
| `apps/worker/src/worker.smoke.spec.ts` | `510ded34b3204fcb466937a9298e69eba0ce18c644fa50e55b6668b499166510` |
| `pnpm-lock.yaml` | `293cd73e9a8f692e6444152a983664c3520f1f8d1d956151b453a213b79069eb` |
| `docs/superpowers/plans/2026-05-31-mvp-S1-app-foundation.md` | `868dc63fee64e254cf267d63db472aa46abf15977f1999785cfaea6fa1956a0a` |
| `docs/superpowers/specs/2026-05-31-mvp-S1-app-foundation-design.md` | `f514de72c36c5fd1ee6f3e87999b911f33d392549dbb76c6ee5d538dac774cdb` |
| `docs/superpowers/plans/2026-06-01-implementation-review-gate-protocol.md` | `c8726acc5457ab87fb8f26c2e439e1d1b98df0255af78e4d6e8d9636dc518478` |

View File

@ -0,0 +1,158 @@
# 2026-06-01 S1 Task 8 API 门禁
## 结论
S1 Task 8 `Implement Project, Version, Asset, Job, And Audit APIs` 已完成实现,并通过 mandatory fresh review gate
- spec compliance review最终 PASS无 Critical / Important。
- quality / feasibility review最终 PASS无 Critical / Important。
可以进入 S1 Task 9。Task 9 之前仍不能运行 `pnpm check:s1-scope` 作为通过门禁,因为 `scripts/check-s1-scope.mjs` 是 Task 9 交付项。
## 实现范围
Task 8 delta 包含:
- `apps/api/src/app.module.ts`
- `apps/api/src/main.ts`
- `apps/api/src/modules/projects/index.ts`
- `apps/api/src/modules/projects/api-runtime.ts`
- `apps/api/src/modules/projects/projects.api.spec.ts`
- `apps/api/src/modules/projects/api-runtime.spec.ts`
- `apps/api/src/modules/assets/index.ts`
- `apps/api/src/modules/assets/assets.api.spec.ts`
- `apps/api/src/modules/jobs/index.ts`
- `apps/api/src/modules/jobs/jobs.spec.ts`
- `apps/api/src/modules/jobs/jobs.api.spec.ts`
- `apps/api/src/modules/audit/index.ts`
- `apps/api/src/modules/audit/audit.api.spec.ts`
未实现:
- Task 9 `scripts/check-s1-scope.mjs`
- S2+ creation agent、GameIR editor、runtime、conversion、publish/feed/telemetry API。
- 新 schema / migration。
- audit admin UI。
## 关键实现事实
- S1 HTTP API surface 包含:
- `POST /auth/login`
- `POST /auth/logout`
- `GET /me`
- `POST /projects`
- `GET /projects`
- `GET /projects/:projectId`
- `POST /projects/:projectId/versions`
- `GET /projects/:projectId/versions`
- `POST /assets/presign`
- `GET /jobs/:jobId`
- `GET /audit-logs`
- `POST /projects` 仅 creator 可创建operator/admin/player/anonymous 会被拒绝且不产生 project/audit mutation。
- creator 只能读写自有 project/version/asset/joboperator/admin 只在列出的 foundation read endpoints 读取审核基础数据。
- draft version 创建通过 `StateTransitionService.createDraftVersion` 写入 `draft`,没有调用无关 S0 review transition。
- asset presign 复用 `LocalStorageAdapter.planObject()`,保持 Task 7 storage boundary。
- queue provider 由 `createQueueAdapterFromEnv()` 决定:
- `QUEUE_ADAPTER=memory` 才使用 `InMemoryQueueAdapter`
- `QUEUE_ADAPTER=bullmq``REDIS_URL` 存在时使用 `BullMqQueueAdapter`
- 缺失 / unsupported `QUEUE_ADAPTER` 或 bullmq 缺 `REDIS_URL` 时 fail closed 为 `QUEUE_UNAVAILABLE`,不静默 fallback memory。
- `ManagedQueueAdapter` 实现 `onModuleDestroy()`Nest `app.close()` 会关闭底层 queue adapter。
- `JobExecutionStore.enqueueScopedJob()` 返回 `{ job, created, enqueued }`,用于区分 create winner、duplicate 和 enqueue success。
- S1 generic job runtime 不暴露 `mutateTarget``onJobCreated` pre-enqueue DB hook避免 queue failure 后留下半成品 target。
- `queue.enqueue` 仍在 DB commit 后执行。
- `job.enqueued` 只在 queue side effect 成功后追加。
- queue failure 后 Job 进入 `pending_retry`,写入 `QUEUE_ENQUEUE_FAILED`,并追加 `job.enqueue_failed`;不会追加 `job.enqueued`,也不会修改 target。
- 全局异常过滤器稳定映射 Prisma `P2002/P2025``JobExecutionError``QueueUnavailableError``StateTransitionDomainError`,并保持 `{ code, message, requestId, details }` 响应形状。
- Task8 API specs 按 runId 清理自己的 audit/project/job/asset 测试数据;`task8-*` leftover 已验证为 0。
## Review Gate
| Gate | Agent ID | Result | Blocking findings |
| --- | --- | --- | --- |
| implementation | `019e82fc-f61d-73e0-a9a6-b5895a49db4d` | DONE | 初始实现通过后进入 review |
| spec compliance initial | `019e830f-0800-77b3-8108-942901a4ae7f` | FAIL | `POST /projects` 允许非 creatorasset presign 未复用 storage boundary |
| implementation fix 1 | `019e82fc-f61d-73e0-a9a6-b5895a49db4d` | DONE | 修复 creator-only project creation 与 storage planner delegation |
| spec compliance re-review | `019e8319-5514-7ba3-8e9b-29c9799fa789` | PASS | 无 |
| quality / feasibility initial | `019e831d-c3d9-7253-bb4e-f777ebfd7fb6` | FAIL | queue provider 忽略 envjob audit 幂等 / queue failure 边界;异常映射不足 |
| implementation fix 2 | `019e82fc-f61d-73e0-a9a6-b5895a49db4d` | DONE | 增加 queue env factory、异常映射、job failure audit |
| spec compliance safety recheck | `019e8335-b8c9-7d10-8292-4117256468b2` | PASS | 无 |
| quality / feasibility re-review | `019e833d-5ccf-70c3-aa3c-662f6af21b44` | FAIL | `QUEUE_ADAPTER` 缺省 fallback memoryenqueue 后 target mutationqueue adapter lifecycle |
| implementation fix 3 | `019e82fc-f61d-73e0-a9a6-b5895a49db4d` | DONE | 显式 queue adapter、ManagedQueueAdapter、移动 target mutation |
| spec compliance recheck 2 | `019e834f-befd-7903-83a9-659f348bcf2a` | FAIL | queue failure 后 target mutation 已提交 |
| implementation fix 4 | `019e82fc-f61d-73e0-a9a6-b5895a49db4d` | DONE | 移除 generic `mutateTarget` |
| spec compliance recheck 3 | `019e835b-fbb1-7951-98f3-d691c4806454` | PASS | 无 |
| quality / feasibility re-review 2 | `019e8361-4709-7bc3-b4de-ad504dbf43aa` | FAIL | `job.enqueued` 早于 queue success`onJobCreated` pre-enqueue DB hook 风险 |
| implementation fix 5 | `019e82fc-f61d-73e0-a9a6-b5895a49db4d` | DONE | 移除 `onJobCreated``job.enqueued` 移到 queue success 后 |
| spec compliance recheck 4 | `019e8374-0515-7832-b402-9882aa8076fc` | FAIL | Job tests 并行污染,`claimNext()` 可领取其他 spec fixture |
| implementation fix 6 | `019e82fc-f61d-73e0-a9a6-b5895a49db4d` | DONE | 修复 Job tests 隔离,不改 runtime `claimNext()` 语义 |
| spec compliance recheck 5 | `019e8389-c240-7d41-9416-ab6eeac2ed1b` | PASS | 无 |
| quality / feasibility re-review 3 | `019e839a-aaef-7ca3-a64e-bd8a00461631` | FAIL | 测试隔离会改写非本 runId JobTask8 audit leftovers |
| implementation fix 7 | `019e82fc-f61d-73e0-a9a6-b5895a49db4d` | DONE | advisory lock 隔离;只清理本 runId / task8 测试数据leftover 清零 |
| spec compliance final | `019e83cb-4f97-7e93-b583-1d7a8f5afdbf` | PASS | 无 |
| quality / feasibility final attempt | `019e83d2-a012-77c1-87e7-d250193c5405` | TIMEOUT | 超时关闭,无结果 |
| quality / feasibility final | `019e859d-3c8f-7b02-99b2-51c632f41469` | PASS | 无 Critical / Important2 个 Minor |
## Controller Verification
已运行:
```bash
pnpm --filter @huijing/api test -- projects assets jobs audit
pnpm --filter @huijing/api test -- auth rbac jobs queue storage audit state-transition
pnpm --filter @huijing/api lint
pnpm --filter @huijing/api typecheck
pnpm --filter @huijing/api build
rm -rf apps/api/dist
pnpm --filter @huijing/api exec prisma validate --schema prisma/schema.prisma
pnpm --filter @huijing/api exec prisma migrate status --schema prisma/schema.prisma
pnpm check:workspace-scripts
docker compose -f infra/docker-compose.dev.yml ps
find apps packages -maxdepth 5 \( -name dist -o -name coverage -o -name .next -o -name tsconfig.tsbuildinfo \) -print
```
结果:
- API Task8 testsPASS15 files / 82 tests。
- API auth/rbac/jobs/queue/storage/audit/state-transition testsPASS15 files / 82 tests。
- API lint / typecheck / buildPASS。
- Prisma validatePASS。
- Prisma migrate status3 migrations founddatabase schema up to date。
- `pnpm check:workspace-scripts`PASS。
- Docker
- `infra-postgres-1` healthy on `5432`
- `infra-redis-1` healthy on `6379`
- Build outputs 已清理;`apps/**` / `packages/**` 下无 `dist``.next``coverage``tsconfig.tsbuildinfo` 残留。
- Task8 DB leftover 查询:
- `audit_task8_leftovers=0`
- `job_task8_leftovers=0`
- `project_task8_leftovers=0`
## Residual Non-blocking Notes
- `pnpm check:s1-scope` 仍失败,因为 `scripts/check-s1-scope.mjs` 不存在;这是 Task 9 交付项,不是 Task 8 blocker。
- `GET /audit-logs` 当前返回全局 latest 200。官方单进程 / 顺序门禁通过;两个完整 Vitest 进程同时打同一个 dev DB 时audit API 测试可能被其他进程新 audit 挤出 latest 200。Quality final 评定为 Minor不阻塞 Task8。后续建议使用 per-process DB/schema或禁止完整 Vitest 并发共享 dev DB。
- API tests 仍输出既有 `pg` deprecation warning命令 exit 0。升级 `pg@9` 前需要复查 Prisma adapter / 并发测试方式。
- Task7 memory 中关于 `mutateTarget` 的 residual note 已过时Task8 最新事实是 runtime 不再暴露 `mutateTarget` / `onJobCreated` pre-enqueue hook。
- 当前 git status 仍包含 `.idea/``docs-design/` 和大量 untracked workspace surface这是前置工作面Task8 未回退或清理这些内容。
## SHA-256
| Path | SHA-256 |
| --- | --- |
| `apps/api/src/app.module.ts` | `8d2b322de359888a470ba863f6cee488a72b8cb6261b66fd9bf14d6f17d073fe` |
| `apps/api/src/main.ts` | `fcefc7c057d211b11f890477a9a0017ea4a3d4ed41c34e93dc1be271f8212d58` |
| `apps/api/src/modules/projects/index.ts` | `fb33104864df22c829eb5beeb1015890f0fd71900f6e32230531fc7ea50d2233` |
| `apps/api/src/modules/projects/api-runtime.ts` | `a4ebdcbba9e25f1a49fc4cc0156147daf8836fec3df1a522b89e5c3d3305713b` |
| `apps/api/src/modules/projects/projects.api.spec.ts` | `c941170e73bf293926283147aa5fe384c515c742035ab7b77181742053f87c09` |
| `apps/api/src/modules/projects/api-runtime.spec.ts` | `96e1e1ea4b47363c4af6238a944e10136033b321c1206cc3fe581a76a4709d3b` |
| `apps/api/src/modules/assets/index.ts` | `df8236d27715f98e2cea9a2abbd40677afc2863293e09682b80bc83ec766092c` |
| `apps/api/src/modules/assets/assets.api.spec.ts` | `4b260fe97ce42cf27dca2aba0c8abc0573091d966a7692d5f504e54e28a8b8a3` |
| `apps/api/src/modules/jobs/index.ts` | `300563b079c80edf2ce64c4035f12446b0b7edb73ba56809f1938128a56f391a` |
| `apps/api/src/modules/jobs/jobs.spec.ts` | `8188d8d52fa3da5b354b89f01d723c87758010356ddbe5abc0f5ae644cd63fa9` |
| `apps/api/src/modules/jobs/jobs.api.spec.ts` | `15275a7298eff7092e2888986c782c070edd29afa66f3100c4320f1ef8222331` |
| `apps/api/src/modules/audit/index.ts` | `d815b6e143f39ccf9525b4cd931f13d003299ba36063946861a546eb3ec97c18` |
| `apps/api/src/modules/audit/audit.api.spec.ts` | `262eb16e9423ab82119aaf2a4d2115c06669d2f518205236128ab6fce8a64640` |
| `apps/api/prisma/schema.prisma` | `e0a536208fe789d0c83ca89c081381a906d8e8520f63625335816c7df6b82c06` |
| `docs/superpowers/plans/2026-05-31-mvp-S1-app-foundation.md` | `868dc63fee64e254cf267d63db472aa46abf15977f1999785cfaea6fa1956a0a` |
| `docs/superpowers/specs/2026-05-31-mvp-S1-app-foundation-design.md` | `f514de72c36c5fd1ee6f3e87999b911f33d392549dbb76c6ee5d538dac774cdb` |
| `docs/superpowers/plans/2026-06-01-implementation-review-gate-protocol.md` | `c8726acc5457ab87fb8f26c2e439e1d1b98df0255af78e4d6e8d9636dc518478` |

View File

@ -0,0 +1,54 @@
# 2026-06-01 S1 规格计划门禁
## 结论
S1 App Foundation spec/plan review gate 已通过。四个 fresh reviewer 均为 `gpt-5.5 + xhigh`,本轮无 Critical / Important finding可以进入 S1 Task 1 实现。
## Reviewers
| Role | Reviewer ID | Gate | Blocking findings |
| --- | --- | --- | --- |
| coherence/scope | `019e80c3-ff72-7d10-bc0b-58bea7bccf9e` | PASS | 无 |
| feasibility | `019e80c4-0003-7a43-926b-ef25efdd7308` | PASS | 无 |
| security | `019e80c4-023c-7373-8548-adeef0dee226` | PASS | 无 |
| adversarial | `019e80c4-0350-7b72-b115-84e71c0cebe5` | PASS | 无 |
## Reviewed Files And SHA-256
| Path | SHA-256 |
| --- | --- |
| `docs/superpowers/specs/2026-05-31-mvp-S1-app-foundation-design.md` | `f514de72c36c5fd1ee6f3e87999b911f33d392549dbb76c6ee5d538dac774cdb` |
| `docs/superpowers/plans/2026-05-31-mvp-S1-app-foundation.md` | `25591c2ec52cde6651dca8fbadbdb6ad13fad051b0319e366f8393c6d1cef71a` |
| `docs/superpowers/plans/2026-06-01-implementation-review-gate-protocol.md` | `c8726acc5457ab87fb8f26c2e439e1d1b98df0255af78e4d6e8d9636dc518478` |
| `harness/scripts/validate-harness.mjs` | `4e40acad9383548a24389c060c9ed71d134b8b2285147ac2bc2aad14d40808da` |
| `harness/schemas/main-creation-agent-session.schema.json` | `6748fefc0fdc49fa9812f63ef66df5b44b2bb025ef3b2459efdcec2f177b4fa2` |
| `harness/schemas/agent-task.schema.json` | `b26d91750c3651690b9b3f1567255960fc326d23e24cb48386b4fa99b8b46ba2` |
| `harness/schemas/lifecycle-event.schema.json` | `b1a4737a25516a04eb8a2ddc6951766219b420a6eb735c8d6bdfb81fb70b789b` |
| `harness/lifecycle.yaml` | `6e2400aa678ea9feed957ebea56a4f98d50508e03c396dc943e42e4cf802e79e` |
| `docs/memorys/2026-06-01-S0阶段通过门禁.md` | `dce65992dd97fdfd6d2ce1474a180fe95ca0f5635c0b94038a594a1f48b406f5` |
## Worktree State
`git status --short` 显示 `docs/``harness/` 仍为 untracked surface`git ls-files --stage` 对上述 reviewed docs/harness 文件无输出,因此这些 reviewed files 当前均为 untracked。`.idea/``docs-design/` 存在既有 staged/modified 内容S1 gate 未修改或回滚这些文件。
## Fixed Blocking Findings
本轮通过前已修复以下阻塞类问题:
- S1 spec/plan 起始事实改为已有 S0 `harness/`S1 固定消费 `harness/scripts/validate-harness.mjs`
- `MainCreationAgentSession` / `AgentTask` 对齐 S0 schema并要求 exact DTO projection 与 S0 CLI 校验。
- `ReviewRecord` / `LifecycleEvent` 补齐 S0 lifecycle transition 证据字段。
- Task 4 验收收窄为 DB schema / raw SQL / direct Prisma不再提前要求 Task 7/8 的 service/API/concurrency。
- Job target 增加 DB 级 same-project invariantJob 状态由 API-owned `JobExecutionStore` 单写入口管理。
- Worker 明确不 import Prisma、不直写 DB、不写 guarded state。
- Guarded state / audit append-only 通过 DB trigger、static scanner、direct Prisma negative tests 约束。
- Final verification 纳入 Docker Postgres/Redis、migration deploy/generate、real DB/queue integration。
- Review gate protocol 要求 evidence note 记录 reviewer ids、roles、SHA-256、`git status --short` 和 tracked/untracked 状态。
## Non-blocking Implementation Notes
- `review_rejected` 可用构造 payload 或新增 S0 fixture 验证;不要误以为当前只存在 `review_approved` fixture 就跳过 rejected path。
- `LifecycleEvent --contract` 只能证明必填字段存在,审核状态的关键约束仍必须用 `--transition review_approved/review_rejected` 验证。
- `JobExecutionStore` 实现要使用 `leaseToken``leasedBy``leaseExpiresAt``lockVersion` 或等价持久字段支撑 claim/lease/concurrency。
- Scope scanner 实现时应固化 denylist、path allowlist、正负 self-test fixture避免靠自然语言判断。
- Harness stdout/stderr bound、storage MIME allowlist 和 max size 必须给出具体上限review 时不接受无界或过宽实现。

View File

@ -0,0 +1,205 @@
# 2026-06-02 S1 Task 9 最终验证门禁
## 结论
S1 Task 9 `Final S1 Verification` 已完成,并通过 mandatory fresh review gate 与 S1 阶段最终 read-only review gate
- Task 9 scope scanner spec compliance review最终 PASS无 Critical / Important。
- Task 9 scope scanner quality / feasibility review最终 PASS无 Critical / Important。
- Task 9 full verificationPASS。
- S1 final read-only reviewPASS无 Critical / Important可以进入 S2。
## 实现范围
Task 9 delta 包含:
- `scripts/check-s1-scope.mjs`
- `apps/api/src/modules/jobs/index.ts`
Task 9 未实现:
- S2+ creation agent / 游戏需求确认 / draft 生成 / draft 修改。
- Web runtime、WebPlatformAdapter、小游戏转换、发布流、feed、analytics。
- 新业务 API、schema model 或 migration。
## 关键实现事实
- `scripts/check-s1-scope.mjs` 扫描 `apps/**``packages/**`,忽略 `node_modules``dist``.next``.vite``coverage`、generated Prisma。
- scanner 会拒绝 S2-S8 路径、路由、模型、符号泄漏,包括:
- `creation-agent/messages`
- `compile-game-ir`
- `web-runtime`
- `mini-game-conversion`
- `AIGameDesignDraft`
- `GameIRArtifact`
- `GameIR`
- `ConversionReport`
- `ValidationReport`
- `MiniGameProject`
- `MiniGameCodeConversion`
- `FeedItem`
- `GameDailyStats`
- `CreatorDailyStats`
- `/events/batch`
- `MainCreationAgentSession``AgentTask``ReviewRecord``LifecycleEvent` 只允许作为 S1 schema/state/audit anchor不允许作为业务实现泄漏。
- `packages/harness-client/**` 只在明确 `validateContract(...)` 或 harness CLI `--contract` 参数断言上下文放行 S0 contract 名称。
- `JobExecutionStore` 是唯一 Job runtime state writerworker 不 import Prisma/DB。
- scanner 会拒绝 Job runtime field 的 Prisma direct write、bracket write、optional chaining write、delegate alias write以及 raw SQL 写。
- scanner 会拒绝 `AuditLog` update/delete/upsert、raw SQL update/delete/truncate/drop/disable trigger/function tamper。
- scanner 会拒绝 guarded state `GameVersion` / `ReviewRecord` / `LifecycleEvent` 在 state-transition boundary 外直接写。
- scanner 会拒绝 runtime guard setter`set_config``SET LOCAL``app.state_transition_guard`
- raw SQL scanner 覆盖本轮 review 发现的绕过:
- `db.$executeRawUnsafe?.call(...)`
- `db.$executeRawUnsafe?.bind(db)(...)`
- `const execRaw = db.$executeRawUnsafe.bind(db); await execRaw(...)`
- `db. $executeRawUnsafe(...)`
- `db["$executeRawUnsafe"](...)`
- `db?.$executeRawUnsafe(...)`
- `db?.["$executeRawUnsafe"](...)`
- bracket / optional bracket 的 `call``bind`、bound alias
- no-semicolon / ASI bound alias
- dynamic raw SQL write identifier fail closeddynamic read-only `SELECT` 放行。
- `apps/api/src/modules/jobs/index.ts` 的 Task9 后续 lint 修复只是 `let cursor` -> `const cursor`,未改变 Job runtime 或 scanner 行为。
## Review Gate
| Gate | Agent ID | Result | Blocking findings |
| --- | --- | --- | --- |
| scanner implementation remediation | `019e87ab-d507-7bd0-ae38-5d537bbba64b` | DONE | 修复 Prisma optional chaining、delegate alias、raw optional/call/bind 基础覆盖 |
| scanner spec compliance | `019e87bb-5f8c-79c0-bdee-64bce4584492` | PASS | 无 |
| scanner quality / feasibility | `019e87c0-c594-7c61-8348-30ebe6332a30` | FAIL | raw SQL `?.call` / `?.bind` / bound alias 仍可绕过 |
| scanner implementation fix 1 | `019e87c7-6a70-72c0-add2-e3ae885fe774` | DONE | 修复 optional call/bind/bound alias |
| scanner spec compliance recheck 1 | `019e87d1-1965-74f3-9b22-c976be68d8ac` | PASS | 无 |
| scanner quality / feasibility recheck 1 | `019e87d5-9210-7611-b41d-162d50adb479` | FAIL | raw executor access 漏掉 dot whitespace、static bracket、optional receiver |
| scanner implementation fix 2 | `019e87e0-57e2-70b1-a272-4fa5604c8390` | DONE | 扩展 raw executor access matching |
| scanner spec compliance recheck 2 | `019e87e6-2508-7931-809b-7a5238233ca8` | FAIL | no-semicolon / ASI bound alias 漏报 |
| scanner implementation fix 3 | `019e87ea-a3f0-7273-9a02-877b378b5426` | DONE | 修复 no-semicolon / ASI bound alias |
| scanner spec compliance final | `019e8817-261a-70a3-af70-e358e55156ca` | PASS | 无 |
| scanner quality / feasibility final | `019e881e-d4ea-70b1-aa23-87febde47014` | PASS | 无 |
| lint implementation fix | `019e882a-e2ca-7093-b23e-4c1c603f14aa` | DONE | 修复 `prefer-const` |
| lint fix spec compliance | `019e8835-22cc-7782-82f6-abcc9e4e2699` | PASS | 无 |
| lint fix quality / feasibility | `019e8838-65a7-7d61-8b25-adfa4187208d` | PASS | 无 |
| S1 final read-only review | `019e883c-91a6-7542-84c1-d1c4464e831e` | PASS | 无 Critical / Important可以进入 S2 |
## Controller Verification
已串行运行:
```bash
pnpm install
pnpm check:workspace-scripts
export POSTGRES_PORT="${POSTGRES_PORT:-5432}"
export REDIS_PORT="${REDIS_PORT:-6379}"
export DATABASE_URL="postgresql://huijing:huijing@localhost:${POSTGRES_PORT}/huijing_dev?schema=public"
export REDIS_URL="redis://localhost:${REDIS_PORT}"
docker compose -f infra/docker-compose.dev.yml up -d --wait
docker compose -f infra/docker-compose.dev.yml exec -T postgres pg_isready -U huijing -d huijing_dev
docker compose -f infra/docker-compose.dev.yml exec -T redis redis-cli ping
pnpm --filter @huijing/api exec prisma migrate deploy --schema prisma/schema.prisma
pnpm --filter @huijing/api exec prisma generate --schema prisma/schema.prisma
pnpm lint
pnpm typecheck
QUEUE_ADAPTER=bullmq pnpm test
pnpm build
pnpm dev:smoke
pnpm check:s1-scope
```
结果:
- `pnpm install`PASS。
- 非阻塞提示pnpm `10.33.0 -> 11.5.0` update notice。
- 非阻塞 warning`msgpackr-extract@3.0.4` ignored build scripts。
- `pnpm check:workspace-scripts`PASS。
- Docker dev services
- `infra-postgres-1` healthy on `5432`
- `infra-redis-1` healthy on `6379`
- `pg_isready`PASS。
- `redis-cli ping``PONG`
- Prisma migrate deployPASS3 migrations foundNo pending migrations。
- Prisma generatePASSPrisma Client 7.8.0 generated。
- `pnpm lint`:初次失败于 `apps/api/src/modules/jobs/index.ts:703 prefer-const`fresh implementer 修复并通过两轮 review 后,重跑 PASS。
- `pnpm typecheck`PASS。
- `QUEUE_ADAPTER=bullmq pnpm test`PASS。
- worker1 file / 2 tests。
- shared-contracts1 file / 2 tests。
- harness-client1 file / 10 tests。
- web1 file / 2 tests。
- api15 files / 82 tests。
- 非阻塞 warning`pg@9` deprecation warning。
- `pnpm build`PASS。
- `pnpm dev:smoke`PASS。
- API smoke 中仍出现同一个 `pg@9` deprecation warning。
- `pnpm check:s1-scope`PASS`S1 scope check passed.`
控制器额外探针:
- optional raw call AuditLog deletescanner failed as expected。
- optional raw bind Job status updatescanner failed as expected。
- bound raw alias AuditLog deletescanner failed as expected。
- dot whitespace raw AuditLog deletescanner failed as expected。
- bracket raw AuditLog deletescanner failed as expected。
- optional receiver / optional bracket raw writescanner failed as expected。
- no-semicolon dot / optional dot / bracket / optional bracket bound aliasscanner failed as expected。
- dynamic read-only `SELECT`scanner passed as expected。
## Cleanup / Runtime State
- build/dev-smoke 产生的输出已清理:
- `apps/web/.next`
- `apps/api/dist`
- `apps/worker/dist`
- `packages/harness-client/dist`
- `packages/shared-contracts/dist`
- `apps/**` / `packages/**` 下无 `dist``.next` 构建输出残留。
- `/tmp` 下无 `huijing-s1-scope-*``huijing-s1-probe-*``huijing-audit-scan-*``huijing-controller-s1-scope-*` 探针目录残留。
- Docker dev services 保持运行且 healthy未在本轮关闭。
## Residual Non-blocking Notes
- 当前仓库大量 S1 workspace surface 仍是 untracked`git diff` 对这些文件不能代表实际变更范围。
- `.idea/``docs-design/` 和大量 untracked workspace surface 是前置工作面;本轮未回退或清理这些内容。
- `pg@9` deprecation warning 仍存在,但所有相关测试命令 exit 0升级 `pg@9` 前需要复查 Prisma adapter / 并发测试方式。
- `msgpackr-extract@3.0.4` ignored build scripts warning 仍存在;当前验证未受影响。
- Final reviewer 未重跑完整 `pnpm test/build`,避免重新写构建产物或扰动 dev DB其基于控制器 full verification 与只读 spot-check 给出 PASS。
## SHA-256
| Path | SHA-256 |
| --- | --- |
| `scripts/check-s1-scope.mjs` | `e47eee55977f28b1d711f1bc7f4e1a330c1d51291d87f6a1fd2a95d80aa738fc` |
| `apps/api/src/modules/jobs/index.ts` | `dac1176331d0adc6ba7c79f0f7788ef9efd147492d3f36d1155902784820ae32` |
| `apps/api/src/modules/assets/assets.api.spec.ts` | `815109ff2eb27cba10837af862702908c6ea06556b1ae4e75ed51a46fdaed6d0` |
| `apps/api/src/modules/audit/audit.api.spec.ts` | `efa91e6e71851bc5d8ddab0bf6b18e8c1dd4a433712529ef8d39cd877506098d` |
| `apps/api/src/modules/jobs/jobs.api.spec.ts` | `5358ac8a1bd9d66821b7c7cafd2de57a79ceeb4585b90ae9e9d06d366663fe90` |
| `apps/api/src/modules/projects/projects.api.spec.ts` | `1facfbac8e6ddb8d9bb3bed3faf69af050efafb3d7ff48c46d6a58a60d5aab32` |
| `docs/superpowers/plans/2026-05-31-mvp-S1-app-foundation.md` | `868dc63fee64e254cf267d63db472aa46abf15977f1999785cfaea6fa1956a0a` |
| `docs/superpowers/specs/2026-05-31-mvp-S1-app-foundation-design.md` | `f514de72c36c5fd1ee6f3e87999b911f33d392549dbb76c6ee5d538dac774cdb` |
| `docs/superpowers/plans/2026-06-01-implementation-review-gate-protocol.md` | `c8726acc5457ab87fb8f26c2e439e1d1b98df0255af78e4d6e8d9636dc518478` |
## Git Status Snapshot
`git status --short` 显示:
```text
A .idea/.gitignore
A .idea/games-development-ai.iml
A .idea/misc.xml
A .idea/modules.xml
A .idea/vcs.xml
A docs-design/*
AM docs-design/Deployment for AI.md
?? .gitignore
?? apps/
?? docs/
?? eslint.config.mjs
?? harness/
?? infra/
?? package.json
?? packages/
?? pnpm-lock.yaml
?? pnpm-workspace.yaml
?? scripts/
?? tsconfig.base.json
```

24
eslint.config.mjs Normal file
View File

@ -0,0 +1,24 @@
import js from "@eslint/js";
import tseslint from "typescript-eslint";
// 仅覆盖 S1 工作区源码;根脚本和未来特殊目录由各自配置单独处理。
export default tseslint.config(
{
ignores: [
"**/node_modules/**",
"**/dist/**",
"**/build/**",
"**/coverage/**",
"**/.next/**",
"**/.turbo/**"
]
},
{
files: ["apps/**/*.{ts,tsx}", "packages/**/*.{ts,tsx}"],
extends: [js.configs.recommended, tseslint.configs.recommended],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module"
}
}
);

View File

@ -0,0 +1,23 @@
services:
postgres:
image: postgres:15
environment:
POSTGRES_USER: huijing
POSTGRES_PASSWORD: huijing
POSTGRES_DB: huijing_dev
ports:
- "${POSTGRES_PORT:-5432}:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U huijing -d huijing_dev"]
interval: 5s
timeout: 3s
retries: 20
redis:
image: redis:7
ports:
- "${REDIS_PORT:-6379}:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 20

20
package.json Normal file
View File

@ -0,0 +1,20 @@
{
"private": true,
"packageManager": "pnpm@10.33.0",
"scripts": {
"lint": "pnpm check:workspace-scripts && pnpm -r lint",
"typecheck": "pnpm check:workspace-scripts && pnpm -r typecheck",
"test": "pnpm check:workspace-scripts && pnpm -r test",
"build": "pnpm check:workspace-scripts && pnpm -r build",
"dev": "pnpm -r --parallel --if-present dev",
"dev:smoke": "pnpm check:workspace-scripts && pnpm -r dev:smoke",
"check:workspace-scripts": "node scripts/check-workspace-scripts.mjs",
"check:s1-scope": "node scripts/check-s1-scope.mjs"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"eslint": "^10.4.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.0"
}
}

View File

@ -0,0 +1,26 @@
{
"name": "@huijing/harness-client",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./runtime": {
"types": "./src/internal.ts",
"default": "./src/internal.ts"
}
},
"scripts": {
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"build": "tsc -p tsconfig.json",
"dev:smoke": "vitest run"
},
"devDependencies": {
"@types/node": "^25.9.1",
"vitest": "^4.1.7"
}
}

View File

@ -0,0 +1,201 @@
import { execFile } from "node:child_process";
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import {
HARNESS_INVALID_OUTPUT,
HARNESS_TIMEOUT,
createHarnessClient,
type ChildProcessRunner
} from "./internal";
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const repoRoot = path.resolve(packageRoot, "../..");
const fixturePath = (...segments: string[]) => path.join(repoRoot, "harness", "fixtures", ...segments);
const realHarnessCli = path.join(repoRoot, "harness", "scripts", "validate-harness.mjs");
async function readFixture(...segments: string[]): Promise<unknown> {
return JSON.parse(await readFile(fixturePath(...segments), "utf8"));
}
async function expectMissing(file: string): Promise<void> {
await expect(stat(file)).rejects.toMatchObject({ code: "ENOENT" });
}
describe("@huijing/harness-client", () => {
const originalCwd = process.cwd();
afterEach(() => {
process.chdir(originalCwd);
});
it("通过真实 S0 fixture 调用 contract CLI并在执行后删除临时 JSON 输入文件", async () => {
let observedInput: string | null = null;
const runner: ChildProcessRunner = async (file, args, options) => {
expect(file).toBe("node");
expect(args.slice(0, 2)).toEqual([realHarnessCli, "--contract"]);
expect(args[2]).toBe("GameIR");
expect(args[3]).toBe("--input");
observedInput = args[4] ?? null;
expect(options.cwd).toBe(repoRoot);
expect(options.timeoutMs).toBe(5_000);
expect(options.maxOutputBytes).toBeGreaterThan(0);
expect(JSON.parse(await readFile(observedInput ?? "", "utf8"))).toEqual(
await readFixture("mvp", "valid", "simulation-game-ir-valid.json")
);
return new Promise((resolve) => {
execFile(file, args, { cwd: options.cwd }, (error, stdout, stderr) => {
resolve({
timedOut: false,
exitCode: typeof error?.code === "number" ? error.code : 0,
stdout,
stderr
});
});
});
};
const result = await createHarnessClient({ runner }).validateContract(
"GameIR",
await readFixture("mvp", "valid", "simulation-game-ir-valid.json")
);
expect(result).toEqual({ ok: true, reasonCode: null });
expect(observedInput).toBeTruthy();
await expectMissing(observedInput ?? "");
});
it("通过真实 S0 failing fixture 解析非零退出 stdout JSON并返回 fail-closed reasonCode", async () => {
const client = createHarnessClient();
await expect(
client.validateContract(
"ValidationReport",
await readFixture("mvp", "invalid", "game-logic-validation-report-web-only-sdk-invalid.json")
)
).resolves.toEqual({ ok: false, reasonCode: "WEB_ONLY_SDK_USAGE" });
});
it("调用 transition CLI使用真实 S0 review_approved fixture", async () => {
const client = createHarnessClient();
await expect(
client.validateTransition(
"review_approved",
await readFixture("lifecycle", "publish-approved-valid.json")
)
).resolves.toEqual({ ok: true, reasonCode: null });
});
it("repo root 解析独立于当前工作目录", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "huijing-cwd-"));
try {
process.chdir(tempDir);
const client = createHarnessClient();
await expect(
client.validateContract("GameIR", await readFixture("mvp", "valid", "simulation-game-ir-valid.json"))
).resolves.toEqual({ ok: true, reasonCode: null });
} finally {
await rm(tempDir, { recursive: true, force: true });
}
});
it("timeout 返回稳定 HARNESS_TIMEOUT 并清理临时文件", async () => {
let observedInput: string | null = null;
const runner: ChildProcessRunner = async (_file, args) => {
observedInput = args[4] ?? null;
return {
timedOut: true,
stdout: "{\"ok\":true,\"reasonCode\":null}",
stderr: ""
};
};
const result = await createHarnessClient({ runner, timeoutMs: 1 }).validateContract("GameIR", { id: "x" });
expect(result).toEqual({ ok: false, reasonCode: HARNESS_TIMEOUT });
await expectMissing(observedInput ?? "");
});
it("invalid JSON 或 invalid result shape 返回稳定 HARNESS_INVALID_OUTPUT", async () => {
const invalidJsonRunner: ChildProcessRunner = async () => ({
timedOut: false,
stdout: "not json",
stderr: ""
});
const invalidShapeRunner: ChildProcessRunner = async () => ({
timedOut: false,
stdout: JSON.stringify({ ok: "yes", reasonCode: null }),
stderr: ""
});
await expect(createHarnessClient({ runner: invalidJsonRunner }).validateContract("GameIR", {})).resolves.toEqual({
ok: false,
reasonCode: HARNESS_INVALID_OUTPUT
});
await expect(createHarnessClient({ runner: invalidShapeRunner }).validateContract("GameIR", {})).resolves.toEqual({
ok: false,
reasonCode: HARNESS_INVALID_OUTPUT
});
});
it("stdout/stderr 超过上限时 fail closed避免无界捕获子进程输出", async () => {
const runner: ChildProcessRunner = async () => ({
timedOut: false,
stdout: `${JSON.stringify({ ok: true, reasonCode: null })}x`,
stderr: "too much"
});
await expect(createHarnessClient({ runner, maxOutputBytes: 16 }).validateContract("GameIR", {})).resolves.toEqual({
ok: false,
reasonCode: HARNESS_INVALID_OUTPUT
});
});
it("spawn error 不泄露为成功,返回稳定 HARNESS_INVALID_OUTPUT", async () => {
const runner: ChildProcessRunner = async () => {
throw new Error("spawn failed");
};
await expect(createHarnessClient({ runner }).validateContract("GameIR", {})).resolves.toEqual({
ok: false,
reasonCode: HARNESS_INVALID_OUTPUT
});
});
it("源码不 import 或复制 S0 validator 内部实现", async () => {
const source = await readFile(path.join(packageRoot, "src", "internal.ts"), "utf8");
expect(source).not.toMatch(/from\s+["'].*validate-harness\.mjs["']/);
expect(source).not.toMatch(/import\s*\(["'].*validate-harness\.mjs["']\)/);
expect(source).not.toContain("validateAgainstSchema");
expect(source).not.toContain("customContractRejection");
expect(source).not.toContain("transitionList");
});
it("包根只暴露 Task 5 指定的两个 public type 合同", async () => {
const source = await readFile(path.join(packageRoot, "src", "index.ts"), "utf8");
const packageJson = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8")) as {
exports: Record<string, unknown>;
};
expect(source).toContain("export type HarnessGateResult");
expect(source).toContain("export type HarnessClient");
expect(source).not.toMatch(/export\s+(const|function|class|interface)\s+/);
expect(source).not.toContain("createHarnessClient");
expect(source).not.toContain("HARNESS_TIMEOUT");
expect(source).not.toContain("ChildProcessRunner");
expect(packageJson.exports["."]).toEqual({
types: "./src/index.ts",
default: "./src/index.ts"
});
expect(packageJson.exports["./runtime"]).toEqual({
types: "./src/internal.ts",
default: "./src/internal.ts"
});
});
});

View File

@ -0,0 +1,6 @@
export type HarnessGateResult = { ok: boolean; reasonCode: string | null };
export type HarnessClient = {
validateContract(contract: string, payload: unknown): Promise<HarnessGateResult>;
validateTransition(event: string, payload: unknown): Promise<HarnessGateResult>;
};

View File

@ -0,0 +1,181 @@
import { spawn } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { HarnessClient, HarnessGateResult } from "./index.js";
export const HARNESS_TIMEOUT = "HARNESS_TIMEOUT" as const;
export const HARNESS_INVALID_OUTPUT = "HARNESS_INVALID_OUTPUT" as const;
export type ChildProcessResult = {
readonly timedOut: boolean;
readonly exitCode?: number | null;
readonly stdout: string;
readonly stderr: string;
};
export type ChildProcessRunnerOptions = {
readonly cwd: string;
readonly timeoutMs: number;
readonly maxOutputBytes: number;
};
export type ChildProcessRunner = (
file: string,
args: readonly string[],
options: ChildProcessRunnerOptions
) => Promise<ChildProcessResult>;
export type HarnessClientOptions = {
readonly repoRoot?: string;
readonly timeoutMs?: number;
readonly maxOutputBytes?: number;
readonly runner?: ChildProcessRunner;
};
const defaultTimeoutMs = 5_000;
const defaultMaxOutputBytes = 64 * 1024;
const currentFile = fileURLToPath(import.meta.url);
const packageRoot = path.resolve(path.dirname(currentFile), "..");
const defaultRepoRoot = path.resolve(packageRoot, "../..");
function byteLength(value: string): number {
return Buffer.byteLength(value, "utf8");
}
function appendBounded(current: string, chunk: Buffer, maxOutputBytes: number): string {
const next = current + chunk.toString("utf8");
if (byteLength(next) <= maxOutputBytes) return next;
return next.slice(0, maxOutputBytes + 1);
}
async function defaultRunner(
file: string,
args: readonly string[],
options: ChildProcessRunnerOptions
): Promise<ChildProcessResult> {
return new Promise((resolve, reject) => {
const child = spawn(file, [...args], {
cwd: options.cwd,
stdio: ["ignore", "pipe", "pipe"]
});
let stdout = "";
let stderr = "";
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, options.timeoutMs);
child.stdout.on("data", (chunk: Buffer) => {
stdout = appendBounded(stdout, chunk, options.maxOutputBytes);
if (byteLength(stdout) > options.maxOutputBytes) child.kill("SIGKILL");
});
child.stderr.on("data", (chunk: Buffer) => {
stderr = appendBounded(stderr, chunk, options.maxOutputBytes);
if (byteLength(stderr) > options.maxOutputBytes) child.kill("SIGKILL");
});
child.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
child.on("close", (exitCode) => {
clearTimeout(timeout);
resolve({
timedOut,
exitCode,
stdout,
stderr
});
});
});
}
function parseHarnessOutput(result: ChildProcessResult, maxOutputBytes: number): HarnessGateResult {
if (result.timedOut) return { ok: false, reasonCode: HARNESS_TIMEOUT };
if (byteLength(result.stdout) > maxOutputBytes || byteLength(result.stderr) > maxOutputBytes) {
return { ok: false, reasonCode: HARNESS_INVALID_OUTPUT };
}
let parsed: unknown;
try {
parsed = JSON.parse(result.stdout);
} catch {
return { ok: false, reasonCode: HARNESS_INVALID_OUTPUT };
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return { ok: false, reasonCode: HARNESS_INVALID_OUTPUT };
}
const value = parsed as { ok?: unknown; reasonCode?: unknown };
if (typeof value.ok !== "boolean") return { ok: false, reasonCode: HARNESS_INVALID_OUTPUT };
if (value.reasonCode !== null && typeof value.reasonCode !== "string") {
return { ok: false, reasonCode: HARNESS_INVALID_OUTPUT };
}
if (!value.ok && value.reasonCode === null) return { ok: false, reasonCode: HARNESS_INVALID_OUTPUT };
if (value.ok && result.exitCode !== undefined && result.exitCode !== null && result.exitCode !== 0) {
return { ok: false, reasonCode: HARNESS_INVALID_OUTPUT };
}
return {
ok: value.ok,
reasonCode: value.reasonCode
};
}
async function withTempJson<T>(payload: unknown, run: (file: string) => Promise<T>): Promise<T> {
const dir = await mkdtemp(path.join(os.tmpdir(), "huijing-harness-"));
const file = path.join(dir, "payload.json");
try {
await writeFile(file, JSON.stringify(payload), "utf8");
return await run(file);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
export function createHarnessClient(options: HarnessClientOptions = {}): HarnessClient {
const repoRoot = options.repoRoot ?? defaultRepoRoot;
const timeoutMs = options.timeoutMs ?? defaultTimeoutMs;
const maxOutputBytes = options.maxOutputBytes ?? defaultMaxOutputBytes;
const runner = options.runner ?? defaultRunner;
const harnessCli = path.join(repoRoot, "harness", "scripts", "validate-harness.mjs");
async function validate(kind: "contract" | "transition", name: string, payload: unknown): Promise<HarnessGateResult> {
return withTempJson(payload, async (inputFile) => {
const args =
kind === "contract"
? [harnessCli, "--contract", name, "--input", inputFile]
: [harnessCli, "--transition", name, "--input", inputFile];
try {
const result = await runner("node", args, {
cwd: repoRoot,
timeoutMs,
maxOutputBytes
});
return parseHarnessOutput(result, maxOutputBytes);
} catch {
// 子进程启动失败时无法证明 S0 gate 通过,必须 fail closed。
return { ok: false, reasonCode: HARNESS_INVALID_OUTPUT };
}
});
}
return {
validateContract(contract: string, payload: unknown) {
return validate("contract", contract, payload);
},
validateTransition(event: string, payload: unknown) {
return validate("transition", event, payload);
}
};
}

View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "src/**/*.spec.ts"]
}

View File

@ -0,0 +1,22 @@
{
"name": "@huijing/shared-contracts",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"build": "tsc -p tsconfig.json",
"dev:smoke": "vitest run"
},
"devDependencies": {
"@types/node": "^25.9.1",
"vitest": "^4.1.7"
}
}

View File

@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { S1_PACKAGE_NAMES, S1_WORKSPACE_KEYS, isS1PackageName } from "./index";
describe("@huijing/shared-contracts S1 exports", () => {
it("exports stable S1 package names", () => {
expect(S1_PACKAGE_NAMES).toEqual({
api: "@huijing/api",
harnessClient: "@huijing/harness-client",
sharedContracts: "@huijing/shared-contracts",
web: "@huijing/web",
worker: "@huijing/worker"
});
});
it("checks whether a value is an S1 package name", () => {
expect(S1_WORKSPACE_KEYS).toEqual(["web", "api", "worker", "sharedContracts", "harnessClient"]);
expect(isS1PackageName("@huijing/api")).toBe(true);
expect(isS1PackageName("@huijing/missing")).toBe(false);
});
});

View File

@ -0,0 +1,18 @@
export const S1_PACKAGE_NAMES = {
api: "@huijing/api",
harnessClient: "@huijing/harness-client",
sharedContracts: "@huijing/shared-contracts",
web: "@huijing/web",
worker: "@huijing/worker"
} as const;
export const S1_WORKSPACE_KEYS = ["web", "api", "worker", "sharedContracts", "harnessClient"] as const;
export type S1WorkspaceKey = (typeof S1_WORKSPACE_KEYS)[number];
export type S1PackageName = (typeof S1_PACKAGE_NAMES)[S1WorkspaceKey];
const S1_PACKAGE_NAME_SET: ReadonlySet<string> = new Set(Object.values(S1_PACKAGE_NAMES));
export function isS1PackageName(value: string): value is S1PackageName {
return S1_PACKAGE_NAME_SET.has(value);
}

View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "src/**/*.spec.ts"]
}

4053
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,3 @@
packages:
- "apps/*"
- "packages/*"

2975
scripts/check-s1-scope.mjs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,95 @@
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
const rootDir = process.cwd();
const requiredScripts = ["lint", "typecheck", "test", "build", "dev:smoke"];
// 只支持当前仓库声明的一层通配符,例如 apps/* 和 packages/*。
function parseWorkspacePatterns(source) {
return source
.split("\n")
.map((line) => line.trim())
.filter((line) => line.startsWith("- "))
.map((line) => line.slice(2).trim().replace(/^["']|["']$/g, ""));
}
async function pathExists(filePath) {
try {
await readFile(filePath, "utf8");
return true;
} catch (error) {
if (error && error.code === "ENOENT") {
return false;
}
throw error;
}
}
async function findWorkspaceManifests(patterns) {
const manifests = [];
for (const pattern of patterns) {
if (!pattern.endsWith("/*")) {
throw new Error(`不支持的 workspace pattern: ${pattern}`);
}
const baseDir = path.join(rootDir, pattern.slice(0, -2));
try {
const entries = await readdir(baseDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const manifestPath = path.join(baseDir, entry.name, "package.json");
if (await pathExists(manifestPath)) {
manifests.push(manifestPath);
}
}
} catch (error) {
if (error && error.code === "ENOENT") {
continue;
}
throw error;
}
}
return manifests;
}
async function readManifest(manifestPath) {
const raw = await readFile(manifestPath, "utf8");
try {
return JSON.parse(raw);
} catch (error) {
throw new Error(`${path.relative(rootDir, manifestPath)} 不是有效 JSON: ${error.message}`);
}
}
const workspaceConfigPath = path.join(rootDir, "pnpm-workspace.yaml");
const workspaceConfig = await readFile(workspaceConfigPath, "utf8");
const manifests = await findWorkspaceManifests(parseWorkspacePatterns(workspaceConfig));
const failures = [];
for (const manifestPath of manifests) {
const manifest = await readManifest(manifestPath);
const missingScripts = requiredScripts.filter((scriptName) => !manifest.scripts?.[scriptName]);
if (missingScripts.length > 0) {
failures.push({
packageName: manifest.name || path.relative(rootDir, path.dirname(manifestPath)),
manifestPath,
missingScripts
});
}
}
if (failures.length > 0) {
console.error("以下 workspace package 缺少必需脚本:");
for (const failure of failures) {
console.error(
`- ${failure.packageName} (${path.relative(rootDir, failure.manifestPath)}): ${failure.missingScripts.join(", ")}`
);
}
process.exitCode = 1;
}

19
tsconfig.base.json Normal file
View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}