// Local, single-operator DEMO driver. Do not deploy this as a customer server. import process from 'node:process'; import console from 'node:console'; import { URL } from 'node:url'; import { createHash, randomUUID } from 'node:crypto'; import { mkdir, open, readFile, rename, unlink, writeFile, } from 'node:fs/promises'; import { resolve } from 'node:path'; import QRCode from 'qrcode'; import { NexionClient } from '@nexionplatform/sdk'; import { newCheck, startCheck, reviewCheck, witnessDecision, failure, } from './verification.ts'; process.umask(0o077); const [command, attempt] = process.argv.slice(2); if ( !['start', 'check'].includes(command) || !/^[a-zA-Z0-9_-]{1,64}$/.test(attempt ?? '') ) { console.error( 'Usage: node --env-file=.env run.mjs ' ); process.exit(1); } function required(name) { const value = process.env[name]?.trim(); if (!value || value.startsWith('REPLACE_')) throw new Error(`Missing ${name}. Fill in the onboarding value in .env.`); return value; } const endpoint = new URL(required('NEXION_API_URL')); if ( endpoint.protocol !== 'https:' || endpoint.username || endpoint.password || endpoint.search || endpoint.hash || endpoint.pathname !== '/' ) { throw new Error( 'Use the HTTPS API origin supplied during onboarding, without a path.' ); } // BEGIN client const client = new NexionClient({ apiKey: required('NEXION_API_KEY'), baseUrl: endpoint.origin, timeout: 10_000, }); const api = client.verificationApi; // END client const workflowId = required('NEXION_WORKFLOW_ID'); const workflowVersionId = required('NEXION_WORKFLOW_VERSION_ID'); const configHash = createHash('sha256') .update( JSON.stringify([ endpoint.origin, required('NEXION_API_KEY'), workflowId, workflowVersionId, ]) ) .digest('hex'); const redirects = {}; for (const [env, field] of [ ['NEXION_RETURN_URL', 'returnUrl'], ['NEXION_CANCEL_URL', 'cancelUrl'], ]) { if (process.env[env]) { const url = new URL(process.env[env]); if (url.protocol !== 'https:' || url.username || url.password) throw new Error('Redirects must use HTTPS'); redirects[field] = url.href; } } const directory = resolve('state'); await mkdir(directory, { recursive: true, mode: 0o700 }); const path = resolve(directory, `${attempt}.json`); const lockPath = `${path}.lock`; let lock; try { lock = await open(lockPath, 'wx', 0o600); await lock.writeFile(String(process.pid)); } catch { console.error( 'Attempt is locked. Stop the other process before removing its .lock file.' ); process.exit(1); } // Atomic replacement and fsync keep the saved intent recoverable after a lost // HTTP response or process restart. For a real service, use a transactional DB. async function save(value) { const temp = `${path}.${randomUUID()}.tmp`; const handle = await open(temp, 'wx', 0o600); try { await handle.writeFile(JSON.stringify(value, null, 2) + '\n'); await handle.sync(); } finally { await handle.close(); } await rename(temp, path); const parent = await open(directory, 'r'); try { await parent.sync(); } finally { await parent.close(); } } async function showJourney(session) { if (Date.parse(session.expiresAt) <= Date.now()) { console.log( 'Journey expired. Run check to record the denial, then start a new attempt.' ); return; } if (session.journey) { console.log(`Open this hosted verification URL: ${session.journey.url}`); } else { const png = resolve(directory, `${attempt}-qr.png`); await writeFile( png, await QRCode.toBuffer(session.verificationUrl, { width: 600, margin: 4 }), { mode: 0o600 } ); console.log(`Open ${png} and scan with the onboarded Lognium app.`); } console.log( 'After completing the check, run the check command. A browser return is not proof of success.' ); } let state; try { try { state = JSON.parse(await readFile(path, 'utf8')); } catch (error) { if (error.code !== 'ENOENT') throw error; } if (!state) { if (command !== 'start') throw new Error('Start this attempt first'); state = { schema: 1, configHash, intent: newCheck(workflowId, workflowVersionId, redirects), }; await save(state); // Must precede the first network request. } if (state.schema !== 1 || state.configHash !== configHash) { throw new Error( 'Do not change the API key, endpoint or workflow for an existing attempt' ); } if (state.retryAt && Date.now() < Date.parse(state.retryAt)) { console.log(`Wait until ${state.retryAt} before retrying this attempt.`); process.exitCode = 2; } else { if (!state.binding) { const started = await startCheck(api, state.intent); Object.assign(state, started); await save(state); // Bind before exposing any journey to the visitor. } if (command === 'start' && !state.decision) { await showJourney(state.session); } else { if (!state.decision) { const review = await reviewCheck(api, state.binding); if (review.state === 'pending') { console.log( 'pending — no access decision yet. Check again in at least 2 seconds.' ); state.retryAt = new Date(Date.now() + 2000).toISOString(); await save(state); } else if (review.state === 'continue') { state.session = review.session; state.retryAt = new Date(Date.now() + 2000).toISOString(); await save(state); await showJourney(review.session); } else { state.decision = review.decision; await save(state); // Durable outbox; never change this effect on retry. } } if (state.decision) { if (state.decision.effect.decision === 'deny') { console.log( 'DENY — recorded locally. No access is allowed, even if witnessing fails.' ); } if (!state.receipt) { state.receipt = await witnessDecision(api, state.decision); state.retryAt = undefined; await save(state); } console.log( JSON.stringify( { state: 'demo_decision_recorded', decision: state.decision.effect.decision, sessionId: state.binding.sessionId, receipt: state.receipt, }, null, 2 ) ); console.log( 'This is a DEMO decision receipt, not an access token or proof of content delivery.' ); } } } } catch (error) { const detail = failure(error); if (state) { state.lastFailure = { ...detail, at: new Date().toISOString() }; state.retryAt = detail.state === 'retry' ? new Date(Date.now() + detail.retryAfterSeconds * 1000).toISOString() : undefined; await save(state); } console.error(JSON.stringify(detail)); console.error( 'Access stays closed. Keep this attempt file; retry it unchanged or resolve the configuration error.' ); process.exitCode = detail.state === 'retry' ? 2 : 1; } finally { await lock.close(); await unlink(lockPath); }