/** * Batch credential issuance endpoint */ import { FastifyInstance } from 'fastify'; import { randomUUID } from 'crypto'; import { KMSClient } from '@the-order/crypto'; import { createVerifiableCredential } from '@the-order/database'; import { logCredentialAction } from '@the-order/database'; import { getEnv, authenticateJWT, requireRole } from '@the-order/shared'; export interface BatchIssuanceRequest { credentials: Array<{ subject: string; credentialSubject: Record; expirationDate?: string; }>; } export interface BatchIssuanceResponse { jobId: string; total: number; accepted: number; results: Array<{ index: number; credentialId?: string; error?: string; }>; } /** * Register batch issuance endpoint */ export async function registerBatchIssuance( server: FastifyInstance, kmsClient: KMSClient ): Promise { const env = getEnv(); const issuerDid = env.VC_ISSUER_DID || (env.VC_ISSUER_DOMAIN ? `did:web:${env.VC_ISSUER_DOMAIN}` : undefined); server.post( '/vc/issue/batch', { preHandler: [authenticateJWT, requireRole('admin', 'issuer')], schema: { body: { type: 'object', required: ['credentials'], properties: { credentials: { type: 'array', minItems: 1, maxItems: 100, // Limit batch size items: { type: 'object', required: ['subject', 'credentialSubject'], properties: { subject: { type: 'string' }, credentialSubject: { type: 'object' }, expirationDate: { type: 'string', format: 'date-time' }, }, }, }, }, }, description: 'Batch issue verifiable credentials', tags: ['credentials'], response: { 200: { type: 'object', properties: { jobId: { type: 'string' }, total: { type: 'number' }, accepted: { type: 'number' }, results: { type: 'array', items: { type: 'object', properties: { index: { type: 'number' }, credentialId: { type: 'string' }, error: { type: 'string' }, }, }, }, }, }, }, }, }, // eslint-disable-next-line @typescript-eslint/no-explicit-any async (request: any, reply: any) => { if (!issuerDid) { return reply.code(500).send({ error: { code: 'CONFIGURATION_ERROR', message: 'VC_ISSUER_DID or VC_ISSUER_DOMAIN must be configured', }, }); } const { credentials } = request.body; const jobId = randomUUID(); const results: BatchIssuanceResponse['results'] = []; let accepted = 0; // Get user ID for audit logging const user = (request as any).user; const userId = user?.id || user?.sub || null; // Process each credential for (let i = 0; i < credentials.length; i++) { const cred = credentials[i]!; try { const credentialId = randomUUID(); const issuanceDate = new Date(); const expirationDate = cred.expirationDate ? new Date(cred.expirationDate) : undefined; // Create credential data const credentialData = { id: credentialId, type: ['VerifiableCredential', 'IdentityCredential'], issuer: issuerDid, subject: cred.subject, credentialSubject: cred.credentialSubject, issuanceDate: issuanceDate.toISOString(), expirationDate: expirationDate?.toISOString(), }; // Sign credential with KMS const credentialJson = JSON.stringify(credentialData); const signature = await kmsClient.sign(Buffer.from(credentialJson)); // Create proof const proof = { type: 'KmsSignature2024', created: issuanceDate.toISOString(), proofPurpose: 'assertionMethod', verificationMethod: `${issuerDid}#kms-key`, jws: signature.toString('base64'), }; const credential = { ...credentialData, proof, }; // Save to database await createVerifiableCredential({ credential_id: credentialId, issuer_did: issuerDid, subject_did: cred.subject, credential_type: credential.type, credential_subject: cred.credentialSubject, issuance_date: issuanceDate, expiration_date: expirationDate, proof, }); // Log audit action await logCredentialAction({ credential_id: credentialId, issuer_did: issuerDid, subject_did: cred.subject, credential_type: credential.type, action: 'issued', performed_by: userId || undefined, metadata: { batchJobId: jobId, batchIndex: i }, ip_address: request.ip, user_agent: request.headers['user-agent'], }); results.push({ index: i, credentialId, }); accepted++; } catch (error) { results.push({ index: i, error: error instanceof Error ? error.message : 'Unknown error', }); } } return reply.send({ jobId, total: credentials.length, accepted, results, }); } ); }