// Shared CARD API helpers // Centralizes HTTP calls for the CARD asset ownership API. // Follows the same promise-based pattern as atlasApi.js, with the addition // of OAuth Bearer token management (auto-acquire, cache, refresh, 401 retry). // // CARD API versioning: // - Read endpoints (GET): /api/v1/... // - Mutation endpoints (POST): /api/v2/... const https = require('https'); const http = require('http'); // --------------------------------------------------------------------------- // Configuration — read from process.env at module load // --------------------------------------------------------------------------- const CARD_API_URL = process.env.CARD_API_URL || ''; const CARD_API_USER = process.env.CARD_API_USER || ''; const CARD_API_PASS = process.env.CARD_API_PASS || ''; const CARD_SKIP_TLS = process.env.CARD_SKIP_TLS === 'true'; const requiredVars = ['CARD_API_URL', 'CARD_API_USER', 'CARD_API_PASS']; const missingVars = requiredVars.filter((v) => !process.env[v]); if (missingVars.length > 0) { console.warn(`[card-api] WARNING: Missing required environment variables: ${missingVars.join(', ')}. CARD API calls will fail.`); } const isConfigured = missingVars.length === 0; // --------------------------------------------------------------------------- // Token Manager — OAuth Bearer token with 1-hour TTL // --------------------------------------------------------------------------- let cachedToken = null; // { token: string, expiresAt: number (epoch ms) } function tokenIsValid() { if (!cachedToken) return false; // Refresh if within 60 seconds of expiry return cachedToken.expiresAt - Date.now() > 60_000; } function invalidateToken() { cachedToken = null; } /** * Acquire a new Bearer token from CARD /api/v1/auth/get_token using Basic Auth. * Caches the token in memory with a 1-hour TTL. */ function acquireToken(timeout) { const authString = Buffer.from(CARD_API_USER + ':' + CARD_API_PASS).toString('base64'); const fullUrl = new URL(CARD_API_URL + '/api/v1/auth/get_token'); const isHttps = fullUrl.protocol === 'https:'; const transport = isHttps ? https : http; return new Promise((resolve, reject) => { const reqOptions = { hostname: fullUrl.hostname, port: fullUrl.port || (isHttps ? 443 : 80), path: fullUrl.pathname + fullUrl.search, method: 'POST', headers: { 'accept': 'application/json', 'authorization': 'Basic ' + authString, 'content-length': '0', }, timeout: timeout || 15000, }; if (isHttps) { reqOptions.rejectUnauthorized = !CARD_SKIP_TLS; } const req = transport.request(reqOptions, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { if (res.statusCode < 200 || res.statusCode >= 300) { return reject(new Error( `[card-api] Token acquisition failed with HTTP ${res.statusCode}: ${data.substring(0, 500)}` )); } // The CARD API returns the token as a JSON string or object. // Try to parse; fall back to raw body as the token string. let token; try { const parsed = JSON.parse(data); token = typeof parsed === 'string' ? parsed : parsed.token || parsed.access_token || data.trim(); } catch (_) { // Response may be a plain token string (unquoted) token = data.trim(); } if (!token) { return reject(new Error('[card-api] Token parse failure: empty token in response body.')); } cachedToken = { token, expiresAt: Date.now() + 60 * 60 * 1000, // 1-hour TTL }; resolve(cachedToken.token); }); }); req.on('timeout', () => req.destroy(new Error('GET /api/v1/auth/get_token timed out'))); req.on('error', (err) => { reject(new Error(`[card-api] GET /api/v1/auth/get_token failed: ${err.message}`)); }); req.end(); }); } /** * Ensure we have a valid Bearer token, acquiring or refreshing as needed. */ async function ensureToken(timeout) { if (tokenIsValid()) return cachedToken.token; return acquireToken(timeout); } // --------------------------------------------------------------------------- // Generic request — supports GET and POST with Bearer auth + 401 retry // --------------------------------------------------------------------------- async function cardRequest(method, urlPath, body, options) { const timeout = (options && options.timeout) || 15000; const skipAuth = (options && options.skipAuth) || false; async function doRequest(bearerToken) { const fullUrl = new URL(CARD_API_URL + urlPath); const isHttps = fullUrl.protocol === 'https:'; const transport = isHttps ? https : http; const headers = { 'accept': 'application/json' }; if (bearerToken) { headers['authorization'] = 'Bearer ' + bearerToken; } let bodyStr = null; if (body !== null && body !== undefined) { bodyStr = JSON.stringify(body); headers['content-type'] = 'application/json'; headers['content-length'] = Buffer.byteLength(bodyStr); } return new Promise((resolve, reject) => { const reqOptions = { hostname: fullUrl.hostname, port: fullUrl.port || (isHttps ? 443 : 80), path: fullUrl.pathname + fullUrl.search, method, headers, timeout, }; if (isHttps) { reqOptions.rejectUnauthorized = !CARD_SKIP_TLS; } const req = transport.request(reqOptions, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => resolve({ status: res.statusCode, body: data })); }); req.on('timeout', () => req.destroy(new Error(`${method} ${urlPath} timed out`))); req.on('error', (err) => { reject(new Error(`[card-api] ${method} ${urlPath} failed: ${err.message}`)); }); if (bodyStr) req.write(bodyStr); req.end(); }); } // Skip auth for the token endpoint itself if (skipAuth) { return doRequest(null); } // Normal flow: ensure token → request → retry once on 401 let token = await ensureToken(timeout); let result = await doRequest(token); if (result.status === 401) { // Invalidate and retry exactly once invalidateToken(); token = await ensureToken(timeout); result = await doRequest(token); } return result; } // --------------------------------------------------------------------------- // Convenience wrappers // --------------------------------------------------------------------------- function cardGet(urlPath, options) { return cardRequest('GET', urlPath, null, options); } function cardPost(urlPath, body, options) { return cardRequest('POST', urlPath, body, options); } // --------------------------------------------------------------------------- // High-level helpers used by the UAT test and routes // --------------------------------------------------------------------------- /** * Test connection by acquiring a token. Returns { ok, token } or { ok, error }. */ async function testConnection() { try { const token = await acquireToken(); return { ok: true, token: token.substring(0, 12) + '...' }; } catch (err) { return { ok: false, error: err.message }; } } /** * GET /api/v1/teams — list all CARD teams. */ async function getTeams() { const res = await cardGet('/api/v1/teams'); return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 }; } /** * GET /api/v1/team/{teamName}/assets — list assets for a team. */ async function getTeamAssets(teamName, { disposition, page, pageSize } = {}) { const params = new URLSearchParams(); if (disposition) params.set('disposition', disposition); if (page) params.set('page', String(page)); params.set('page_size', String(pageSize || 50)); const qs = params.toString(); const res = await cardGet(`/api/v1/team/${encodeURIComponent(teamName)}/assets${qs ? '?' + qs : ''}`); return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 }; } /** * GET /api/v1/owner/{assetId} — get owner record including update_token. */ async function getOwner(assetId) { const res = await cardGet(`/api/v1/owner/${encodeURIComponent(assetId)}`); return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 }; } /** * POST /api/v2/owner/{assetId}/confirm — confirm asset to a team. */ async function confirmAsset(assetId, teamName, updateToken, comment) { const params = new URLSearchParams({ update_token: updateToken }); if (comment) params.set('comment', comment); const res = await cardPost( `/api/v2/owner/${encodeURIComponent(assetId)}/confirm?${params.toString()}`, { name: teamName } ); return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 }; } /** * POST /api/v2/owner/{assetId}/decline — decline asset from a team. */ async function declineAsset(assetId, teamName, updateToken, comment) { const params = new URLSearchParams({ update_token: updateToken }); if (comment) params.set('comment', comment); const res = await cardPost( `/api/v2/owner/${encodeURIComponent(assetId)}/decline?${params.toString()}`, { name: teamName } ); return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 }; } /** * POST /api/v2/owner/{assetId}/{fromTeam}/redirect — redirect asset between teams. */ async function redirectAsset(assetId, fromTeam, toTeam, updateToken) { const params = new URLSearchParams({ update_token: updateToken }); const res = await cardPost( `/api/v2/owner/${encodeURIComponent(assetId)}/${encodeURIComponent(fromTeam)}/redirect?${params.toString()}`, { name: toTeam } ); return { status: res.status, body: res.body, ok: res.status >= 200 && res.status < 300 }; } module.exports = { isConfigured, missingVars, cardRequest, cardGet, cardPost, testConnection, getTeams, getTeamAssets, getOwner, confirmAsset, declineAsset, redirectAsset, invalidateToken, };