Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions docs/milestones.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,3 +316,132 @@ getEmbeddingReindexProgress()
support methods, `BackfillCursorStore`, the embedding provider, and the `embeddings.reindex` job
handler — all against lightweight in-memory fakes, so the suite runs without a live database (no
`DATABASE_URL` or pgvector required).

---

## Bulk Milestone Check-in

### POST /api/verifications/bulk

Submits multiple milestone check-ins in a single request. Each item is validated and applied independently; failures are reported per-item without aborting the entire batch.

**Authentication:** Required (JWT Bearer token)
**Authorization:** VERIFIER role required
**Idempotency:** Yes - repeated submissions for the same targetId return conflict error

#### Request

- **Method:** POST
- **Path:** `/api/verifications/bulk`
- **Headers:**
- `Authorization: Bearer <jwt-token>`
- `Content-Type: application/json`
- **Body:** Array of check-in items

```json
[
{
"targetId": "milestone-1",
"result": "approved",
"disputed": false,
"evidenceHash": "a".repeat(64),
"evidenceReferenceUrl": "https://s3.example.com/evidence.pdf"
},
{
"targetId": "milestone-2",
"result": "rejected",
"disputed": true,
"evidenceHash": "b".repeat(64),
"evidenceReferenceUrl": "https://s3.example.com/evidence2.pdf"
}
]
```

#### Response

**Success (200):**
```json
{
"results": [
{
"targetId": "milestone-1",
"success": true,
"verification": {
"id": "ver-1",
"verifierUserId": "verifier-1",
"targetId": "milestone-1",
"result": "approved",
"evidenceHash": "a".repeat(64),
"disputed": false,
"timestamp": "2024-01-01T00:00:00.000Z"
},
"evidenceReference": {
"id": "ev-1",
"verificationId": "ver-1",
"evidenceHash": "a".repeat(64),
"evidenceReferenceUrl": "https://s3.example.com/evidence.pdf"
}
},
{
"targetId": "milestone-2",
"success": false,
"error": {
"code": "CONFLICT",
"message": "conflicting verification decision already exists"
}
}
],
"summary": {
"total": 2,
"succeeded": 1,
"failed": 1
}
}
```

#### Error Codes

| Code | Description |
|---|---|
| `BAD_REQUEST` | Invalid request data (missing/invalid fields) |
| `VALIDATION_ERROR` | Evidence reference validation failed |
| `CONFLICT` | Verification decision already exists for this targetId |
| `INTERNAL_ERROR` | Unexpected server error |

#### Constraints

- **Batch Size:** Maximum 100 items per request
- **Per-item Validation:** Each item is validated independently
- **Partial Failure:** One failed item does not abort the entire batch
- **Idempotency:** Retrying the same batch returns consistent results

#### Authorization Rules

1. **Role Check:** User must have VERIFIER role
2. **Active Verifier:** Verifier account must be active
3. **Per-item Authorization:** All items use the authenticated verifier's userId

#### Events

Successful check-ins emit:
- `verification.decision.recorded` audit log for each successful item
- Evidence reference created for each successful item

#### Security Considerations

- Verifier identity verified from authenticated JWT context
- Per-item isolation prevents one bad item from affecting others
- Bounded batch size prevents resource exhaustion
- All validation attempts logged with actor information

#### Testing

Tests live in `src/tests/verifications.bulk.test.ts` and cover:
- Request validation (array format, empty array, batch size cap)
- Per-item validation (missing fields, invalid formats)
- Mixed success/failure scenarios
- Batch size cap enforcement
- Idempotent retry behavior
- Duplicate items in batch
- Authorization requirements

183 changes: 182 additions & 1 deletion src/routes/verifications.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Router, Request, Response, NextFunction } from 'express'
import { authenticate } from '../middleware/auth.js'
import { requireVerifier, requireAdmin } from '../middleware/rbac.js'
import { recordVerification, listVerifications } from '../services/verifiers.js'
import { recordVerification, listVerifications, VerificationConflictError } from '../services/verifiers.js'
import { createAuditLog } from '../lib/audit-logs.js'
import { AppError } from '../middleware/errorHandler.js'
import { createEvidenceReference, EvidenceReferenceValidationError } from '../services/evidence.js'
Expand All @@ -11,6 +11,7 @@
export const verificationsRouter = Router()

const EVIDENCE_HASH_RE = /^[0-9a-f]{32,128}$/i
const MAX_BATCH_SIZE = 100

