import { randomUUID } from 'node:crypto'; // Server-side reference core. The runner supplies client.verificationApi. // These structural types describe the subset used here; they are also checked // against the onboarding SDK by scripts/developer-quickstart/qualify.mjs. export type CreateInput = { workflowId: string; executionMode: 'demo'; requiredAge: 18; idempotencyKey: string; context: { country: null; resource: string; device: 'desktop' }; returnUrl?: string; cancelUrl?: string; }; export type Session = { sessionId: string; status: 'pending'; expiresAt: string; verificationUrl: string; executionMode?: 'demo' | 'production'; generation?: number; journey?: { type: 'hosted'; provider: 'yoti'; url: string }; }; export type Status = { sessionId: string; status: 'pending' | 'completed' | 'failed' | 'expired' | 'declined'; result?: 'pass' | 'fail'; expiresAt: string; nextAction?: Session; policy?: { active: boolean; workflowId: string; workflowVersionId: string; executionMode: 'demo' | 'production'; }; }; export type Effect = { idempotencyKey: string; resource: string; decision: 'allow' | 'deny'; guardVersion: string; occurredAt: string; }; export type Receipt = { id: string; evidenceClass: 'ATTESTED'; recordedAt: string; witness: { recordId: string; chainEntryId: string; projectionHash: string }; }; export type Gateway = { create(input: CreateInput): Promise; getVerificationStatus(sessionId: string): Promise; recordResourceDecision(sessionId: string, effect: Effect): Promise; }; export type CreationIntent = { request: CreateInput; workflowVersionId: string; }; export type Binding = { sessionId: string; workflowId: string; workflowVersionId: string; resource: string; executionMode: 'demo'; }; export type Decision = { sessionId: string; effect: Effect; reason: string }; // Persist this BEFORE calling startCheck. Retry the same request and key. export function newCheck( workflowId: string, workflowVersionId: string, redirects: { returnUrl?: string; cancelUrl?: string } = {} ): CreationIntent { return { workflowVersionId, request: { workflowId, executionMode: 'demo', requiredAge: 18, idempotencyKey: randomUUID(), context: { country: null, resource: 'restricted-area', device: 'desktop', }, ...(redirects.returnUrl ? { returnUrl: redirects.returnUrl } : {}), ...(redirects.cancelUrl ? { cancelUrl: redirects.cancelUrl } : {}), }, }; } export async function startCheck(api: Gateway, intent: CreationIntent) { const session = await api.create(intent.request); validateAction(session); const binding: Binding = { sessionId: session.sessionId, workflowId: intent.request.workflowId, workflowVersionId: intent.workflowVersionId, resource: intent.request.context.resource, executionMode: 'demo', }; return { binding, session }; } // Load binding from trusted server storage, never from a browser request. // Evaluate before presenting a continuation or making an access decision. export async function reviewCheck(api: Gateway, binding: Binding) { const result = await api.getVerificationStatus(binding.sessionId); if (!result || result.sessionId !== binding.sessionId) { throw new Error( 'Response does not match the stored session. Keep access closed.' ); } const policy = result.policy; const expiresAt = Date.parse(result.expiresAt); let reason: string | undefined; if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) reason = 'expired'; else if ( binding.executionMode !== 'demo' || policy?.active !== true || policy.workflowId !== binding.workflowId || policy.workflowVersionId !== binding.workflowVersionId || policy.executionMode !== binding.executionMode ) reason = 'policy_mismatch'; if (!reason && result.status === 'pending') { if (result.nextAction) { validateAction(result.nextAction, binding.sessionId); return { state: 'continue' as const, session: result.nextAction }; } return { state: 'pending' as const }; } if (result.nextAction && result.status !== 'pending') reason = 'inconsistent_status'; const allow = !reason && result.status === 'completed' && result.result === 'pass'; return { state: 'decision' as const, decision: { sessionId: binding.sessionId, reason: allow ? 'verified_demo' : (reason ?? 'verification_not_passed'), effect: { idempotencyKey: randomUUID(), resource: binding.resource, decision: allow ? ('allow' as const) : ('deny' as const), guardVersion: 'developer-demo-v2', occurredAt: new Date().toISOString(), }, }, }; } // Persist the entire Decision before sending it. A retry ONLY resends that // immutable payload; it must not re-evaluate and reuse its key for another result. export async function witnessDecision(api: Gateway, decision: Decision) { const receipt = await api.recordResourceDecision( decision.sessionId, decision.effect ); if ( !receipt || typeof receipt.id !== 'string' || !receipt.id || receipt.evidenceClass !== 'ATTESTED' || !Number.isFinite(Date.parse(receipt.recordedAt)) || typeof receipt.witness?.recordId !== 'string' || !receipt.witness.recordId || typeof receipt.witness.chainEntryId !== 'string' || !receipt.witness.chainEntryId || !/^[a-f0-9]{64}$/.test(receipt.witness.projectionHash) ) throw new Error('A valid witness receipt is required. Keep access closed.'); return receipt; } function validateAction(session: Session, expectedSessionId?: string) { if ( !session || typeof session.sessionId !== 'string' || !session.sessionId || (expectedSessionId !== undefined && session.sessionId !== expectedSessionId) || session.status !== 'pending' || session.executionMode !== 'demo' || !Number.isFinite(Date.parse(session.expiresAt)) || Date.parse(session.expiresAt) <= Date.now() ) throw new Error( 'Invalid demo journey. Check onboarding configuration; keep access closed.' ); const url = new URL(session.journey?.url ?? session.verificationUrl); if ( url.username || url.password || !(session.journey ? url.protocol === 'https:' : ['https:', 'openid4vp:'].includes(url.protocol)) ) { throw new Error('Unsupported verification URL. Keep access closed.'); } } // Safe diagnostics: do not print raw SDK errors, request bodies or API keys. export function failure(error: unknown) { const e = error as { status?: unknown; type?: unknown; retryAfter?: unknown; name?: unknown; } | null; const status = typeof e?.status === 'number' ? e.status : undefined; const retryable = status === 408 || status === 429 || (status !== undefined && status >= 500) || (status === 409 && typeof e?.type === 'string' && e.type.endsWith('/idempotency-pending')) || e?.name === 'NexionNetworkError' || e?.name === 'NexionTimeoutError'; return { state: retryable ? ('retry' as const) : ('blocked' as const), code: status ? `http_${status}` : 'transport_or_contract_error', retryAfterSeconds: Math.max( 2, typeof e?.retryAfter === 'number' && Number.isFinite(e.retryAfter) ? Math.ceil(e.retryAfter) : 5 ), }; }