apiV4Js constant
String
const apiV4Js
API v4 console JavaScript code
Implementation
static const String apiV4Js = r'''(function () {
const REGISTRAR_ENVS = {
dev: 'https://my.atsign.wtf',
prod: 'https://my.atsign.com'
};
const STATUS_ENVS = {
dev: 'https://directory.atsign.wtf',
prod: 'https://wavi.ng'
};
const REQUEST_TIMEOUT_MS = 15000;
const REGISTER_ATSIGNS_ENDPOINT = {
id: 'register-atsigns',
title: 'Register Atsigns',
method: 'POST',
baseType: 'registrar',
path: '/api/app/v4/register-atsign/',
authRequired: true,
type: 'batch-register',
params: [
{
name: 'atSigns',
label: 'Atsigns',
required: true,
type: 'textarea',
placeholder: 'meow01\nmeow02\nmeow03',
help: 'One Atsign per line. Do not include the prefix/postfix. The prefix/postfix is already implied by the Authorization API Key. So for example, entering "test01", "test02", and "test03" as your Atsigns will provision "test01_np", "test02_np", and "test03_np". Do not include the @ symbol.'
},
{ name: 'startAtServer', required: false, type: 'select', options: ['true', 'false'], defaultValue: 'true', help: 'Optional' }
]
};
const PRIMARY_ENDPOINTS = [
{
id: 'lookup-atsign-v4',
title: 'Lookup an Atsign',
method: 'POST',
baseType: 'registrar',
path: '/api/app/v4/register-atsign/',
authRequired: true,
params: [
{
name: 'atSign',
label: 'Atsign',
required: false,
type: 'text',
placeholder: 'meow01',
help: 'Optional. Do not include the prefix/postfix. The prefix/postfix is already implied by the Authorization API Key. So for example, entering "test01" as your Atsign may provision "test01_np". Do not include the @ symbol.'
},
{ name: 'operation', required: true, type: 'select', options: ['lookup'], defaultValue: 'lookup', disabled: true, help: 'Required' },
{ name: 'startAtServer', required: false, type: 'select', options: ['true', 'false'], defaultValue: 'true', help: 'Optional' }
]
},
{
id: 'reset-atsigns',
title: 'Reset Atsigns',
method: 'POST',
baseType: 'registrar',
path: '/api/app/v4/reset-atsign/',
authRequired: true,
type: 'batch-reset',
params: [
{
name: 'atSigns',
label: 'Atsigns',
required: true,
type: 'textarea',
placeholder: 'meow01_jttest\nmeow02_jttest',
help: 'One Atsign per line. Include the full prefix/postfix. For example, if your postfix is _jttest, enter meow01_jttest. This intentionally differs from the register endpoint.'
}
]
},
{
id: 'delete-atsigns',
title: 'Delete Atsigns',
method: 'POST',
baseType: 'registrar',
path: '/api/app/v4/manage-atsigns',
authRequired: true,
type: 'delete-atsigns',
params: [
{
name: 'atSigns',
label: 'Atsigns',
required: true,
type: 'textarea',
placeholder: 'meow01_jttest\nmeow02_jttest',
help: 'Required. One Atsign per line. Include the full prefix/postfix, the same way the reset endpoint does. The Authorization API Key can only delete Atsigns it created, so anything else comes back as skipped. Do not include the @ symbol. A name that has already been deleted once is refused on later deletes, so avoid reusing one.'
}
]
}
];
const OTHER_ENDPOINTS = [
{
id: 'get-free-atsign-get',
title: 'Get Free Atsign',
method: 'GET',
baseType: 'registrar',
path: '/api/app/v4/get-free-atsign/',
authRequired: true,
params: []
},
{
id: 'get-free-atsign-post',
title: 'Get Free Atsign by Category',
method: 'POST',
baseType: 'registrar',
path: '/api/app/v4/get-free-atsign/',
authRequired: true,
params: [
{ name: 'category', required: false, type: 'csv-array', placeholder: 'animals,movies', help: 'Comma-separated list' }
]
},
{
id: 'register-person',
title: 'Register Person',
method: 'POST',
baseType: 'registrar',
path: '/api/app/v4/register-person/',
authRequired: true,
params: [
{ name: 'atsign', label: 'Atsign', required: true, type: 'text', placeholder: 'wisefrog', help: 'Generated free Atsign' },
{ name: 'email', required: true, type: 'text', placeholder: 'user@example.com', help: 'Required' },
{ name: 'oldEmail', required: false, type: 'text', placeholder: 'old@example.com', help: 'Optional' }
]
},
{
id: 'validate-person',
title: 'Validate Person',
method: 'POST',
baseType: 'registrar',
path: '/api/app/v4/validate-person/',
authRequired: true,
params: [
{ name: 'atsign', label: 'Atsign', required: true, type: 'text', placeholder: 'wisefrog', help: 'Required' },
{ name: 'email', required: true, type: 'text', placeholder: 'user@example.com', help: 'Required' },
{ name: 'otp', required: true, type: 'text', placeholder: 'AB12', help: '4-character code' },
{ name: 'confirmation', required: false, type: 'boolean', placeholder: 'false', help: 'Optional, defaults to false' }
]
},
{
id: 'authenticate-person-atsign',
title: 'Authenticate Person with Atsign',
method: 'POST',
baseType: 'registrar',
path: '/api/app/v4/authenticate/atsign',
authRequired: true,
params: [
{ name: 'atsign', label: 'Atsign', required: true, type: 'text', placeholder: '@alice', help: 'Registered Atsign' }
]
},
{
id: 'validate-person-atsign-otp',
title: 'Validate Person with Atsign and OTP',
method: 'POST',
baseType: 'registrar',
path: '/api/app/v4/authenticate/atsign/activate',
authRequired: true,
params: [
{ name: 'atsign', label: 'Atsign', required: true, type: 'text', placeholder: '@alice', help: 'Registered Atsign' },
{ name: 'otp', required: true, type: 'text', placeholder: 'AB12', help: '4-character code' }
]
},
{
id: 'get-atsign-v3',
title: 'Get Atsign and ActivationKey (v3)',
method: 'POST',
baseType: 'registrar',
path: '/api/app/v3/get-atsign/',
authRequired: true,
params: [
{ name: 'atSign', label: 'Atsign', required: false, type: 'text', placeholder: 'embergarden', help: 'Optional' },
{ name: 'ActivationKey', required: false, type: 'text', placeholder: '4V3GbA4u', help: 'Optional' }
]
},
{
id: 'get-atsign-v4',
title: 'Get Atsign and ActivationKey (v4)',
method: 'POST',
baseType: 'registrar',
path: '/api/app/v4/get-atsign/',
authRequired: true,
params: [
{ name: 'atSign', label: 'Atsign', required: false, type: 'text', placeholder: 'emberly', help: 'Optional' },
{ name: 'ActivationKey', required: false, type: 'text', placeholder: '4V3GbA4u', help: 'Optional' }
]
},
{
id: 'activate-atsign',
title: 'Activate an Atsign',
method: 'POST',
baseType: 'registrar',
path: '/api/app/v4/activate-atsign/',
authRequired: false,
params: [
{ name: 'atSign', label: 'Atsign', required: true, type: 'text', placeholder: '@alice', help: 'Required' },
{ name: 'activationKey', required: true, type: 'text', placeholder: '4V3GbA4u', help: 'Required' }
]
},
{
id: 'register-atsign-v3',
title: 'Register an Atsign (v3)',
method: 'POST',
baseType: 'registrar',
path: '/api/app/v3/register-atsign/',
authRequired: true,
params: [
{ name: 'atSign', label: 'Atsign', required: false, type: 'text', placeholder: 'mydog_01', help: 'Optional' },
{ name: 'operation', required: true, type: 'select', options: ['lookup', 'register'], help: 'Required' }
]
}
];
const ALL_ENDPOINTS = [...PRIMARY_ENDPOINTS, ...OTHER_ENDPOINTS];
function envOptions(baseType) {
const source = baseType === 'status' ? STATUS_ENVS : REGISTRAR_ENVS;
return Object.keys(source)
.map((key) => `<option value="${key}" ${key === 'prod' ? 'selected' : ''}>${key} - ${source[key]}</option>`)
.join('');
}
function resolveBase(baseType, env) {
return baseType === 'status' ? STATUS_ENVS[env] : REGISTRAR_ENVS[env];
}
function paramInput(param) {
if (param.type === 'select') {
return `
<select data-param-name="${param.name}" ${param.disabled ? 'disabled' : ''}>
<option value=""></option>
${param.options.map((opt) => `<option value="${opt}" ${param.defaultValue === opt ? 'selected' : ''}>${opt}</option>`).join('')}
</select>
`;
}
if (param.type === 'boolean') {
return `
<select data-param-name="${param.name}">
<option value=""></option>
<option value="true">true</option>
<option value="false">false</option>
</select>
`;
}
if (param.type === 'textarea') {
return `<textarea data-param-name="${param.name}" rows="8" placeholder="${param.placeholder || ''}"></textarea>`;
}
return `<input type="text" data-param-name="${param.name}" placeholder="${param.placeholder || ''}" />`;
}
function requiredSummary(endpoint) {
const required = endpoint.params.filter((p) => p.required).map((p) => p.name);
return required.length ? `Required parameters: ${required.join(', ')}` : 'Required parameters: none';
}
function normalizeParamHelpText(helpText) {
if (!helpText) {
return '';
}
return String(helpText).replace(/^(required|optional)\.?\s*/i, '').trim();
}
function endpointMarkup(endpoint) {
return `
<div class="api-v4-row" data-endpoint-id="${endpoint.id}">
<button type="button" class="api-v4-row-head" data-role="toggle" aria-expanded="false">
<div>
<div class="api-v4-row-title">${endpoint.title}</div>
<div class="api-v4-param-summary">${requiredSummary(endpoint)}</div>
</div>
<div class="api-v4-row-route">
<span class="api-v4-method api-v4-method-${endpoint.method.toLowerCase()}">${endpoint.method}</span>
<code>${endpoint.path}</code>
<span class="api-v4-toggle-indicator" aria-hidden="true"></span>
</div>
</button>
<div class="api-v4-row-body" data-role="body" hidden>
<div class="api-v4-param-grid">
<label class="api-v4-field">
<span>Environment</span>
<select data-role="env">${envOptions(endpoint.baseType)}</select>
</label>
<label class="api-v4-field">
<span>Authorization API Key ${endpoint.authRequired ? '<small>(required)</small>' : '<small>(not required for this call)</small>'}</span>
<input type="password" data-role="api-key" placeholder="Your registrar API key" ${endpoint.authRequired ? '' : ''} />
</label>
${endpoint.params.map((param) => `
<label class="api-v4-field">
<span>${param.label || param.name} <small>${param.required ? 'required' : 'optional'}${normalizeParamHelpText(param.help) ? ` ยท ${normalizeParamHelpText(param.help)}` : ''}</small></span>
${paramInput(param)}
</label>
`).join('')}
</div>
<div class="api-v4-inline-warning" data-role="warning" hidden></div>
<div class="api-v4-actions">
<button type="button" data-role="send">Send</button>
<button type="button" class="api-v4-secondary" data-role="clear">Clear Output</button>
</div>
<div class="api-v4-result" data-role="result" hidden></div>
<div class="api-v4-batch-results" data-role="batch-results" hidden></div>
<div class="api-v4-status" data-role="status">Ready</div>
<pre class="api-v4-output" data-role="output"></pre>
</div>
</div>
`;
}
function otherSectionMarkup() {
if (OTHER_ENDPOINTS.length === 0) {
return '';
}
return `
<details class="api-v4-section">
<summary>Other</summary>
<div class="api-v4-section-body">
${OTHER_ENDPOINTS.map(endpointMarkup).join('')}
</div>
</details>
`;
}
function parseJsonObject(value, fieldName) {
const trimmed = value.trim();
if (!trimmed) return {};
try {
const parsed = JSON.parse(trimmed);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed;
}
throw new Error(`${fieldName} must be a JSON object`);
} catch (err) {
throw new Error(`Invalid ${fieldName}: ${err.message}`);
}
}
function collectPayload(row, endpoint) {
const payload = {};
const warning = row.querySelector('[data-role="warning"]');
if (warning) {
warning.hidden = true;
warning.textContent = '';
}
for (const param of endpoint.params) {
const el = row.querySelector(`[data-param-name="${param.name}"]`);
let raw = (el ? el.value : '').trim();
if (!raw) {
if (param.required) {
throw new Error(`Missing required parameter: ${param.name}`);
}
continue;
}
if ((endpoint.id === 'reset-atsigns' || endpoint.id === 'delete-atsigns') && param.name === 'atSigns') {
const normalizedValues = raw
.split('\n')
.map((value) => normalizeAtSignForRequest(value.trim()))
.filter(Boolean);
payload[param.name] = normalizedValues;
if (el) {
el.value = normalizedValues.join('\n');
}
if (warning && normalizedValues.some((value, index) => value !== raw.split('\n').map((entry) => entry.trim()).filter(Boolean)[index])) {
warning.textContent = 'Warning: leading @ was removed from one or more Atsigns before sending.';
warning.hidden = false;
}
continue;
}
if (param.type === 'csv-array') {
payload[param.name] = raw.split(',').map((v) => v.trim()).filter(Boolean);
} else if (param.type === 'boolean') {
payload[param.name] = raw === 'true';
} else {
payload[param.name] = raw;
}
}
return payload;
}
function buildUrl(endpoint, env, payload) {
let path = endpoint.path;
if (path.includes('{atSign}')) {
path = path.replace('{atSign}', encodeURIComponent(payload.atSign || payload.atsign || ''));
delete payload.atSign;
}
return `${resolveBase(endpoint.baseType, env)}${path}`;
}
function renderPrimarySummary(row, endpoint, parsedBody) {
const result = row.querySelector('[data-role="result"]');
if (!result) return;
if (
!PRIMARY_ENDPOINTS.some((item) => item.id === endpoint.id) ||
endpoint.id === 'reset-atsigns' ||
!parsedBody ||
typeof parsedBody !== 'object'
) {
result.hidden = true;
result.innerHTML = '';
return;
}
const apiStatus = parsedBody.status === 'success' ? 'success' : 'error';
const badgeLabel = apiStatus === 'success' ? 'Success' : 'Error';
let summaryText = parsedBody.message || 'Request completed.';
if (endpoint.id === 'lookup-atsign-v4' && parsedBody.message) {
summaryText = parsedBody.message;
}
const cramkeyBlock = parsedBody.cramkey
? `<div class="api-v4-cramkey-block"><div class="api-v4-cramkey-label">cramkey</div><code>${escapeHtml(parsedBody.cramkey)}</code></div>`
: '';
result.innerHTML = `
<div class="api-v4-result-card api-v4-result-${apiStatus}">
<div class="api-v4-result-head">
<span class="api-v4-result-badge api-v4-result-badge-${apiStatus}">${badgeLabel}</span>
<span class="api-v4-result-text">${escapeHtml(summaryText)}</span>
</div>
${cramkeyBlock}
</div>
`;
result.hidden = false;
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
function extractAtSignFromCramkey(cramkey) {
if (!cramkey || typeof cramkey !== 'string') {
return '';
}
const separatorIndex = cramkey.indexOf(':');
return separatorIndex >= 0 ? cramkey.substring(0, separatorIndex) : cramkey;
}
function extractCramKeyValue(cramkey) {
if (!cramkey || typeof cramkey !== 'string') {
return '';
}
const separatorIndex = cramkey.indexOf(':');
return separatorIndex >= 0 ? cramkey.substring(separatorIndex + 1) : cramkey;
}
function parseRequestBody(data) {
const request = data && data.request ? data.request : {};
const body = request.body;
if (!body || typeof body !== 'string') {
return {};
}
try {
const parsed = JSON.parse(body);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch (_) {
return {};
}
}
function transactionTypeLabel(type) {
if (type === 'register-atsign') {
return 'Register an Atsign';
}
if (type === 'reset-atsign') {
return 'Reset an Atsign';
}
if (type === 'delete-atsign') {
return 'Delete Atsigns';
}
return type || 'Transaction';
}
function summarizeTransaction(item) {
const data = item.data || {};
const request = data.request || {};
const response = data.response || {};
const responseBody = response.body || {};
const requestBody = parseRequestBody(data);
const cramkey = responseBody.cramkey || '';
const cramAtSign = extractAtSignFromCramkey(cramkey);
const requestedAtSign = requestBody.atSign ? `@${String(requestBody.atSign).replace(/^@/, '')}` : '';
const requestedAtSigns = Array.isArray(requestBody.atSigns)
? requestBody.atSigns.map((value) => `@${String(value).replace(/^@/, '')}`).join(', ')
: '';
const label = cramAtSign || requestedAtSign || requestedAtSigns || item.filename || 'Transaction';
const apiStatus = responseBody.status || (response.ok ? 'success' : 'error');
const message = responseBody.message || '';
const createdAt = data.createdAtUtc || '';
return {
data,
request,
response,
responseBody,
cramkey,
label,
apiStatus,
message,
createdAt,
typeLabel: transactionTypeLabel(data.type)
};
}
const HISTORY_PAGE_SIZE = 25;
let historyEvents = [];
let historyVisibleCount = HISTORY_PAGE_SIZE;
function formatHistoryTimestamp(value) {
if (!value) {
return '';
}
try {
return new Date(value).toLocaleString();
} catch (_) {
return value;
}
}
function historyTypeLabel(type) {
if (type === 'register-atsign' || type === 'reset-atsign' || type === 'delete-atsign') {
return transactionTypeLabel(type);
}
return String(type || 'event').replaceAll('_', ' ');
}
function historySourceLabel(source) {
return source === 'super-api-key' ? 'Super API Key' : 'Local';
}
function formatHistoryData(data) {
if (!data || typeof data !== 'object') {
return '';
}
const entries = Object.entries(data);
if (entries.length === 0) {
return '';
}
return entries
.map(([key, value]) => `${key}: ${typeof value === 'string' ? value : JSON.stringify(value)}`)
.join(' | ');
}
function getHistoryFilters() {
const searchInput = document.getElementById('history-search-input');
const typeFilter = document.getElementById('history-type-filter');
const sourceFilter = document.getElementById('history-source-filter');
return {
search: (searchInput ? searchInput.value : '').trim().toLowerCase(),
type: typeFilter ? typeFilter.value : 'all',
source: sourceFilter ? sourceFilter.value : 'all'
};
}
function historySearchText(event) {
return [
event.type,
historyTypeLabel(event.type),
event.atSign,
event.summary,
event.status,
historySourceLabel(event.source)
].join(' ').toLowerCase();
}
function getFilteredHistoryEvents() {
const filters = getHistoryFilters();
return historyEvents.filter((event) => {
if (filters.source !== 'all' && event.source !== filters.source) {
return false;
}
if (filters.type !== 'all' && event.type !== filters.type) {
return false;
}
if (filters.search && !historySearchText(event).includes(filters.search)) {
return false;
}
return true;
});
}
function populateHistoryTypeFilter() {
const select = document.getElementById('history-type-filter');
if (!select) return;
const previous = select.value;
const types = Array.from(new Set(historyEvents.map((event) => event.type).filter(Boolean)));
types.sort((a, b) => historyTypeLabel(a).localeCompare(historyTypeLabel(b)));
select.innerHTML = ['<option value="all">All operations</option>']
.concat(types.map((type) => `<option value="${escapeHtml(type)}">${escapeHtml(historyTypeLabel(type))}</option>`))
.join('');
select.value = types.includes(previous) ? previous : 'all';
}
function bindTransactionCramKeyCopy(container) {
container.querySelectorAll('[data-copy-transaction-cramkey]').forEach((button) => {
button.addEventListener('click', async function (event) {
event.preventDefault();
event.stopPropagation();
const value = button.getAttribute('data-copy-transaction-cramkey') || '';
await navigator.clipboard.writeText(value);
const originalText = button.textContent;
button.textContent = 'Copied';
setTimeout(() => {
button.textContent = originalText;
}, 1500);
});
});
}
function renderHistoryTransactionBody(item) {
const summary = summarizeTransaction(item);
return `
<div class="api-v4-transaction-meta"><strong>URL:</strong> ${escapeHtml(summary.request.url || '')}</div>
<div class="api-v4-transaction-meta"><strong>Path:</strong> ${escapeHtml(item.path || '')}</div>
${summary.cramkey ? `
<div class="api-v4-transaction-cramkey">
<code>${escapeHtml(summary.cramkey)}</code>
<button type="button" class="api-v4-secondary" data-copy-transaction-cramkey="${escapeHtml(extractCramKeyValue(summary.cramkey))}">Copy CRAM Key</button>
</div>
` : ''}
<details class="api-v4-transaction-raw">
<summary>Raw JSON</summary>
<pre class="api-v4-transaction-json">${escapeHtml(JSON.stringify(summary.data, null, 2))}</pre>
</details>
`;
}
function renderHistoryEventCard(event) {
const transaction = event.origin === 'transaction' && event.data ? event.data.transaction : null;
const title = event.atSign || historyTypeLabel(event.type);
const subtitle = event.summary || historyTypeLabel(event.type);
const badgeClass = event.status === 'success' ? 'success' : 'error';
const sourceClass = event.source === 'super-api-key' ? 'super' : 'local';
const details = transaction ? '' : formatHistoryData(event.data);
return `
<details class="api-v4-transaction-card">
<summary class="api-v4-transaction-summary">
<div class="api-v4-transaction-title-wrap">
<div class="api-v4-transaction-title">${escapeHtml(title)}</div>
<div class="api-v4-transaction-subtitle">${escapeHtml(subtitle)}</div>
</div>
<div class="api-v4-transaction-summary-right">
<span class="history-source-chip history-source-chip-${sourceClass}">${escapeHtml(historySourceLabel(event.source))}</span>
<span class="api-v4-result-badge api-v4-result-badge-${badgeClass}">${escapeHtml(event.status || '')}</span>
<span class="api-v4-transaction-created">${escapeHtml(formatHistoryTimestamp(event.createdAtUtc))}</span>
</div>
</summary>
<div class="api-v4-transaction-body">
<div class="api-v4-transaction-meta"><strong>Operation:</strong> ${escapeHtml(historyTypeLabel(event.type))}</div>
${event.atSign ? `<div class="api-v4-transaction-meta"><strong>Atsign:</strong> ${escapeHtml(event.atSign)}</div>` : ''}
${transaction ? renderHistoryTransactionBody(transaction) : ''}
${details ? `<div class="api-v4-transaction-meta">${escapeHtml(details)}</div>` : ''}
</div>
</details>
`;
}
function renderHistoryEvents() {
const list = document.getElementById('history-events-list');
const matchCount = document.getElementById('history-match-count');
const loadMore = document.getElementById('history-events-load-more-btn');
if (!list) return;
const filtered = getFilteredHistoryEvents();
if (matchCount) {
matchCount.textContent = historyEvents.length === 0
? ''
: `${filtered.length} of ${historyEvents.length}`;
}
if (filtered.length === 0) {
const message = historyEvents.length === 0
? 'No history yet.'
: 'No entries match the current filters.';
list.innerHTML = `<div class="api-v4-transaction-card"><div class="api-v4-transaction-meta">${message}</div></div>`;
if (loadMore) loadMore.hidden = true;
return;
}
list.innerHTML = filtered.slice(0, historyVisibleCount).map(renderHistoryEventCard).join('');
if (loadMore) {
loadMore.hidden = historyVisibleCount >= filtered.length;
}
bindTransactionCramKeyCopy(list);
}
function filterHistory() {
historyVisibleCount = HISTORY_PAGE_SIZE;
renderHistoryEvents();
}
function loadMoreHistoryEvents() {
historyVisibleCount += HISTORY_PAGE_SIZE;
renderHistoryEvents();
}
async function loadHistory() {
const status = document.getElementById('history-status');
const list = document.getElementById('history-events-list');
if (!status || !list) return;
status.textContent = 'Loading history...';
try {
const response = await fetch('/api/history');
const data = await response.json();
if (!response.ok || data.error) {
throw new Error(data.error || 'Failed to load history');
}
historyEvents = Array.isArray(data.events) ? data.events : [];
historyVisibleCount = HISTORY_PAGE_SIZE;
populateHistoryTypeFilter();
renderHistoryEvents();
status.textContent = `Loaded ${historyEvents.length} history ${historyEvents.length === 1 ? 'entry' : 'entries'}`;
} catch (err) {
historyEvents = [];
status.textContent = `Load failed: ${err.message}`;
list.innerHTML = '<div class="api-v4-transaction-card"><div class="api-v4-transaction-meta">Failed to load history.</div></div>';
}
}
async function openTransactionsFolder() {
const status = document.getElementById('history-status') || document.getElementById('api-v4-save-status');
if (status) status.textContent = 'Opening transactions folder...';
try {
const response = await fetch('/api/transactions/open-folder', {
method: 'POST'
});
const data = await response.json();
if (!response.ok || data.error) {
throw new Error(data.error || 'Failed to open transactions folder');
}
if (status) status.textContent = `Opened transactions folder: ${data.path}`;
} catch (err) {
if (status) status.textContent = `Open failed: ${err.message}`;
}
}
async function persistSuccessfulTransaction(endpoint, payload) {
const allowed = {
'register-atsign-v3': 'register-atsign',
'register-atsigns': 'register-atsign',
'reset-atsigns': 'reset-atsign',
'delete-atsigns': 'delete-atsign'
};
const type = allowed[endpoint.id];
if (!type) {
return null;
}
const responseBody = payload && payload.response ? payload.response.body : null;
if (!responseBody || responseBody.status !== 'success') {
return null;
}
const response = await fetch('/api/transactions/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
type,
transaction: payload
})
});
const data = await response.json();
if (!response.ok || data.error) {
throw new Error(data.error || 'Failed to save register transaction');
}
if (data.cramKeyPath) {
const saveStatus = document.getElementById('api-v4-save-status');
if (saveStatus) {
saveStatus.textContent = `Saved CRAM key file: ${data.cramKeyPath}`;
}
}
return data.path || null;
}
function normalizeAtSignForRequest(value) {
return value.startsWith('@') ? value.substring(1) : value;
}
function buildRequestDetails(endpoint, env, apiKey, payload) {
const url = buildUrl(endpoint, env, payload);
const headers = {
'Content-Type': 'application/json'
};
if (endpoint.authRequired && !apiKey) {
throw new Error('Authorization API key is required for this call');
}
if (apiKey) {
headers.authorization = apiKey;
}
const requestInit = {
method: endpoint.method,
headers
};
if (endpoint.method !== 'GET') {
requestInit.body = JSON.stringify(payload);
}
return { url, headers, requestInit };
}
async function ensureInternalDartApiReady() {
let response;
try {
response = await fetch('/api/health', {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
Pragma: 'no-cache'
}
});
} catch (_) {
throw new Error('Internal Dart service is unreachable');
}
let payload = null;
try {
payload = await response.json();
} catch (_) {
payload = null;
}
if (!response.ok || !payload || payload.status !== 'ok') {
throw new Error(payload && payload.error ? payload.error : 'Internal Dart service is unavailable');
}
}
async function executeApiRequest(endpoint, env, apiKey, payload) {
await ensureInternalDartApiReady();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const details = buildRequestDetails(endpoint, env, apiKey, payload);
details.requestInit.signal = controller.signal;
let response;
try {
response = await fetch(details.url, details.requestInit);
} finally {
clearTimeout(timeoutId);
}
const rawText = await response.text();
let parsedBody = rawText;
try {
parsedBody = rawText ? JSON.parse(rawText) : '';
} catch (_) {}
const responseHeaders = {};
response.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
return {
response,
parsedBody,
resultPayload: {
request: {
environment: env,
url: details.url,
method: endpoint.method,
headers: details.headers,
body: details.requestInit.body || null
},
response: {
status: response.status,
statusText: response.statusText,
ok: response.ok,
headers: responseHeaders,
body: parsedBody
}
}
};
}
async function sendBatchRequest(row, endpoint, config) {
const statusOutput = row.querySelector('[data-role="status"]');
const output = row.querySelector('[data-role="output"]');
const result = row.querySelector('[data-role="result"]');
const batchResults = row.querySelector('[data-role="batch-results"]');
const button = row.querySelector('[data-role="send"]');
const warning = row.querySelector('[data-role="warning"]');
const startedAt = Date.now();
statusOutput.textContent = `Preparing batch ${config.action} request...`;
button.disabled = true;
output.textContent = '';
if (result) {
result.hidden = true;
result.innerHTML = '';
}
if (batchResults) {
batchResults.hidden = true;
batchResults.innerHTML = '';
}
if (warning) {
warning.hidden = true;
warning.textContent = '';
}
try {
const env = row.querySelector('[data-role="env"]').value;
const apiKey = row.querySelector('[data-role="api-key"]').value.trim();
const payload = collectPayload(row, endpoint);
const atSigns = (payload.atSigns || []).filter(Boolean);
if (atSigns.length === 0) {
throw new Error(`Enter at least one Atsign for batch ${config.action}`);
}
const preview = atSigns.join('\n');
const confirmed = config.confirm
? config.confirm(atSigns, preview)
: window.confirm(`Are you really sure you want to ${config.action} this list of Atsigns?\n\n${preview}`);
if (!confirmed) {
statusOutput.textContent = `Batch ${config.action} cancelled`;
return;
}
if (batchResults) {
batchResults.hidden = false;
batchResults.innerHTML = atSigns
.map((atSign) => `
<div class="api-v4-batch-item" data-batch-atsign="${escapeHtml(atSign)}">
<div class="api-v4-batch-item-head">
<span class="api-v4-batch-atsign">${escapeHtml(atSign)}</span>
<span class="api-v4-batch-state">Queued</span>
</div>
<div class="api-v4-batch-message"></div>
${config.showCramkey ? '<div class="api-v4-batch-cramkey" hidden></div>' : ''}
</div>
`)
.join('');
}
const allResults = [];
let successCount = 0;
for (let index = 0; index < atSigns.length; index += 1) {
const atSign = atSigns[index];
statusOutput.textContent = `Processing ${index + 1} of ${atSigns.length}...`;
const item = batchResults
? Array.from(batchResults.querySelectorAll('[data-batch-atsign]')).find((element) => element.getAttribute('data-batch-atsign') === atSign)
: null;
if (item) {
item.querySelector('.api-v4-batch-state').textContent = 'Sending...';
}
const itemPayload = config.buildItemPayload(atSign, payload);
try {
const executed = await executeApiRequest(endpoint, env, apiKey, itemPayload);
allResults.push(executed.resultPayload);
const transactionPath = await persistSuccessfulTransaction(endpoint, executed.resultPayload);
const apiStatus = executed.parsedBody && executed.parsedBody.status === 'success' ? 'success' : 'error';
if (item) {
item.classList.add(`api-v4-batch-item-${apiStatus}`);
item.querySelector('.api-v4-batch-state').textContent = apiStatus === 'success' ? 'Success' : 'Error';
item.querySelector('.api-v4-batch-message').textContent = executed.parsedBody.message || (apiStatus === 'success' ? config.successMessage : 'Request completed');
if (config.showCramkey && executed.parsedBody.cramkey) {
const cramkeyEl = item.querySelector('.api-v4-batch-cramkey');
cramkeyEl.hidden = false;
cramkeyEl.innerHTML = `
<code>${escapeHtml(executed.parsedBody.cramkey)}</code>
<button type="button" class="api-v4-secondary" data-copy-cramkey="${escapeHtml(executed.parsedBody.cramkey)}">Copy</button>
`;
}
if (transactionPath) {
item.querySelector('.api-v4-batch-message').textContent += ` | Saved: ${transactionPath}`;
}
}
if (apiStatus === 'success') {
successCount += 1;
}
} catch (err) {
allResults.push({ atSign, error: err && err.message ? err.message : String(err) });
if (item) {
item.classList.add('api-v4-batch-item-error');
item.querySelector('.api-v4-batch-state').textContent = 'Error';
item.querySelector('.api-v4-batch-message').textContent = err && err.message ? err.message : String(err);
}
}
}
if (config.showCramkey && batchResults) {
batchResults.querySelectorAll('[data-copy-cramkey]').forEach((copyBtn) => {
copyBtn.addEventListener('click', async function () {
const value = copyBtn.getAttribute('data-copy-cramkey') || '';
await navigator.clipboard.writeText(value);
copyBtn.textContent = 'Copied';
setTimeout(() => {
copyBtn.textContent = 'Copy';
}, 1500);
});
});
}
const elapsed = Date.now() - startedAt;
statusOutput.textContent = `Batch completed: ${successCount}/${atSigns.length} succeeded (${elapsed} ms). One transaction file was created per attempted Atsign.`;
output.textContent = JSON.stringify(allResults, null, 2);
} catch (err) {
const elapsed = Date.now() - startedAt;
if (err && err.name === 'AbortError') {
statusOutput.textContent = `Batch timed out after ${REQUEST_TIMEOUT_MS / 1000} seconds`;
output.textContent = 'The batch request exceeded the 15 second timeout and was cancelled.';
} else {
statusOutput.textContent = `Batch failed (${elapsed} ms)`;
output.textContent = err && err.stack ? err.stack : String(err);
}
} finally {
button.disabled = false;
}
}
function sendBatchResetRequest(row, endpoint) {
return sendBatchRequest(row, endpoint, {
action: 'reset',
showCramkey: false,
successMessage: 'Reset successfully',
confirm(atSigns, preview) {
const answer = window.prompt(
`You are about to reset ${atSigns.length} Atsign(s):\n\n${preview}\n\nType RESET to confirm.`
);
return answer !== null && answer.trim() === 'RESET';
},
buildItemPayload(atSign) {
return { atSign };
}
});
}
function normalizeAtSignKey(value) {
return String(value || '').trim().replace(/^@/, '').toLowerCase();
}
function sameAtSign(left, right) {
return normalizeAtSignKey(left) === normalizeAtSignKey(right);
}
function extractAtSignNames(value) {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item) => {
if (item && typeof item === 'object') {
return String(item.atSign || item.atsign || item.name || '');
}
return String(item || '');
})
.map((item) => item.trim())
.filter(Boolean);
}
// The registrar reports a generic top-level message and puts the real
// reason on each entry of failed/skippedatSigns, so keep those by Atsign.
function extractAtSignMessages(value) {
const messages = new Map();
if (!Array.isArray(value)) {
return messages;
}
value.forEach((item) => {
if (!item || typeof item !== 'object') {
return;
}
const name = normalizeAtSignKey(item.atSign || item.atsign || item.name || '');
const message = String(item.message || item.reason || '').trim();
if (name && message) {
messages.set(name, message);
}
});
return messages;
}
function renderDeleteItems(container, atSigns) {
if (!container) return;
container.hidden = false;
container.innerHTML = atSigns
.map((atSign) => `
<div class="api-v4-batch-item" data-batch-atsign="${escapeHtml(atSign)}">
<div class="api-v4-batch-item-head">
<span class="api-v4-batch-atsign">${escapeHtml(atSign)}</span>
<span class="api-v4-batch-state">Queued</span>
</div>
<div class="api-v4-batch-message"></div>
</div>
`)
.join('');
}
function updateDeleteItem(container, atSign, state, message) {
if (!container) return;
const item = Array.from(container.querySelectorAll('[data-batch-atsign]'))
.find((element) => sameAtSign(element.getAttribute('data-batch-atsign'), atSign));
if (!item) return;
item.classList.add(state === 'Deleted' ? 'api-v4-batch-item-success' : 'api-v4-batch-item-error');
item.querySelector('.api-v4-batch-state').textContent = state;
item.querySelector('.api-v4-batch-message').textContent = message;
}
async function moveStaleLocalKeys(deletedAtSigns, warning) {
if (!warning) return;
let results;
try {
const response = await fetch('/api/atsigns/stale', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ atSigns: deletedAtSigns })
});
const data = await response.json();
if (!response.ok || data.error) {
throw new Error(data.error || 'Failed to move stale keys');
}
results = data.results || [];
} catch (err) {
warning.textContent = 'Could not move local key files to stale/. '
+ 'You may want to manually check ~/.atsign/keys/ for stale files. '
+ (err.message || '');
warning.hidden = false;
return;
}
const moved = results.filter((r) => r.status === 'moved');
const errors = results.filter((r) => r.status === 'error');
if (moved.length === 0 && errors.length === 0) return;
const parts = [];
if (moved.length > 0) {
parts.push(`Moved ${moved.length} local key file(s) to stale/: `
+ moved.map((r) => r.to).join(', ') + '.'
+ ' If any of these Atsigns also exist in another atDirectory,'
+ ' retrieve the key file from stale/ and move it back to ~/.atsign/keys/.');
}
if (errors.length > 0) {
parts.push(`Failed to move ${errors.length} file(s): `
+ errors.map((r) => `${r.atSign} (${r.error})`).join(', ') + '.');
}
warning.textContent = parts.join(' ');
warning.hidden = false;
}
async function sendDeleteAtSignsRequest(row, endpoint) {
const statusOutput = row.querySelector('[data-role="status"]');
const output = row.querySelector('[data-role="output"]');
const batchResults = row.querySelector('[data-role="batch-results"]');
const button = row.querySelector('[data-role="send"]');
const warning = row.querySelector('[data-role="warning"]');
const startedAt = Date.now();
const executedPayloads = [];
clearRowOutput(row);
statusOutput.textContent = 'Preparing delete request...';
button.disabled = true;
try {
const env = row.querySelector('[data-role="env"]').value;
const apiKey = row.querySelector('[data-role="api-key"]').value.trim();
const payload = collectPayload(row, endpoint);
const atSigns = (payload.atSigns || []).filter(Boolean);
if (atSigns.length === 0) {
throw new Error('Enter at least one Atsign to delete');
}
const preview = atSigns.join('\n');
const confirmation = window.prompt(`Type DELETE to permanently delete ${atSigns.length} Atsign(s). This cannot be undone.\n\n${preview}`, '');
if (confirmation === null) {
statusOutput.textContent = 'Delete cancelled';
output.textContent = 'Delete request cancelled before sending.';
return;
}
if (confirmation.trim() !== 'DELETE') {
throw new Error('Confirmation did not match. Type exactly: DELETE');
}
renderDeleteItems(batchResults, atSigns);
statusOutput.textContent = 'Requesting delete token...';
const tokenExecuted = await executeApiRequest(endpoint, env, apiKey, {
atSigns,
operation: 'deletetoken'
});
executedPayloads.push(tokenExecuted.resultPayload);
const tokenBody = tokenExecuted.parsedBody && typeof tokenExecuted.parsedBody === 'object' ? tokenExecuted.parsedBody : {};
const tokenData = tokenBody.data && typeof tokenBody.data === 'object' ? tokenBody.data : {};
const skipped = extractAtSignNames(tokenData.skippedatSigns);
const skippedMessages = extractAtSignMessages(tokenData.skippedatSigns);
const token = tokenData.token ? String(tokenData.token) : '';
skipped.forEach((atSign) => {
updateDeleteItem(batchResults, atSign, 'Skipped',
skippedMessages.get(normalizeAtSignKey(atSign))
|| 'The Authorization API Key did not create this Atsign, so it cannot delete it.');
});
const deletable = atSigns.filter((atSign) => !skipped.some((entry) => sameAtSign(entry, atSign)));
if (deletable.length === 0) {
throw new Error('The Authorization API Key did not create any of these Atsigns, so none of them can be deleted');
}
if (!token) {
throw new Error(tokenBody.message || 'Registrar did not return a delete token');
}
statusOutput.textContent = `Deleting ${deletable.length} of ${atSigns.length}...`;
const deleteExecuted = await executeApiRequest(endpoint, env, apiKey, {
token,
atSigns: deletable,
operation: 'delete'
});
executedPayloads.push(deleteExecuted.resultPayload);
const deleteBody = deleteExecuted.parsedBody && typeof deleteExecuted.parsedBody === 'object' ? deleteExecuted.parsedBody : {};
const deleteData = deleteBody.data && typeof deleteBody.data === 'object' ? deleteBody.data : {};
const failed = extractAtSignNames(deleteData.failed);
// The spec also allows ineligible Atsigns to come back as skipped
// on this call, though the registrar has only been seen using failed.
const skippedOnDelete = extractAtSignNames(deleteData.skippedatSigns);
const reasons = new Map([
...extractAtSignMessages(deleteData.failed),
...extractAtSignMessages(deleteData.skippedatSigns)
]);
const deleted = Array.isArray(deleteData.deleted)
? extractAtSignNames(deleteData.deleted)
: (deleteBody.status === 'success'
? deletable.filter((atSign) => !failed.some((entry) => sameAtSign(entry, atSign)))
: []);
deleted.forEach((atSign) => {
updateDeleteItem(batchResults, atSign, 'Deleted', 'Deleted from the registrar.');
});
const unresolved = deletable.filter((atSign) => !deleted.some((entry) => sameAtSign(entry, atSign)));
const failureReasons = [];
unresolved.forEach((atSign) => {
const reason = reasons.get(normalizeAtSignKey(atSign))
|| deleteBody.message
|| 'The registrar did not report this Atsign as deleted.';
if (!failureReasons.includes(reason)) {
failureReasons.push(reason);
}
const state = skippedOnDelete.some((entry) => sameAtSign(entry, atSign)) ? 'Skipped' : 'Failed';
updateDeleteItem(batchResults, atSign, state, reason);
});
output.textContent = JSON.stringify(executedPayloads, null, 2);
let transactionPath = null;
try {
transactionPath = await persistSuccessfulTransaction(endpoint, deleteExecuted.resultPayload);
} catch (persistError) {
if (warning) {
warning.textContent = `Warning: the delete request completed, but the transaction file was not saved: ${persistError.message}`;
warning.hidden = false;
}
}
if (deleted.length > 0) {
await moveStaleLocalKeys(deleted, warning);
}
const elapsed = Date.now() - startedAt;
statusOutput.textContent = `Deleted ${deleted.length}/${atSigns.length} (${elapsed} ms).`
+ (skipped.length ? ` Skipped ${skipped.length}.` : '')
+ (unresolved.length ? ` Failed ${unresolved.length}.` : '')
// One shared reason is the common case, and it is the only thing
// worth reading; per-Atsign reasons stay on the rows below.
+ (failureReasons.length === 1 ? ` ${failureReasons[0]}` : '')
+ (transactionPath ? ` Transaction saved to: ${transactionPath}` : '');
} catch (err) {
const elapsed = Date.now() - startedAt;
const details = executedPayloads.length ? `${JSON.stringify(executedPayloads, null, 2)}\n\n` : '';
if (err && err.name === 'AbortError') {
statusOutput.textContent = `Request timed out after ${REQUEST_TIMEOUT_MS / 1000} seconds`;
output.textContent = `${details}The request exceeded the 15 second timeout and was cancelled.`;
} else {
statusOutput.textContent = `Delete failed (${elapsed} ms)`;
output.textContent = details + (err && err.stack ? err.stack : String(err));
}
} finally {
button.disabled = false;
}
}
async function sendRequest(row, endpoint) {
const statusOutput = row.querySelector('[data-role="status"]');
const output = row.querySelector('[data-role="output"]');
const button = row.querySelector('[data-role="send"]');
const startedAt = Date.now();
statusOutput.textContent = 'Sending request...';
button.disabled = true;
try {
const env = row.querySelector('[data-role="env"]').value;
const apiKey = row.querySelector('[data-role="api-key"]').value.trim();
const payload = collectPayload(row, endpoint);
const executed = await executeApiRequest(endpoint, env, apiKey, payload);
const parsedBody = executed.parsedBody;
const resultPayload = executed.resultPayload;
let transactionPath = null;
try {
transactionPath = await persistSuccessfulTransaction(endpoint, resultPayload);
} catch (persistError) {
const warning = row.querySelector('[data-role="warning"]');
if (warning) {
warning.textContent = `Warning: request completed, but transaction file was not saved: ${persistError.message}`;
warning.hidden = false;
}
}
const elapsed = Date.now() - startedAt;
statusOutput.textContent = `${executed.response.status} ${executed.response.statusText} (${elapsed} ms)`;
renderPrimarySummary(row, endpoint, parsedBody);
if (transactionPath) {
const result = row.querySelector('[data-role="result"]');
if (result && !result.hidden) {
result.innerHTML += `<div class="api-v4-transaction-path">Transaction saved to: <code>${escapeHtml(transactionPath)}</code></div>`;
}
}
output.textContent = JSON.stringify(resultPayload, null, 2);
} catch (err) {
const elapsed = Date.now() - startedAt;
if (err && err.name === 'AbortError') {
statusOutput.textContent = `Request timed out after ${REQUEST_TIMEOUT_MS / 1000} seconds`;
output.textContent = 'The request exceeded the 15 second timeout and was cancelled.';
} else {
statusOutput.textContent = `Request failed (${elapsed} ms)`;
output.textContent = err && err.stack ? err.stack : String(err);
}
renderPrimarySummary(row, endpoint, null);
} finally {
button.disabled = false;
}
}
function clearRowOutput(row) {
const result = row.querySelector('[data-role="result"]');
if (result) {
result.hidden = true;
result.innerHTML = '';
}
const batchResults = row.querySelector('[data-role="batch-results"]');
if (batchResults) {
batchResults.hidden = true;
batchResults.innerHTML = '';
}
row.querySelector('[data-role="status"]').textContent = 'Ready';
row.querySelector('[data-role="output"]').textContent = '';
const warning = row.querySelector('[data-role="warning"]');
if (warning) {
warning.hidden = true;
warning.textContent = '';
}
}
function bindRow(root, endpoint) {
const row = root.querySelector(`[data-endpoint-id="${endpoint.id}"]`);
const toggle = row.querySelector('[data-role="toggle"]');
const body = row.querySelector('[data-role="body"]');
row.querySelector('[data-role="send"]').addEventListener('click', function () {
if (endpoint.type === 'batch-reset') {
sendBatchResetRequest(row, endpoint);
} else if (endpoint.type === 'delete-atsigns') {
sendDeleteAtSignsRequest(row, endpoint);
} else {
sendRequest(row, endpoint);
}
});
row.querySelector('[data-role="clear"]').addEventListener('click', function () {
clearRowOutput(row);
});
toggle.addEventListener('click', function () {
const expanded = toggle.getAttribute('aria-expanded') === 'true';
toggle.setAttribute('aria-expanded', expanded ? 'false' : 'true');
body.hidden = expanded;
});
}
function syncApiKeyInputs(root, value, visible) {
if (!root) return;
root.querySelectorAll('[data-role="api-key"]').forEach((input) => {
input.value = value;
input.type = visible ? 'text' : 'password';
});
}
async function loadSavedConfig(statusEl, configFields, root, isVisible) {
try {
const response = await fetch('/api/config');
const data = await response.json();
if (!response.ok || data.error) {
throw new Error(data.error || 'Failed to load config');
}
configFields.apiKey.value = data.registrarApiKey || '';
configFields.prefix.value = data.atsignPrefix || '';
configFields.postfix.value = data.atsignPostfix || '';
syncApiKeyInputs(root, configFields.apiKey.value, isVisible);
if (statusEl) {
statusEl.textContent = 'Loaded config from ~/.atsign/at_activate_web/config.json';
}
} catch (err) {
if (statusEl) {
statusEl.textContent = `Load failed: ${err.message}`;
}
}
}
function bindConfigPanel(root) {
const globalApiKey = document.getElementById('api-v4-global-api-key');
const atsignPrefix = document.getElementById('api-v4-atsign-prefix');
const atsignPostfix = document.getElementById('api-v4-atsign-postfix');
const toggleButton = document.getElementById('api-v4-toggle-api-key');
const copyButton = document.getElementById('api-v4-copy-api-key');
const saveButton = document.getElementById('api-v4-save-api-key');
const refreshButton = document.getElementById('api-v4-refresh-config');
const openFolderButton = document.getElementById('api-v4-open-config-folder');
const saveStatus = document.getElementById('api-v4-save-status');
if (!globalApiKey || !atsignPrefix || !atsignPostfix) return;
let isVisible = false;
const configFields = {
apiKey: globalApiKey,
prefix: atsignPrefix,
postfix: atsignPostfix
};
const sync = function () {
syncApiKeyInputs(root, globalApiKey.value, isVisible);
};
globalApiKey.addEventListener('input', sync);
if (toggleButton) {
toggleButton.addEventListener('click', function () {
isVisible = !isVisible;
globalApiKey.type = isVisible ? 'text' : 'password';
toggleButton.textContent = isVisible ? 'Hide' : 'Show';
sync();
});
}
if (copyButton) {
copyButton.addEventListener('click', async function () {
try {
await navigator.clipboard.writeText(globalApiKey.value);
if (saveStatus) saveStatus.textContent = 'API key copied to clipboard';
} catch (_) {
if (saveStatus) saveStatus.textContent = 'Copy failed';
}
});
}
if (saveButton) {
saveButton.addEventListener('click', async function () {
saveButton.disabled = true;
if (saveStatus) saveStatus.textContent = 'Saving...';
try {
const response = await fetch('/api/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
registrarApiKey: globalApiKey.value,
atsignPrefix: atsignPrefix.value,
atsignPostfix: atsignPostfix.value
})
});
const data = await response.json();
if (!response.ok || data.error) {
throw new Error(data.error || 'Failed to save config');
}
if (saveStatus) saveStatus.textContent = 'Saved to ~/.atsign/at_activate_web/config.json';
} catch (err) {
if (saveStatus) saveStatus.textContent = `Save failed: ${err.message}`;
} finally {
saveButton.disabled = false;
}
});
}
if (refreshButton) {
refreshButton.addEventListener('click', async function () {
refreshButton.disabled = true;
if (saveStatus) saveStatus.textContent = 'Refreshing...';
try {
await loadSavedConfig(saveStatus, configFields, root, isVisible);
} finally {
refreshButton.disabled = false;
}
});
}
if (openFolderButton) {
openFolderButton.addEventListener('click', async function () {
openFolderButton.disabled = true;
if (saveStatus) saveStatus.textContent = 'Opening config folder...';
try {
const response = await fetch('/api/config/open-folder', {
method: 'POST'
});
const data = await response.json();
if (!response.ok || data.error) {
throw new Error(data.error || 'Failed to open config folder');
}
if (saveStatus) saveStatus.textContent = `Opened config folder: ${data.path}`;
} catch (err) {
if (saveStatus) saveStatus.textContent = `Open failed: ${err.message}`;
} finally {
openFolderButton.disabled = false;
}
});
}
globalApiKey.type = 'password';
sync();
loadSavedConfig(saveStatus, configFields, root, isVisible);
}
// --- Register + Activate tab ---
var registerActivateItems = [];
var registerActivateInProgress = 0;
function registerActivateGetElements(atSign) {
var escaped = CSS.escape(atSign);
return {
statusEl: document.querySelector('[data-ra-activate-item-status="' + escaped + '"]'),
badgeEl: document.querySelector('[data-ra-activate-badge="' + escaped + '"]'),
logEl: document.querySelector('[data-ra-activate-log="' + escaped + '"]')
};
}
function registerActivateSetBadge(badgeEl, text, badgeClass) {
if (!badgeEl) return;
badgeEl.textContent = text;
badgeEl.className = 'api-v4-result-badge api-v4-result-badge-' + badgeClass;
}
async function initRegisterActivateTab() {
var apiKeyInput = document.getElementById('ra-api-key');
if (apiKeyInput && !apiKeyInput.value.trim()) {
try {
var response = await fetch('/api/config');
var data = await response.json();
if (response.ok && data.registrarApiKey) apiKeyInput.value = data.registrarApiKey;
} catch (_) {}
}
if (!registerActivateItems.length) {
try {
var cramResponse = await fetch('/api/cramkeys');
var cramData = await cramResponse.json();
if (cramResponse.ok && cramData.cramKeys) {
var items = cramData.cramKeys
.filter(function (entry) { return entry.data && entry.data.atSign && entry.data.cramKey; })
.map(function (entry) { return { atSign: entry.data.atSign, cramkey: entry.data.cramKey }; });
if (items.length > 0) registerActivateShowActivationSection(items);
}
} catch (_) {}
}
}
async function registerActivateRegister() {
var registerButton = document.getElementById('ra-register-btn');
var registerStatus = document.getElementById('ra-register-status');
var registerResults = document.getElementById('ra-register-results');
var environment = document.getElementById('ra-env').value;
var apiKey = (document.getElementById('ra-api-key').value || '').trim();
var startAtServer = document.getElementById('ra-start-at-server').value;
var rawText = (document.getElementById('ra-atsigns').value || '').trim();
var atSigns = rawText.split('\n')
.map(function (value) { return normalizeAtSignForRequest(value.trim()); })
.filter(Boolean);
if (registerActivateInProgress > 0) {
registerStatus.textContent = 'Cannot register while activation is in progress';
return;
}
if (!atSigns.length) { registerStatus.textContent = 'Enter at least one Atsign'; return; }
if (!apiKey) { registerStatus.textContent = 'Authorization API Key is required'; return; }
if (!window.confirm('Register ' + atSigns.length + ' Atsign(s)?\n\n' + atSigns.join('\n'))) {
registerStatus.textContent = 'Registration cancelled'; return;
}
registerButton.disabled = true;
registerStatus.textContent = 'Registering...';
registerResults.hidden = false;
registerResults.innerHTML = atSigns.map(function (atSign) {
var escapedAtSign = escapeHtml(atSign);
return '<div class="api-v4-batch-item" data-ra-batch="' + escapedAtSign + '">'
+ '<div class="api-v4-batch-item-head"><span class="api-v4-batch-atsign">' + escapedAtSign + '</span>'
+ '<span class="api-v4-batch-state">Queued</span></div>'
+ '<div class="api-v4-batch-message"></div><div class="api-v4-batch-cramkey" hidden></div></div>';
}).join('');
var endpoint = REGISTER_ATSIGNS_ENDPOINT;
var successfulItems = [];
try {
for (var index = 0; index < atSigns.length; index++) {
registerStatus.textContent = 'Registering ' + (index + 1) + ' of ' + atSigns.length + '...';
var result = await registerActivateRegisterOne(atSigns[index], endpoint, environment, apiKey, startAtServer, registerResults);
if (result) successfulItems.push(result);
}
registerStatus.textContent = 'Registration complete: ' + successfulItems.length + '/' + atSigns.length + ' succeeded';
if (successfulItems.length > 0) registerActivateShowActivationSection(successfulItems);
} finally {
registerButton.disabled = false;
}
}
async function registerActivateRegisterOne(atSign, endpoint, environment, apiKey, startAtServer, registerResults) {
var batchItem = registerResults.querySelector('[data-ra-batch="' + CSS.escape(atSign) + '"]');
if (batchItem) batchItem.querySelector('.api-v4-batch-state').textContent = 'Sending...';
var payload = { atSign: atSign, operation: 'register' };
if (startAtServer) payload.startAtServer = startAtServer;
var executed;
try {
executed = await executeApiRequest(endpoint, environment, apiKey, payload);
} catch (error) {
if (batchItem) {
batchItem.classList.add('api-v4-batch-item-error');
batchItem.querySelector('.api-v4-batch-state').textContent = 'Error';
batchItem.querySelector('.api-v4-batch-message').textContent = error.message || String(error);
}
return null;
}
var responseBody = (typeof executed.parsedBody === 'object' && executed.parsedBody) || {};
var isSuccess = responseBody.status === 'success';
var statusMessage = responseBody.message || '';
if (!statusMessage && typeof executed.parsedBody === 'string') {
statusMessage = executed.parsedBody.substring(0, 200);
}
var transactionPath = '';
try {
transactionPath = await persistSuccessfulTransaction(endpoint, executed.resultPayload) || '';
} catch (_) {}
if (batchItem) {
batchItem.classList.add(isSuccess ? 'api-v4-batch-item-success' : 'api-v4-batch-item-error');
batchItem.querySelector('.api-v4-batch-state').textContent = isSuccess ? 'Success' : 'Error';
batchItem.querySelector('.api-v4-batch-message').textContent =
statusMessage + (transactionPath ? ' | Saved: ' + transactionPath : '');
if (responseBody.cramkey) {
var cramkeyEl = batchItem.querySelector('.api-v4-batch-cramkey');
cramkeyEl.hidden = false;
cramkeyEl.innerHTML = '<code>' + escapeHtml(responseBody.cramkey) + '</code>';
}
}
if (isSuccess && responseBody.cramkey) {
return { atSign: extractAtSignFromCramkey(responseBody.cramkey) || atSign, cramkey: responseBody.cramkey };
}
return null;
}
function registerActivateShowActivationSection(items) {
var activateSection = document.getElementById('ra-activate-section');
var activateList = document.getElementById('ra-activate-list');
var activateStatusEl = document.getElementById('ra-activate-status');
if (!activateSection || !activateList) return;
activateSection.hidden = false;
activateStatusEl.textContent = items.length + ' Atsign(s) ready to activate';
registerActivateItems = items;
activateList.innerHTML = items.map(function (item) {
var escapedAtSign = escapeHtml(item.atSign);
return '<details class="api-v4-transaction-card" data-ra-activate="' + escapedAtSign + '">'
+ '<summary class="api-v4-transaction-summary">'
+ '<div class="api-v4-transaction-title-wrap"><div class="api-v4-transaction-title">' + escapedAtSign + '</div></div>'
+ '<div class="api-v4-transaction-summary-right">'
+ '<button type="button" class="api-v4-secondary" data-ra-activate-btn="' + escapedAtSign + '">Activate</button>'
+ '<span class="api-v4-result-badge api-v4-result-badge-neutral" data-ra-activate-badge="' + escapedAtSign + '">pending</span>'
+ '</div></summary>'
+ '<div class="api-v4-transaction-body">'
+ '<div class="api-v4-transaction-cramkey"><code>' + escapeHtml(item.cramkey) + '</code></div>'
+ '<div class="api-v4-status" data-ra-activate-item-status="' + escapedAtSign + '">Ready</div>'
+ '<pre class="api-v4-activation-log" data-ra-activate-log="' + escapedAtSign + '" hidden></pre>'
+ '</div></details>';
}).join('');
activateList.querySelectorAll('[data-ra-activate-btn]').forEach(function (activateButton) {
activateButton.addEventListener('click', function (event) {
event.preventDefault();
event.stopPropagation();
registerActivateActivateCramKey(activateButton.getAttribute('data-ra-activate-btn'), activateButton);
});
});
}
async function registerActivateActivateCramKey(atSign, activateButton) {
if (activateButton && activateButton.disabled) return false;
var elements = registerActivateGetElements(atSign);
if (activateButton) activateButton.disabled = true;
if (elements.statusEl) elements.statusEl.textContent = 'Activating...';
registerActivateSetBadge(elements.badgeEl, 'running', 'success');
if (elements.logEl) { elements.logEl.hidden = true; elements.logEl.textContent = ''; }
registerActivateInProgress++;
try {
var password = (document.getElementById('ra-activate-password') || {}).value || '';
password = password.trim();
if (password) {
var pwResponse = await fetch('/api/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ atsign: atSign, password: password })
});
var pwData = await pwResponse.json();
if (!pwResponse.ok || pwData.error) throw new Error(pwData.error || 'Failed to set password');
}
var response = await fetch('/api/cramkeys/activate/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ atSign: atSign })
});
var data = await response.json();
if (!response.ok || data.error) throw new Error(data.error || 'Activation failed');
if (elements.logEl) { elements.logEl.hidden = false; elements.logEl.textContent = 'Activation started...\n'; }
var job = await registerActivatePollJob(data.jobId, elements, activateButton);
return job && job.status === 'success';
} catch (error) {
if (elements.statusEl) elements.statusEl.textContent = 'Activation failed: ' + error.message;
registerActivateSetBadge(elements.badgeEl, 'error', 'error');
if (activateButton) activateButton.disabled = false;
return false;
} finally {
registerActivateInProgress--;
}
}
function registerActivatePollJob(jobId, elements, activateButton) {
return new Promise(function (resolve, reject) {
var intervalId;
function renderJobStatus(job) {
if (elements.logEl) {
var logLines = [].concat(
(job.stdout || []).map(function (line) { return '[stdout] ' + line; }),
(job.stderr || []).map(function (line) { return '[stderr] ' + line; })
);
elements.logEl.hidden = false;
elements.logEl.textContent = logLines.length ? logLines.join('\n') : 'No process output yet...';
}
if (job.status === 'running') {
if (elements.statusEl) elements.statusEl.textContent = 'Running...';
return false;
}
if (job.status === 'success') {
if (elements.statusEl) elements.statusEl.textContent = 'Success. Archived to ' + (job.archivedPath || 'old/');
registerActivateSetBadge(elements.badgeEl, 'Onboarded', 'neutral');
} else {
if (elements.statusEl) elements.statusEl.textContent = 'Failed. Exit code: ' + job.exitCode;
registerActivateSetBadge(elements.badgeEl, 'error', 'error');
}
if (activateButton) activateButton.disabled = false;
return true;
}
async function pollJobStatus() {
try {
var response = await fetch('/api/cramkeys/activate/status/' + encodeURIComponent(jobId));
var job = await response.json();
if (!response.ok || job.error) throw new Error(job.error || 'Status check failed');
if (renderJobStatus(job)) { clearInterval(intervalId); resolve(job); }
} catch (error) {
if (elements.statusEl) elements.statusEl.textContent = 'Status check failed: ' + error.message;
registerActivateSetBadge(elements.badgeEl, 'error', 'error');
if (activateButton) activateButton.disabled = false;
clearInterval(intervalId);
reject(error);
}
}
pollJobStatus();
intervalId = setInterval(pollJobStatus, 1000);
});
}
async function registerActivateActivateAll() {
var activateAllStatusEl = document.getElementById('ra-activate-status');
var activateAllButton = document.getElementById('ra-activate-all-btn');
if (!registerActivateItems.length) {
if (activateAllStatusEl) activateAllStatusEl.textContent = 'No Atsigns to activate';
return;
}
var atSignNames = registerActivateItems.map(function (item) { return item.atSign; });
if (!window.confirm('Activate ' + atSignNames.length + ' Atsign(s)?\n\n' + atSignNames.join('\n'))) {
if (activateAllStatusEl) activateAllStatusEl.textContent = 'Batch activation cancelled';
return;
}
if (activateAllButton) activateAllButton.disabled = true;
try {
var successCount = 0;
for (var index = 0; index < atSignNames.length; index++) {
if (activateAllStatusEl) activateAllStatusEl.textContent = 'Activating ' + (index + 1) + ' of ' + atSignNames.length + ': ' + atSignNames[index] + '...';
var activateButton = document.querySelector('[data-ra-activate-btn="' + CSS.escape(atSignNames[index]) + '"]');
if (await registerActivateActivateCramKey(atSignNames[index], activateButton)) successCount++;
}
if (activateAllStatusEl) activateAllStatusEl.textContent = 'Batch activation complete: ' + successCount + '/' + atSignNames.length + ' succeeded';
} finally {
if (activateAllButton) activateAllButton.disabled = false;
}
}
async function initializeApiV4Console() {
const root = document.getElementById('api-v4-rows');
if (!root) return;
root.innerHTML = PRIMARY_ENDPOINTS.map(endpointMarkup).join('') + otherSectionMarkup();
ALL_ENDPOINTS.forEach((endpoint) => bindRow(root, endpoint));
bindConfigPanel(root);
window.loadHistory = loadHistory;
window.filterHistory = filterHistory;
window.loadMoreHistoryEvents = loadMoreHistoryEvents;
window.openTransactionsFolder = openTransactionsFolder;
window.registerActivateRegister = registerActivateRegister;
window.registerActivateActivateAll = registerActivateActivateAll;
window.initRegisterActivateTab = initRegisterActivateTab;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeApiV4Console);
} else {
initializeApiV4Console();
}
})();
''';