The /api/v1/teams endpoint returns 193 teams with nested objects and can take longer than 15s to respond under load. Token acquisition succeeds within 500ms but subsequent data calls were hitting the 15s timeout.
308 lines
11 KiB
JavaScript
308 lines
11 KiB
JavaScript
// 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',
|
|
family: 4, // Force IPv4 — IPv6 is unreachable from this network
|
|
headers: {
|
|
'accept': 'application/json',
|
|
'authorization': 'Basic ' + authString,
|
|
'content-length': '0',
|
|
},
|
|
timeout: timeout || 30000,
|
|
};
|
|
|
|
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) || 30000;
|
|
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,
|
|
family: 4, // Force IPv4 — IPv6 is unreachable from this network
|
|
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,
|
|
};
|