function isSerializationError(err: Error): boolean {
const msg = err.message.toLowerCase()
Expand Down Expand Up @@ -114,3 +115,183 @@
const all = await listVerifications()
res.json({ verifications: all })
})

interface BulkCheckInItem {
targetId: string
result: 'approved' | 'rejected'
disputed?: boolean
evidenceHash: string
evidenceReferenceUrl: string
}

interface BulkCheckInResult {
targetId: string
success: boolean
error?: {
code: string
message:string
}
verification?: {
id: string
verifierUserId: string
targetId: string
result: 'approved' | 'rejected'
evidenceHash: string | null
disputed: boolean
timestamp: string
}
evidenceReference?: {
id: string
verificationId: string
evidenceHash: string
evidenceReferenceUrl: string
}
}

interface BulkCheckInResponse {
results: BulkCheckInResult[]
summary: {
total: number
succeeded: number
failed: number
}
}

verificationsRouter.post('/bulk', authenticate, requireVerifier, async (req: Request, res: Response, next: NextFunction) => {
const payload = req.user!
const verifierUserId = payload.userId
const items = req.body as BulkCheckInItem[]

if (!Array.isArray(items)) {
return next(AppError.badRequest('Request body must be an array of check-in items'))
}

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.
This route handler performs
authorization
, but is not rate-limited.

if (items.length === 0) {
return next(AppError.badRequest('Request body must contain at least one check-in item'))
}

if (items.length > MAX_BATCH_SIZE) {
return next(AppError.badRequest(`Batch size exceeds maximum of ${MAX_BATCH_SIZE}`))
}

const results: BulkCheckInResult[] = []
let succeeded = 0
let failed = 0

for (const item of items) {
const { targetId, result, disputed, evidenceHash, evidenceReferenceUrl } = item

const itemResult: BulkCheckInResult = {
targetId,
success: false,
}

try {
// Validate individual item
if (!targetId || !targetId.trim()) {
throw AppError.badRequest('targetId is required')
}

if (result !== 'approved' && result !== 'rejected') {
throw AppError.validation("result must be 'approved' or 'rejected'")
}

if (!evidenceHash || !evidenceHash.trim()) {
throw AppError.badRequest('evidenceHash is required')
}

const cleanEvidenceHash = evidenceHash.trim().toLowerCase()
if (!EVIDENCE_HASH_RE.test(cleanEvidenceHash)) {
throw AppError.validation('evidenceHash must be a valid hex string (32–128 characters)')
}

if (!evidenceReferenceUrl || !evidenceReferenceUrl.trim()) {
throw AppError.badRequest('evidenceReferenceUrl is required')
}

const cleanTargetId = targetId.trim()
const cleanEvidenceReferenceUrl = evidenceReferenceUrl.trim()

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.
This route handler performs
authorization
, but is not rate-limited.
// Process the verification
const rec = await retryWithBackoff(
() =>
db.transaction(async (trx) => {
const verification = await recordVerification(
verifierUserId,
cleanTargetId,
result,
!!disputed,
cleanEvidenceHash,
trx,
)

await createAuditLog(
{
actor_user_id: verifierUserId,
action: 'verification.decision.recorded',
target_type: 'verification',
target_id: cleanTargetId,
metadata: {
result,
disputed: !!disputed,
evidence_hash: cleanEvidenceHash,
},
},
trx,
)

return verification
}),
undefined,
isSerializationError,
)

const evidenceReference = await createEvidenceReference(
rec.id,
cleanEvidenceHash,
cleanEvidenceReferenceUrl,
)

itemResult.success = true
itemResult.verification = rec
itemResult.evidenceReference = evidenceReference
succeeded++
} catch (error: any) {
failed++
if (error?.name === 'VerificationConflictError') {
itemResult.error = {
code: 'CONFLICT',
message: 'conflicting verification decision already exists',
}
} else if (error?.name === 'EvidenceReferenceValidationError') {
itemResult.error = {
code: 'VALIDATION_ERROR',
message: error.message,
}
} else if (error instanceof AppError) {
itemResult.error = {
code: error.statusCode === 400 ? 'BAD_REQUEST' : error.statusCode === 409 ? 'CONFLICT' : 'INTERNAL_ERROR',
message: error.message,
}
} else {
itemResult.error = {
code: 'INTERNAL_ERROR',
message: 'failed to record verification decision',
}
}
}

results.push(itemResult)
}

const response: BulkCheckInResponse = {
results,
summary: {
total: items.length,
succeeded,
failed,
},
}

res.status(200).json(response)
})
Loading
Loading