diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a148057..e4e923db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,3 +16,5 @@ | 2026-02-24 | 0.1.11 | Added frontend-ready core API guide and linked M4 API catalog to it as source of truth for consumption. | | 2026-02-24 | 0.1.12 | Reduced M4 API docs to core-only scope and removed command-route references until command implementation is complete. | | 2026-02-24 | 0.1.13 | Added verification architecture contract with endpoint design and workflow split for attire, government ID, and certification. | +| 2026-02-24 | 0.1.14 | Implemented core verification endpoints in dev and updated frontend/API docs with live verification route contracts. | +| 2026-02-24 | 0.1.15 | Added live Vertex Flash Lite attire verification path and third-party adapter scaffolding for government ID and certification checks. | diff --git a/backend/core-api/src/contracts/core/create-verification.js b/backend/core-api/src/contracts/core/create-verification.js new file mode 100644 index 00000000..ee03d8ec --- /dev/null +++ b/backend/core-api/src/contracts/core/create-verification.js @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const createVerificationSchema = z.object({ + type: z.enum(['attire', 'government_id', 'certification']), + subjectType: z.string().min(1).max(80).optional(), + subjectId: z.string().min(1).max(120).optional(), + fileUri: z.string().startsWith('gs://', 'fileUri must start with gs://'), + rules: z.record(z.any()).optional().default({}), + metadata: z.record(z.any()).optional().default({}), +}); diff --git a/backend/core-api/src/contracts/core/review-verification.js b/backend/core-api/src/contracts/core/review-verification.js new file mode 100644 index 00000000..1fb2a56c --- /dev/null +++ b/backend/core-api/src/contracts/core/review-verification.js @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const reviewVerificationSchema = z.object({ + decision: z.enum(['APPROVED', 'REJECTED']), + note: z.string().max(1000).optional().default(''), + reasonCode: z.string().max(100).optional().default('MANUAL_REVIEW'), +}); diff --git a/backend/core-api/src/routes/core.js b/backend/core-api/src/routes/core.js index d73b0170..f9d22ef7 100644 --- a/backend/core-api/src/routes/core.js +++ b/backend/core-api/src/routes/core.js @@ -4,10 +4,23 @@ import { z } from 'zod'; import { AppError } from '../lib/errors.js'; import { requireAuth, requirePolicy } from '../middleware/auth.js'; import { createSignedUrlSchema } from '../contracts/core/create-signed-url.js'; +import { createVerificationSchema } from '../contracts/core/create-verification.js'; import { invokeLlmSchema } from '../contracts/core/invoke-llm.js'; +import { reviewVerificationSchema } from '../contracts/core/review-verification.js'; import { invokeVertexModel } from '../services/llm.js'; import { checkLlmRateLimit } from '../services/llm-rate-limit.js'; -import { generateReadSignedUrl, uploadToGcs, validateFileUriAccess } from '../services/storage.js'; +import { + ensureFileExistsForActor, + generateReadSignedUrl, + uploadToGcs, + validateFileUriAccess, +} from '../services/storage.js'; +import { + createVerificationJob, + getVerificationJob, + retryVerificationJob, + reviewVerificationJob, +} from '../services/verification-jobs.js'; const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024; const DEFAULT_MAX_SIGNED_URL_SECONDS = 900; @@ -42,6 +55,10 @@ function useMockUpload() { return process.env.UPLOAD_MOCK !== 'false'; } +function requireVerificationFileExists() { + return process.env.VERIFICATION_REQUIRE_FILE_EXISTS !== 'false'; +} + function parseBody(schema, body) { const parsed = schema.safeParse(body); if (!parsed.success) { @@ -177,12 +194,84 @@ async function handleInvokeLlm(req, res, next) { } } +async function handleCreateVerification(req, res, next) { + try { + const payload = parseBody(createVerificationSchema, req.body || {}); + validateFileUriAccess({ + fileUri: payload.fileUri, + actorUid: req.actor.uid, + }); + + if (requireVerificationFileExists() && !useMockUpload()) { + await ensureFileExistsForActor({ + fileUri: payload.fileUri, + actorUid: req.actor.uid, + }); + } + + const created = createVerificationJob({ + actorUid: req.actor.uid, + payload, + }); + return res.status(202).json({ + ...created, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } +} + +async function handleGetVerification(req, res, next) { + try { + const verificationId = req.params.verificationId; + const job = getVerificationJob(verificationId, req.actor.uid); + return res.status(200).json({ + ...job, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } +} + +async function handleReviewVerification(req, res, next) { + try { + const verificationId = req.params.verificationId; + const payload = parseBody(reviewVerificationSchema, req.body || {}); + const updated = reviewVerificationJob(verificationId, req.actor.uid, payload); + return res.status(200).json({ + ...updated, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } +} + +async function handleRetryVerification(req, res, next) { + try { + const verificationId = req.params.verificationId; + const updated = retryVerificationJob(verificationId, req.actor.uid); + return res.status(202).json({ + ...updated, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } +} + export function createCoreRouter() { const router = Router(); router.post('/upload-file', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleUploadFile); router.post('/create-signed-url', requireAuth, requirePolicy('core.sign-url', 'file'), handleCreateSignedUrl); router.post('/invoke-llm', requireAuth, requirePolicy('core.invoke-llm', 'model'), handleInvokeLlm); + router.post('/verifications', requireAuth, requirePolicy('core.verification.create', 'verification'), handleCreateVerification); + router.get('/verifications/:verificationId', requireAuth, requirePolicy('core.verification.read', 'verification'), handleGetVerification); + router.post('/verifications/:verificationId/review', requireAuth, requirePolicy('core.verification.review', 'verification'), handleReviewVerification); + router.post('/verifications/:verificationId/retry', requireAuth, requirePolicy('core.verification.retry', 'verification'), handleRetryVerification); return router; } diff --git a/backend/core-api/src/services/llm.js b/backend/core-api/src/services/llm.js index 31d8b17e..19c90862 100644 --- a/backend/core-api/src/services/llm.js +++ b/backend/core-api/src/services/llm.js @@ -35,14 +35,34 @@ function toTextFromCandidate(candidate) { .trim(); } -export async function invokeVertexModel({ prompt, responseJsonSchema, fileUrls = [] }) { - const { project, location } = buildVertexConfig(); - const model = process.env.LLM_MODEL || 'gemini-2.0-flash-001'; - const timeoutMs = Number.parseInt(process.env.LLM_TIMEOUT_MS || '20000', 10); +function withJsonSchemaInstruction(prompt, responseJsonSchema) { const schemaText = JSON.stringify(responseJsonSchema); - const fileContext = fileUrls.length > 0 ? `\nFiles:\n${fileUrls.join('\n')}` : ''; - const instruction = `Respond with strict JSON only. Follow this schema exactly:\n${schemaText}`; - const textPrompt = `${prompt}\n\n${instruction}${fileContext}`; + return `${prompt}\n\nRespond with strict JSON only. Follow this schema exactly:\n${schemaText}`; +} + +function guessMimeTypeFromUri(fileUri) { + const path = fileUri.split('?')[0].toLowerCase(); + if (path.endsWith('.jpg') || path.endsWith('.jpeg')) return 'image/jpeg'; + if (path.endsWith('.png')) return 'image/png'; + if (path.endsWith('.pdf')) return 'application/pdf'; + return 'application/octet-stream'; +} + +function buildMultimodalParts(prompt, fileUris = []) { + const parts = [{ text: prompt }]; + for (const fileUri of fileUris) { + parts.push({ + fileData: { + fileUri, + mimeType: guessMimeTypeFromUri(fileUri), + }, + }); + } + return parts; +} + +async function callVertexJsonModel({ model, timeoutMs, parts }) { + const { project, location } = buildVertexConfig(); const url = `https://${location}-aiplatform.googleapis.com/v1/projects/${project}/locations/${location}/publishers/google/models/${model}:generateContent`; const auth = new GoogleAuth({ scopes: ['https://www.googleapis.com/auth/cloud-platform'], @@ -56,7 +76,7 @@ export async function invokeVertexModel({ prompt, responseJsonSchema, fileUrls = url, method: 'POST', data: { - contents: [{ role: 'user', parts: [{ text: textPrompt }] }], + contents: [{ role: 'user', parts }], generationConfig: { temperature: 0.2, responseMimeType: 'application/json', @@ -91,3 +111,35 @@ export async function invokeVertexModel({ prompt, responseJsonSchema, fileUrls = }; } } + +export async function invokeVertexModel({ prompt, responseJsonSchema, fileUrls = [] }) { + const model = process.env.LLM_MODEL || 'gemini-2.0-flash-001'; + const timeoutMs = Number.parseInt(process.env.LLM_TIMEOUT_MS || '20000', 10); + const promptWithSchema = withJsonSchemaInstruction(prompt, responseJsonSchema); + const fileContext = fileUrls.length > 0 ? `\nFiles:\n${fileUrls.join('\n')}` : ''; + return callVertexJsonModel({ + model, + timeoutMs, + parts: [{ text: `${promptWithSchema}${fileContext}` }], + }); +} + +export async function invokeVertexMultimodalModel({ + prompt, + responseJsonSchema, + fileUris = [], + model, + timeoutMs, +}) { + const resolvedModel = model || process.env.LLM_MODEL || 'gemini-2.0-flash-001'; + const resolvedTimeoutMs = Number.parseInt( + `${timeoutMs || process.env.LLM_TIMEOUT_MS || '20000'}`, + 10 + ); + const promptWithSchema = withJsonSchemaInstruction(prompt, responseJsonSchema); + return callVertexJsonModel({ + model: resolvedModel, + timeoutMs: resolvedTimeoutMs, + parts: buildMultimodalParts(promptWithSchema, fileUris), + }); +} diff --git a/backend/core-api/src/services/storage.js b/backend/core-api/src/services/storage.js index da0dd382..3dcfc2d7 100644 --- a/backend/core-api/src/services/storage.js +++ b/backend/core-api/src/services/storage.js @@ -72,3 +72,12 @@ export async function generateReadSignedUrl({ fileUri, actorUid, expiresInSecond expiresAt: new Date(expiresAtMs).toISOString(), }; } + +export async function ensureFileExistsForActor({ fileUri, actorUid }) { + const { bucket, path } = validateFileUriAccess({ fileUri, actorUid }); + const file = storage.bucket(bucket).file(path); + const [exists] = await file.exists(); + if (!exists) { + throw new AppError('NOT_FOUND', 'Evidence file not found', 404, { fileUri }); + } +} diff --git a/backend/core-api/src/services/verification-jobs.js b/backend/core-api/src/services/verification-jobs.js new file mode 100644 index 00000000..5ffe44bd --- /dev/null +++ b/backend/core-api/src/services/verification-jobs.js @@ -0,0 +1,510 @@ +import crypto from 'node:crypto'; +import { AppError } from '../lib/errors.js'; +import { invokeVertexMultimodalModel } from './llm.js'; + +const jobs = new Map(); + +export const VerificationStatus = Object.freeze({ + PENDING: 'PENDING', + PROCESSING: 'PROCESSING', + AUTO_PASS: 'AUTO_PASS', + AUTO_FAIL: 'AUTO_FAIL', + NEEDS_REVIEW: 'NEEDS_REVIEW', + APPROVED: 'APPROVED', + REJECTED: 'REJECTED', + ERROR: 'ERROR', +}); + +const MACHINE_TERMINAL_STATUSES = new Set([ + VerificationStatus.AUTO_PASS, + VerificationStatus.AUTO_FAIL, + VerificationStatus.NEEDS_REVIEW, + VerificationStatus.ERROR, +]); + +const HUMAN_TERMINAL_STATUSES = new Set([ + VerificationStatus.APPROVED, + VerificationStatus.REJECTED, +]); + +function nowIso() { + return new Date().toISOString(); +} + +function accessMode() { + return process.env.VERIFICATION_ACCESS_MODE || 'authenticated'; +} + +function eventRecord({ fromStatus, toStatus, actorType, actorId, details = {} }) { + return { + id: crypto.randomUUID(), + fromStatus, + toStatus, + actorType, + actorId, + details, + createdAt: nowIso(), + }; +} + +function toPublicJob(job) { + return { + verificationId: job.id, + type: job.type, + subjectType: job.subjectType, + subjectId: job.subjectId, + fileUri: job.fileUri, + status: job.status, + confidence: job.confidence, + reasons: job.reasons, + extracted: job.extracted, + provider: job.provider, + review: job.review, + createdAt: job.createdAt, + updatedAt: job.updatedAt, + }; +} + +function assertAccess(job, actorUid) { + if (accessMode() === 'authenticated') { + return; + } + if (job.ownerUid !== actorUid) { + throw new AppError('FORBIDDEN', 'Not allowed to access this verification', 403); + } +} + +function requireJob(id) { + const job = jobs.get(id); + if (!job) { + throw new AppError('NOT_FOUND', 'Verification not found', 404, { verificationId: id }); + } + return job; +} + +function normalizeMachineStatus(status) { + if ( + status === VerificationStatus.AUTO_PASS + || status === VerificationStatus.AUTO_FAIL + || status === VerificationStatus.NEEDS_REVIEW + ) { + return status; + } + return VerificationStatus.NEEDS_REVIEW; +} + +function clampConfidence(value, fallback = 0.5) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return fallback; + if (parsed < 0) return 0; + if (parsed > 1) return 1; + return parsed; +} + +function asReasonList(reasons, fallback) { + if (Array.isArray(reasons) && reasons.length > 0) { + return reasons.map((item) => `${item}`); + } + return [fallback]; +} + +function providerTimeoutMs() { + return Number.parseInt(process.env.VERIFICATION_PROVIDER_TIMEOUT_MS || '8000', 10); +} + +function attireModel() { + return process.env.VERIFICATION_ATTIRE_MODEL || 'gemini-2.0-flash-lite-001'; +} + +async function runAttireChecks(job) { + if (process.env.VERIFICATION_ATTIRE_AUTOPASS === 'true') { + return { + status: VerificationStatus.AUTO_PASS, + confidence: 0.8, + reasons: ['Auto-pass mode enabled for attire in dev'], + extracted: { + expected: job.rules, + }, + provider: { + name: 'attire-auto-pass', + reference: null, + }, + }; + } + + const attireProvider = process.env.VERIFICATION_ATTIRE_PROVIDER || 'vertex'; + if (attireProvider !== 'vertex') { + return { + status: VerificationStatus.NEEDS_REVIEW, + confidence: 0.45, + reasons: [`Attire provider '${attireProvider}' is not supported`], + extracted: { + expected: job.rules, + }, + provider: { + name: attireProvider, + reference: null, + }, + }; + } + + try { + const prompt = [ + 'You are validating worker attire evidence.', + `Rules: ${JSON.stringify(job.rules || {})}`, + 'Return AUTO_PASS only when the image clearly matches required attire.', + 'Return AUTO_FAIL when the image clearly violates required attire.', + 'Return NEEDS_REVIEW when uncertain.', + ].join('\n'); + + const schema = { + type: 'object', + properties: { + status: { type: 'string' }, + confidence: { type: 'number' }, + reasons: { + type: 'array', + items: { type: 'string' }, + }, + extracted: { + type: 'object', + additionalProperties: true, + }, + }, + required: ['status', 'confidence', 'reasons'], + }; + + const modelOutput = await invokeVertexMultimodalModel({ + prompt, + responseJsonSchema: schema, + fileUris: [job.fileUri], + model: attireModel(), + timeoutMs: providerTimeoutMs(), + }); + + const result = modelOutput?.result || {}; + return { + status: normalizeMachineStatus(result.status), + confidence: clampConfidence(result.confidence, 0.6), + reasons: asReasonList(result.reasons, 'Attire check completed'), + extracted: result.extracted || {}, + provider: { + name: 'vertex-attire', + reference: modelOutput?.model || attireModel(), + }, + }; + } catch (error) { + return { + status: VerificationStatus.NEEDS_REVIEW, + confidence: 0.35, + reasons: ['Automatic attire check unavailable, manual review required'], + extracted: {}, + provider: { + name: 'vertex-attire', + reference: `error:${error?.code || 'unknown'}`, + }, + }; + } +} + +function getProviderConfig(type) { + if (type === 'government_id') { + return { + name: 'government-id-provider', + url: process.env.VERIFICATION_GOV_ID_PROVIDER_URL, + token: process.env.VERIFICATION_GOV_ID_PROVIDER_TOKEN, + }; + } + return { + name: 'certification-provider', + url: process.env.VERIFICATION_CERT_PROVIDER_URL, + token: process.env.VERIFICATION_CERT_PROVIDER_TOKEN, + }; +} + +async function runThirdPartyChecks(job, type) { + const provider = getProviderConfig(type); + if (!provider.url) { + return { + status: VerificationStatus.NEEDS_REVIEW, + confidence: 0.4, + reasons: [`${provider.name} is not configured`], + extracted: {}, + provider: { + name: provider.name, + reference: null, + }, + }; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), providerTimeoutMs()); + + try { + const response = await fetch(provider.url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(provider.token ? { Authorization: `Bearer ${provider.token}` } : {}), + }, + body: JSON.stringify({ + type, + subjectType: job.subjectType, + subjectId: job.subjectId, + fileUri: job.fileUri, + rules: job.rules, + metadata: job.metadata, + }), + signal: controller.signal, + }); + + const bodyText = await response.text(); + let body = {}; + try { + body = bodyText ? JSON.parse(bodyText) : {}; + } catch { + body = {}; + } + + if (!response.ok) { + return { + status: VerificationStatus.NEEDS_REVIEW, + confidence: 0.35, + reasons: [`${provider.name} returned ${response.status}`], + extracted: {}, + provider: { + name: provider.name, + reference: body?.reference || null, + }, + }; + } + + return { + status: normalizeMachineStatus(body.status), + confidence: clampConfidence(body.confidence, 0.6), + reasons: asReasonList(body.reasons, `${provider.name} completed check`), + extracted: body.extracted || {}, + provider: { + name: provider.name, + reference: body.reference || null, + }, + }; + } catch (error) { + const isAbort = error?.name === 'AbortError'; + return { + status: VerificationStatus.NEEDS_REVIEW, + confidence: 0.3, + reasons: [ + isAbort + ? `${provider.name} timeout, manual review required` + : `${provider.name} unavailable, manual review required`, + ], + extracted: {}, + provider: { + name: provider.name, + reference: null, + }, + }; + } finally { + clearTimeout(timeout); + } +} + +async function runMachineChecks(job) { + if (job.type === 'attire') { + return runAttireChecks(job); + } + + if (job.type === 'government_id') { + return runThirdPartyChecks(job, 'government_id'); + } + + return runThirdPartyChecks(job, 'certification'); +} + +async function processVerificationJob(id) { + const job = requireJob(id); + if (job.status !== VerificationStatus.PENDING) { + return; + } + + const beforeProcessing = job.status; + job.status = VerificationStatus.PROCESSING; + job.updatedAt = nowIso(); + job.events.push( + eventRecord({ + fromStatus: beforeProcessing, + toStatus: VerificationStatus.PROCESSING, + actorType: 'system', + actorId: 'verification-worker', + }) + ); + + try { + const outcome = await runMachineChecks(job); + if (!MACHINE_TERMINAL_STATUSES.has(outcome.status)) { + throw new Error(`Invalid machine outcome status: ${outcome.status}`); + } + const fromStatus = job.status; + job.status = outcome.status; + job.confidence = outcome.confidence; + job.reasons = outcome.reasons; + job.extracted = outcome.extracted; + job.provider = outcome.provider; + job.updatedAt = nowIso(); + job.events.push( + eventRecord({ + fromStatus, + toStatus: job.status, + actorType: 'system', + actorId: 'verification-worker', + details: { + confidence: job.confidence, + reasons: job.reasons, + provider: job.provider, + }, + }) + ); + } catch (error) { + const fromStatus = job.status; + job.status = VerificationStatus.ERROR; + job.confidence = null; + job.reasons = [error?.message || 'Verification processing failed']; + job.extracted = {}; + job.provider = { + name: 'verification-worker', + reference: null, + }; + job.updatedAt = nowIso(); + job.events.push( + eventRecord({ + fromStatus, + toStatus: VerificationStatus.ERROR, + actorType: 'system', + actorId: 'verification-worker', + details: { + error: error?.message || 'Verification processing failed', + }, + }) + ); + } +} + +function queueVerificationProcessing(id) { + setTimeout(() => { + processVerificationJob(id).catch(() => {}); + }, 0); +} + +export function createVerificationJob({ actorUid, payload }) { + const now = nowIso(); + const id = `ver_${crypto.randomUUID()}`; + const job = { + id, + type: payload.type, + subjectType: payload.subjectType || null, + subjectId: payload.subjectId || null, + ownerUid: actorUid, + fileUri: payload.fileUri, + rules: payload.rules || {}, + metadata: payload.metadata || {}, + status: VerificationStatus.PENDING, + confidence: null, + reasons: [], + extracted: {}, + provider: null, + review: null, + createdAt: now, + updatedAt: now, + events: [ + eventRecord({ + fromStatus: null, + toStatus: VerificationStatus.PENDING, + actorType: 'system', + actorId: actorUid, + }), + ], + }; + jobs.set(id, job); + queueVerificationProcessing(id); + return toPublicJob(job); +} + +export function getVerificationJob(verificationId, actorUid) { + const job = requireJob(verificationId); + assertAccess(job, actorUid); + return toPublicJob(job); +} + +export function reviewVerificationJob(verificationId, actorUid, review) { + const job = requireJob(verificationId); + assertAccess(job, actorUid); + + if (HUMAN_TERMINAL_STATUSES.has(job.status)) { + throw new AppError('CONFLICT', 'Verification already finalized', 409, { + verificationId, + status: job.status, + }); + } + + const fromStatus = job.status; + job.status = review.decision; + job.review = { + decision: review.decision, + reviewedBy: actorUid, + reviewedAt: nowIso(), + note: review.note || '', + reasonCode: review.reasonCode || 'MANUAL_REVIEW', + }; + job.updatedAt = nowIso(); + job.events.push( + eventRecord({ + fromStatus, + toStatus: job.status, + actorType: 'reviewer', + actorId: actorUid, + details: { + reasonCode: job.review.reasonCode, + }, + }) + ); + + return toPublicJob(job); +} + +export function retryVerificationJob(verificationId, actorUid) { + const job = requireJob(verificationId); + assertAccess(job, actorUid); + + if (job.status === VerificationStatus.PROCESSING) { + throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, { + verificationId, + }); + } + + const fromStatus = job.status; + job.status = VerificationStatus.PENDING; + job.confidence = null; + job.reasons = []; + job.extracted = {}; + job.provider = null; + job.review = null; + job.updatedAt = nowIso(); + job.events.push( + eventRecord({ + fromStatus, + toStatus: VerificationStatus.PENDING, + actorType: 'reviewer', + actorId: actorUid, + details: { + retried: true, + }, + }) + ); + queueVerificationProcessing(verificationId); + return toPublicJob(job); +} + +export function __resetVerificationJobsForTests() { + jobs.clear(); +} diff --git a/backend/core-api/test/app.test.js b/backend/core-api/test/app.test.js index b1cdbc0e..e12f9005 100644 --- a/backend/core-api/test/app.test.js +++ b/backend/core-api/test/app.test.js @@ -3,16 +3,42 @@ import assert from 'node:assert/strict'; import request from 'supertest'; import { createApp } from '../src/app.js'; import { __resetLlmRateLimitForTests } from '../src/services/llm-rate-limit.js'; +import { __resetVerificationJobsForTests } from '../src/services/verification-jobs.js'; beforeEach(() => { process.env.AUTH_BYPASS = 'true'; process.env.LLM_MOCK = 'true'; process.env.SIGNED_URL_MOCK = 'true'; + process.env.UPLOAD_MOCK = 'true'; process.env.MAX_SIGNED_URL_SECONDS = '900'; process.env.LLM_RATE_LIMIT_PER_MINUTE = '20'; + process.env.VERIFICATION_REQUIRE_FILE_EXISTS = 'false'; + process.env.VERIFICATION_ACCESS_MODE = 'authenticated'; + process.env.VERIFICATION_ATTIRE_PROVIDER = 'mock'; __resetLlmRateLimitForTests(); + __resetVerificationJobsForTests(); }); +async function waitForMachineStatus(app, verificationId, maxAttempts = 30) { + let last; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + last = await request(app) + .get(`/core/verifications/${verificationId}`) + .set('Authorization', 'Bearer test-token'); + if ( + last.body?.status === 'AUTO_PASS' + || last.body?.status === 'AUTO_FAIL' + || last.body?.status === 'NEEDS_REVIEW' + || last.body?.status === 'ERROR' + ) { + return last; + } + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 10)); + } + return last; +} + test('GET /healthz returns healthy response', async () => { const app = createApp(); const res = await request(app).get('/healthz'); @@ -123,3 +149,98 @@ test('POST /core/invoke-llm enforces per-user rate limit', async () => { assert.equal(second.body.code, 'RATE_LIMITED'); assert.equal(typeof second.headers['retry-after'], 'string'); }); + +test('POST /core/verifications creates async job and GET returns status', async () => { + const app = createApp(); + const created = await request(app) + .post('/core/verifications') + .set('Authorization', 'Bearer test-token') + .send({ + type: 'attire', + subjectType: 'staff', + subjectId: 'staff_1', + fileUri: 'gs://krow-workforce-dev-private/uploads/test-user/attire.jpg', + rules: { attireType: 'shoes', expectedColor: 'black' }, + }); + + assert.equal(created.status, 202); + assert.equal(created.body.type, 'attire'); + assert.equal(created.body.status, 'PENDING'); + assert.equal(typeof created.body.verificationId, 'string'); + + const status = await waitForMachineStatus(app, created.body.verificationId); + assert.equal(status.status, 200); + assert.equal(status.body.verificationId, created.body.verificationId); + assert.equal(status.body.type, 'attire'); + assert.ok(['NEEDS_REVIEW', 'AUTO_PASS', 'AUTO_FAIL', 'ERROR'].includes(status.body.status)); +}); + +test('POST /core/verifications rejects file paths not owned by actor', async () => { + const app = createApp(); + const res = await request(app) + .post('/core/verifications') + .set('Authorization', 'Bearer test-token') + .send({ + type: 'attire', + fileUri: 'gs://krow-workforce-dev-private/uploads/other-user/not-allowed.jpg', + rules: { attireType: 'shoes' }, + }); + + assert.equal(res.status, 403); + assert.equal(res.body.code, 'FORBIDDEN'); +}); + +test('POST /core/verifications/:id/review finalizes verification', async () => { + const app = createApp(); + const created = await request(app) + .post('/core/verifications') + .set('Authorization', 'Bearer test-token') + .send({ + type: 'certification', + subjectType: 'staff', + subjectId: 'staff_1', + fileUri: 'gs://krow-workforce-dev-private/uploads/test-user/cert.pdf', + rules: { certType: 'food_safety' }, + }); + + const status = await waitForMachineStatus(app, created.body.verificationId); + assert.equal(status.status, 200); + + const reviewed = await request(app) + .post(`/core/verifications/${created.body.verificationId}/review`) + .set('Authorization', 'Bearer test-token') + .send({ + decision: 'APPROVED', + note: 'Looks good', + reasonCode: 'MANUAL_REVIEW', + }); + + assert.equal(reviewed.status, 200); + assert.equal(reviewed.body.status, 'APPROVED'); + assert.equal(reviewed.body.review.decision, 'APPROVED'); +}); + +test('POST /core/verifications/:id/retry requeues verification', async () => { + const app = createApp(); + const created = await request(app) + .post('/core/verifications') + .set('Authorization', 'Bearer test-token') + .send({ + type: 'government_id', + subjectType: 'staff', + subjectId: 'staff_1', + fileUri: 'gs://krow-workforce-dev-private/uploads/test-user/id-front.jpg', + rules: {}, + }); + + const status = await waitForMachineStatus(app, created.body.verificationId); + assert.equal(status.status, 200); + + const retried = await request(app) + .post(`/core/verifications/${created.body.verificationId}/retry`) + .set('Authorization', 'Bearer test-token') + .send({}); + + assert.equal(retried.status, 202); + assert.equal(retried.body.status, 'PENDING'); +}); diff --git a/docs/MILESTONES/M4/planning/m4-api-catalog.md b/docs/MILESTONES/M4/planning/m4-api-catalog.md index 50e88137..516ebf38 100644 --- a/docs/MILESTONES/M4/planning/m4-api-catalog.md +++ b/docs/MILESTONES/M4/planning/m4-api-catalog.md @@ -128,7 +128,83 @@ This catalog defines the currently implemented core backend contract for M4. - `MODEL_FAILED` - `RATE_LIMITED` -## 3.4 Health +## 3.4 Create verification job +1. Method and route: `POST /core/verifications` +2. Auth: required +3. Request: +```json +{ + "type": "attire", + "subjectType": "worker", + "subjectId": "worker_123", + "fileUri": "gs://krow-workforce-dev-private/uploads//file.pdf", + "rules": {} +} +``` +4. Behavior: +- validates `fileUri` ownership +- requires file existence when `UPLOAD_MOCK=false` and `VERIFICATION_REQUIRE_FILE_EXISTS=true` +- enqueues async verification +5. Success `202`: +```json +{ + "verificationId": "ver_123", + "status": "PENDING", + "type": "attire", + "requestId": "uuid" +} +``` +6. Errors: +- `UNAUTHENTICATED` +- `VALIDATION_ERROR` +- `FORBIDDEN` +- `NOT_FOUND` + +## 3.5 Get verification status +1. Method and route: `GET /core/verifications/{verificationId}` +2. Auth: required +3. Success `200`: +```json +{ + "verificationId": "ver_123", + "status": "NEEDS_REVIEW", + "type": "attire", + "requestId": "uuid" +} +``` +4. Errors: +- `UNAUTHENTICATED` +- `FORBIDDEN` +- `NOT_FOUND` + +## 3.6 Review verification +1. Method and route: `POST /core/verifications/{verificationId}/review` +2. Auth: required +3. Request: +```json +{ + "decision": "APPROVED", + "note": "Manual review passed", + "reasonCode": "MANUAL_REVIEW" +} +``` +4. Success `200`: status becomes `APPROVED` or `REJECTED`. +5. Errors: +- `UNAUTHENTICATED` +- `VALIDATION_ERROR` +- `FORBIDDEN` +- `NOT_FOUND` + +## 3.7 Retry verification +1. Method and route: `POST /core/verifications/{verificationId}/retry` +2. Auth: required +3. Success `202`: status resets to `PENDING`. +4. Errors: +- `UNAUTHENTICATED` +- `FORBIDDEN` +- `NOT_FOUND` + +## 3.8 Health 1. Method and route: `GET /health` 2. Success `200`: ```json @@ -150,3 +226,7 @@ This catalog defines the currently implemented core backend contract for M4. 5. Max signed URL expiry: `900` seconds. 6. LLM timeout: `20000` ms. 7. LLM rate limit: `20` requests/minute/user. +8. Verification access mode default: `authenticated`. +9. Verification file existence check default: enabled (`VERIFICATION_REQUIRE_FILE_EXISTS=true`). +10. Verification attire provider default in dev: `vertex` with model `gemini-2.0-flash-lite-001`. +11. Verification government/certification providers: external adapters via configured provider URL/token. diff --git a/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md b/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md index ca34b112..64f8a5c2 100644 --- a/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md +++ b/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md @@ -118,6 +118,82 @@ Authorization: Bearer } ``` +## 4.4 Create verification job +1. Route: `POST /core/verifications` +2. Auth: required +3. Purpose: enqueue an async verification job for an uploaded file. +4. Request body: +```json +{ + "type": "attire", + "subjectType": "worker", + "subjectId": "", + "fileUri": "gs://krow-workforce-dev-private/uploads//file.pdf", + "rules": { + "dressCode": "black shoes" + } +} +``` +5. Success `202` example: +```json +{ + "verificationId": "ver_123", + "status": "PENDING", + "type": "attire", + "requestId": "uuid" +} +``` +6. Current machine processing behavior in dev: +- `attire`: live vision check using Vertex Gemini Flash Lite model. +- `government_id`: third-party adapter path (falls back to `NEEDS_REVIEW` if provider is not configured). +- `certification`: third-party adapter path (falls back to `NEEDS_REVIEW` if provider is not configured). + +## 4.5 Get verification status +1. Route: `GET /core/verifications/{verificationId}` +2. Auth: required +3. Purpose: polling status from frontend. +4. Success `200` example: +```json +{ + "verificationId": "ver_123", + "status": "NEEDS_REVIEW", + "type": "attire", + "review": null, + "requestId": "uuid" +} +``` + +## 4.6 Review verification +1. Route: `POST /core/verifications/{verificationId}/review` +2. Auth: required +3. Purpose: final human decision for the verification. +4. Request body: +```json +{ + "decision": "APPROVED", + "note": "Manual review passed", + "reasonCode": "MANUAL_REVIEW" +} +``` +5. Success `200` example: +```json +{ + "verificationId": "ver_123", + "status": "APPROVED", + "review": { + "decision": "APPROVED", + "reviewedBy": "" + }, + "requestId": "uuid" +} +``` + +## 4.7 Retry verification +1. Route: `POST /core/verifications/{verificationId}/retry` +2. Auth: required +3. Purpose: requeue verification to run again. +4. Success `202` example: status resets to `PENDING`. + ## 5) Frontend fetch examples (web) ## 5.1 Signed URL request @@ -163,5 +239,7 @@ const data = await res.json(); 2. Aliases exist only for migration compatibility. 3. `requestId` in responses should be logged client-side for debugging. 4. For 429 on model route, retry with exponential backoff and respect `Retry-After`. -5. Verification workflows (`attire`, `government_id`, `certification`) are defined in: +5. Verification routes are now available in dev under `/core/verifications*`. +6. Current verification processing is async and returns machine statuses first (`PENDING`, `PROCESSING`, `NEEDS_REVIEW`, etc.). +7. Full verification design and policy details: `docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md`. diff --git a/docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md b/docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md index 64612e73..59731ea3 100644 --- a/docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md +++ b/docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md @@ -1,9 +1,20 @@ # M4 Verification Architecture Contract (Attire, Government ID, Certification) -Status: Proposed (next implementation slice) +Status: Partially implemented in dev (core endpoints + async in-memory processor) Date: 2026-02-24 Owner: Technical Lead +## Implementation status today (dev) +1. Implemented routes: +- `POST /core/verifications` +- `GET /core/verifications/{verificationId}` +- `POST /core/verifications/{verificationId}/review` +- `POST /core/verifications/{verificationId}/retry` +2. Current processor is in-memory and non-persistent (for fast frontend integration in dev). +3. Next hardening step is persistent job storage and worker execution before staging. +4. Attire uses a live Vertex vision model path with `gemini-2.0-flash-lite-001` by default. +5. Government ID and certification use third-party adapter contracts (provider URL/token envs) and fall back to `NEEDS_REVIEW` when providers are not configured. + ## 1) Goal Define a single backend verification pipeline for: 1. `attire` @@ -196,6 +207,19 @@ Rules: 4. Log request and decision IDs for every transition. 5. For government ID, keep provider response reference and verification timestamp. +## 11) Provider configuration (environment variables) +1. Attire model: +- `VERIFICATION_ATTIRE_PROVIDER=vertex` +- `VERIFICATION_ATTIRE_MODEL=gemini-2.0-flash-lite-001` +2. Government ID provider: +- `VERIFICATION_GOV_ID_PROVIDER_URL` +- `VERIFICATION_GOV_ID_PROVIDER_TOKEN` (Secret Manager recommended) +3. Certification provider: +- `VERIFICATION_CERT_PROVIDER_URL` +- `VERIFICATION_CERT_PROVIDER_TOKEN` (Secret Manager recommended) +4. Provider timeout: +- `VERIFICATION_PROVIDER_TIMEOUT_MS` (default `8000`) + ## 9) Frontend integration pattern 1. Upload file via existing `POST /core/upload-file`. 2. Create verification job with returned `fileUri`. diff --git a/makefiles/backend.mk b/makefiles/backend.mk index 79b38bf8..5ee113c0 100644 --- a/makefiles/backend.mk +++ b/makefiles/backend.mk @@ -31,6 +31,8 @@ BACKEND_CORE_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKE BACKEND_COMMAND_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKEND_ARTIFACT_REPO)/command-api:latest BACKEND_LOG_LIMIT ?= 100 BACKEND_LLM_MODEL ?= gemini-2.0-flash-001 +BACKEND_VERIFICATION_ATTIRE_MODEL ?= gemini-2.0-flash-lite-001 +BACKEND_VERIFICATION_PROVIDER_TIMEOUT_MS ?= 8000 BACKEND_MAX_SIGNED_URL_SECONDS ?= 900 BACKEND_LLM_RATE_LIMIT_PER_MINUTE ?= 20 @@ -131,7 +133,7 @@ backend-deploy-core: --region=$(BACKEND_REGION) \ --project=$(GCP_PROJECT_ID) \ --service-account=$(BACKEND_RUNTIME_SA_EMAIL) \ - --set-env-vars=APP_ENV=$(ENV),GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_PRIVATE_BUCKET),UPLOAD_MOCK=false,SIGNED_URL_MOCK=false,LLM_MOCK=false,LLM_LOCATION=$(BACKEND_REGION),LLM_MODEL=$(BACKEND_LLM_MODEL),LLM_TIMEOUT_MS=20000,MAX_SIGNED_URL_SECONDS=$(BACKEND_MAX_SIGNED_URL_SECONDS),LLM_RATE_LIMIT_PER_MINUTE=$(BACKEND_LLM_RATE_LIMIT_PER_MINUTE) \ + --set-env-vars=APP_ENV=$(ENV),GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_PRIVATE_BUCKET),UPLOAD_MOCK=false,SIGNED_URL_MOCK=false,LLM_MOCK=false,LLM_LOCATION=$(BACKEND_REGION),LLM_MODEL=$(BACKEND_LLM_MODEL),LLM_TIMEOUT_MS=20000,MAX_SIGNED_URL_SECONDS=$(BACKEND_MAX_SIGNED_URL_SECONDS),LLM_RATE_LIMIT_PER_MINUTE=$(BACKEND_LLM_RATE_LIMIT_PER_MINUTE),VERIFICATION_ACCESS_MODE=authenticated,VERIFICATION_REQUIRE_FILE_EXISTS=true,VERIFICATION_ATTIRE_PROVIDER=vertex,VERIFICATION_ATTIRE_MODEL=$(BACKEND_VERIFICATION_ATTIRE_MODEL),VERIFICATION_PROVIDER_TIMEOUT_MS=$(BACKEND_VERIFICATION_PROVIDER_TIMEOUT_MS) \ $(BACKEND_RUN_AUTH_FLAG) @echo "✅ Core backend service deployed."