Add reserved handle pricing, quote management, and tenant DB hardening.
Reserved handles can carry an optional preset price that overrides tenant pricing strategy; handles without a price are quoted as unavailable. Adds handles.price migration, tenant-handles UI, quote delete API, automatic quote retention (KEEP_QUOTES_FOR_DAYS), and resilient tenant schema application for invalid DB files. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+48
-5
@@ -94,6 +94,7 @@ const CREATE_HANDLES_TABLE = `
|
||||
handle TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL DEFAULT 'reserved' CHECK(status IN ('reserved','available','staged','committed','parked','published','unpublished')),
|
||||
script_pubkey TEXT,
|
||||
price INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
@@ -116,6 +117,8 @@ const CREATE_PAYMENTS_TABLE = `
|
||||
|
||||
const PAYMENTS_OPTIONAL_COLUMNS = [{ name: 'purchase_id', sql: 'purchase_id INTEGER' }];
|
||||
|
||||
const HANDLES_OPTIONAL_COLUMNS = [{ name: 'price', sql: 'price INTEGER' }];
|
||||
|
||||
const COUPONS_OPTIONAL_COLUMNS = [
|
||||
{ name: 'completely_free', sql: 'completely_free INTEGER NOT NULL DEFAULT 0' },
|
||||
{ name: 'affiliate_split_enabled', sql: 'affiliate_split_enabled INTEGER NOT NULL DEFAULT 0' },
|
||||
@@ -282,6 +285,11 @@ function runTenantDDL(tenantDb, callback) {
|
||||
callback(e9);
|
||||
return;
|
||||
}
|
||||
addMissingColumns(tenantDb, 'handles', HANDLES_OPTIONAL_COLUMNS, (e9b) => {
|
||||
if (e9b) {
|
||||
callback(e9b);
|
||||
return;
|
||||
}
|
||||
tenantDb.run(CREATE_PAYMENTS_TABLE, (e10) => {
|
||||
if (e10) {
|
||||
callback(e10);
|
||||
@@ -289,6 +297,7 @@ function runTenantDDL(tenantDb, callback) {
|
||||
}
|
||||
addMissingColumns(tenantDb, 'payments', PAYMENTS_OPTIONAL_COLUMNS, callback);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -306,27 +315,60 @@ function runTenantDDL(tenantDb, callback) {
|
||||
/**
|
||||
* Apply canonical tenant DDL to every *.db under data/spaces (used by migration 001).
|
||||
*/
|
||||
function isSqliteDatabaseFile(dbPath) {
|
||||
try {
|
||||
const stat = fs.statSync(dbPath);
|
||||
if (!stat.isFile() || stat.size < 16) {
|
||||
return false;
|
||||
}
|
||||
const fd = fs.openSync(dbPath, 'r');
|
||||
const header = Buffer.alloc(16);
|
||||
fs.readSync(fd, header, 0, 16, 0);
|
||||
fs.closeSync(fd);
|
||||
return header.toString('utf8', 0, 15) === 'SQLite format 3';
|
||||
} catch (_err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyTenantSchemaToAllFiles(spacesDir) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!fs.existsSync(spacesDir)) {
|
||||
resolve();
|
||||
resolve({ processed: 0, skipped: 0 });
|
||||
return;
|
||||
}
|
||||
const files = fs.readdirSync(spacesDir).filter((f) => f.endsWith('.db'));
|
||||
if (files.length === 0) {
|
||||
resolve();
|
||||
resolve({ processed: 0, skipped: 0 });
|
||||
return;
|
||||
}
|
||||
let idx = 0;
|
||||
let processed = 0;
|
||||
let skipped = 0;
|
||||
|
||||
function processNext() {
|
||||
if (idx >= files.length) {
|
||||
resolve();
|
||||
if (skipped > 0) {
|
||||
console.warn(
|
||||
`[tenantSchema] applyTenantSchemaToAllFiles: processed ${processed}, skipped ${skipped} invalid file(s) in ${spacesDir}`
|
||||
);
|
||||
}
|
||||
resolve({ processed, skipped });
|
||||
return;
|
||||
}
|
||||
const fileName = files[idx++];
|
||||
const dbPath = path.join(spacesDir, fileName);
|
||||
if (!isSqliteDatabaseFile(dbPath)) {
|
||||
skipped++;
|
||||
console.warn(`[tenantSchema] Skipping non-SQLite file: ${dbPath}`);
|
||||
processNext();
|
||||
return;
|
||||
}
|
||||
const dbPath = path.join(spacesDir, files[idx++]);
|
||||
const tenantDb = new sqlite3.Database(dbPath, (openErr) => {
|
||||
if (openErr) {
|
||||
reject(openErr);
|
||||
skipped++;
|
||||
console.warn(`[tenantSchema] Skipping unreadable database ${dbPath}: ${openErr.message}`);
|
||||
processNext();
|
||||
return;
|
||||
}
|
||||
runTenantDDL(tenantDb, (runErr) => {
|
||||
@@ -339,6 +381,7 @@ function applyTenantSchemaToAllFiles(spacesDir) {
|
||||
reject(runErr);
|
||||
return;
|
||||
}
|
||||
processed++;
|
||||
processNext();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Adds optional price column to per-tenant handles table.
|
||||
* Idempotent: addMissingColumns in runTenantDDL handles re-runs safely.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { applyTenantSchemaToAllFiles } = require('../lib/tenantSchema');
|
||||
|
||||
module.exports = {
|
||||
up: function (db, callback) {
|
||||
const spacesDir = path.join(__dirname, '..', 'data', 'spaces');
|
||||
applyTenantSchemaToAllFiles(spacesDir)
|
||||
.then(() => callback(null))
|
||||
.catch(callback);
|
||||
},
|
||||
|
||||
down: function (db, callback) {
|
||||
callback(null);
|
||||
},
|
||||
};
|
||||
+118
-7
@@ -464,9 +464,23 @@
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background: rgba(255,255,255,0.35);
|
||||
border-color: white;
|
||||
.price-input {
|
||||
width: 90px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.price-unavailable {
|
||||
color: #c0392b;
|
||||
font-size: 0.82em;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.price-set {
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -485,13 +499,14 @@
|
||||
<h2>Upload Reserved Handles (CSV)</h2>
|
||||
</div>
|
||||
<p style="color:var(--text-secondary);margin-top:0;font-size:0.95em;">
|
||||
Upload a plain-text CSV file with one handle name per line. All handles will be imported with <strong>reserved</strong> status.
|
||||
Upload a plain-text CSV with one handle per line. Use <code>handle</code> or <code>handle,price</code> (price in sats).
|
||||
All imported handles get <strong>reserved</strong> status. Reserved handles without a price cannot be purchased.
|
||||
</p>
|
||||
<div class="upload-area" id="upload-area">
|
||||
<input type="file" id="csv-file-input" accept=".csv,.txt" />
|
||||
<div class="upload-icon">📂</div>
|
||||
<p>Drag & drop a CSV file here, or click to browse</p>
|
||||
<p class="file-hint">One handle name per line • .csv or .txt</p>
|
||||
<p class="file-hint">handle per line, or handle,price • .csv or .txt</p>
|
||||
</div>
|
||||
<div class="upload-actions">
|
||||
<span class="selected-file" id="selected-file-name"></span>
|
||||
@@ -531,6 +546,10 @@
|
||||
<option value="unpublished">unpublished</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="new-handle-price-wrap">
|
||||
<label style="font-size:0.85em;color:var(--text-secondary);display:block;margin-bottom:4px;">Price (sats, optional)</label>
|
||||
<input type="number" id="new-handle-price" min="0" step="1" placeholder="e.g. 50000" style="padding:8px 12px;border:2px solid var(--border-color);border-radius:6px;font-size:0.9em;width:140px;" />
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:0.85em;color:var(--text-secondary);display:block;margin-bottom:4px;">Script Pubkey (optional)</label>
|
||||
<input type="text" id="new-handle-notes" placeholder="5120..." style="padding:8px 12px;border:2px solid var(--border-color);border-radius:6px;font-size:0.9em;width:220px;" />
|
||||
@@ -576,6 +595,8 @@
|
||||
checkPlatformMode();
|
||||
loadHandles();
|
||||
setupFileInput();
|
||||
document.getElementById('new-handle-status').addEventListener('change', syncAddFormPriceVisibility);
|
||||
syncAddFormPriceVisibility();
|
||||
});
|
||||
|
||||
function getSpaceName() {
|
||||
@@ -699,6 +720,7 @@
|
||||
<tr id="row-${h.id}">
|
||||
<td><span class="handle-name">${escHtml(h.handle)}</span></td>
|
||||
<td><span class="status-badge status-${h.status}">${h.status}</span></td>
|
||||
<td>${renderPriceCell(h)}</td>
|
||||
<td style="color:var(--text-secondary);font-size:0.85em;font-family:'Courier New',monospace;" title="${escHtml(h.script_pubkey || '')}">${escHtml(truncatePubkey(h.script_pubkey))}</td>
|
||||
<td style="color:var(--text-secondary);font-size:0.8em;">${formatDate(h.created_at)}</td>
|
||||
<td>
|
||||
@@ -720,6 +742,7 @@
|
||||
<tr>
|
||||
<th>Handle</th>
|
||||
<th>Status</th>
|
||||
<th>Price (sats)</th>
|
||||
<th>Script Pubkey</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
@@ -730,6 +753,36 @@
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPriceCell(h) {
|
||||
if (h.status !== 'reserved') {
|
||||
return '<span style="color:#aaa;">—</span>';
|
||||
}
|
||||
const priceVal = h.price != null && h.price !== '' ? String(h.price) : '';
|
||||
if (priceVal) {
|
||||
return `
|
||||
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;">
|
||||
<input type="number" class="price-input" id="price-input-${h.id}" min="0" step="1" value="${escAttr(priceVal)}" />
|
||||
<button class="btn-success" onclick="savePrice(${h.id})">Save</button>
|
||||
<button class="btn-secondary" style="padding:4px 10px;font-size:0.8em;" onclick="clearPrice(${h.id})">Clear</button>
|
||||
</div>`;
|
||||
}
|
||||
return `
|
||||
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;">
|
||||
<span class="price-unavailable">unavailable</span>
|
||||
<input type="number" class="price-input" id="price-input-${h.id}" min="0" step="1" placeholder="sats" />
|
||||
<button class="btn-success" onclick="savePrice(${h.id})">Set</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function syncAddFormPriceVisibility() {
|
||||
const status = document.getElementById('new-handle-status').value;
|
||||
const wrap = document.getElementById('new-handle-price-wrap');
|
||||
wrap.style.display = status === 'reserved' ? 'block' : 'none';
|
||||
if (status !== 'reserved') {
|
||||
document.getElementById('new-handle-price').value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function renderPagination() {
|
||||
const totalPages = Math.ceil(filteredHandles.length / PAGE_SIZE);
|
||||
const el = document.getElementById('pagination');
|
||||
@@ -810,31 +863,89 @@
|
||||
document.getElementById('add-handle-form').style.display = 'none';
|
||||
document.getElementById('new-handle-name').value = '';
|
||||
document.getElementById('new-handle-notes').value = '';
|
||||
document.getElementById('new-handle-price').value = '';
|
||||
document.getElementById('new-handle-status').value = 'reserved';
|
||||
syncAddFormPriceVisibility();
|
||||
}
|
||||
|
||||
async function addHandle() {
|
||||
const handle = document.getElementById('new-handle-name').value.trim();
|
||||
const status = document.getElementById('new-handle-status').value;
|
||||
const script_pubkey = document.getElementById('new-handle-notes').value.trim();
|
||||
const priceRaw = document.getElementById('new-handle-price').value.trim();
|
||||
if (!handle) { showNotification('Handle is required.', 'error'); return; }
|
||||
const body = { handle_name: handle, status, script_pubkey: script_pubkey || undefined };
|
||||
if (status === 'reserved' && priceRaw !== '') {
|
||||
body.price = parseInt(priceRaw, 10);
|
||||
if (!Number.isFinite(body.price) || body.price < 0) {
|
||||
showNotification('Price must be a non-negative integer (sats).', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/tenant/handles', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ handle_name: handle, status, script_pubkey: script_pubkey || undefined })
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.message);
|
||||
allHandles.unshift(data.handle);
|
||||
hideAddHandleForm();
|
||||
applyFilters();
|
||||
showNotification(`Handle "${handle}" added with status "${status}".`, 'success');
|
||||
const priceNote = status === 'reserved' && !priceRaw
|
||||
? ' (no price — not purchasable until a price is set)'
|
||||
: (priceRaw ? ` at ${priceRaw} sats` : '');
|
||||
showNotification(`Handle "${handle}" added with status "${status}"${priceNote}.`, 'success');
|
||||
} catch (err) {
|
||||
showNotification('Error adding handle: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function savePrice(id) {
|
||||
const input = document.getElementById(`price-input-${id}`);
|
||||
if (!input) return;
|
||||
const raw = input.value.trim();
|
||||
if (!raw) {
|
||||
showNotification('Enter a price in sats, or use Clear to remove pricing.', 'error');
|
||||
return;
|
||||
}
|
||||
const price = parseInt(raw, 10);
|
||||
if (!Number.isFinite(price) || price < 0) {
|
||||
showNotification('Price must be a non-negative integer (sats).', 'error');
|
||||
return;
|
||||
}
|
||||
await updatePrice(id, price);
|
||||
}
|
||||
|
||||
async function clearPrice(id) {
|
||||
if (!confirm('Clear price? This handle will be unavailable for purchase until a price is set.')) return;
|
||||
await updatePrice(id, null);
|
||||
}
|
||||
|
||||
async function updatePrice(id, price) {
|
||||
try {
|
||||
const res = await fetch(`/api/tenant/handles/${id}/price`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ price })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.message);
|
||||
const handle = allHandles.find(h => h.id === id);
|
||||
if (handle) handle.price = data.handle ? data.handle.price : price;
|
||||
applyFilters();
|
||||
showNotification(
|
||||
price == null
|
||||
? 'Price cleared — handle is unavailable for purchase.'
|
||||
: `Price set to ${price} sats (overrides tenant pricing strategy).`,
|
||||
'success'
|
||||
);
|
||||
} catch (err) {
|
||||
showNotification('Error updating price: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── CSV Upload ────────────────────────────────────────────────────────
|
||||
|
||||
function setupFileInput() {
|
||||
|
||||
@@ -117,6 +117,21 @@
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #27ae60;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #1e8449;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
@@ -523,12 +538,21 @@
|
||||
<td>${escHtml(String(confirmInfo))}</td>
|
||||
<td>${dateCell}</td>
|
||||
<td>
|
||||
<button class="btn btn-danger"
|
||||
style="padding: 5px 12px; font-size: 0.82em;"
|
||||
onclick="removePayment(${p.id}, '${escAttr(txShort)}')"
|
||||
title="${isWatching ? 'Stop watching and remove from list' : 'Remove from list'}">
|
||||
Remove
|
||||
</button>
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-success"
|
||||
style="padding: 5px 12px; font-size: 0.82em;"
|
||||
onclick="confirmPayment(${p.id}, '${escAttr(txShort)}')"
|
||||
${isWatching ? '' : 'disabled'}
|
||||
title="${isWatching ? 'Manually confirm this transaction (same as Spaced callback)' : 'Already confirmed or not watching'}">
|
||||
Confirm
|
||||
</button>
|
||||
<button class="btn btn-danger"
|
||||
style="padding: 5px 12px; font-size: 0.82em;"
|
||||
onclick="removePayment(${p.id}, '${escAttr(txShort)}')"
|
||||
title="${isWatching ? 'Stop watching and remove from list' : 'Remove from list'}">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
@@ -599,6 +623,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmPayment(id, txShort) {
|
||||
if (!confirm(`Manually confirm ${txShort ? 'transaction ' + txShort : 'this payment'} as on-chain confirmed?`)) return;
|
||||
try {
|
||||
const url = spaceParam
|
||||
? `/api/tenant/payments/${id}/confirm?space=${encodeURIComponent(spaceParam)}`
|
||||
: `/api/tenant/payments/${id}/confirm`;
|
||||
const res = await fetch(url, { method: 'POST', credentials: 'same-origin' });
|
||||
let data;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch (_) {
|
||||
throw new Error(res.status === 401 ? 'Authentication required' : `HTTP ${res.status}`);
|
||||
}
|
||||
if (!res.ok || !data.success) {
|
||||
throw new Error(data.message || `HTTP ${res.status}`);
|
||||
}
|
||||
showNotification(data.message || 'Payment confirmed.', 'success');
|
||||
await loadPayments();
|
||||
} catch (err) {
|
||||
showNotification('Error: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function removePayment(id, txShort) {
|
||||
if (!confirm(`Remove ${txShort ? 'transaction ' + txShort : 'this payment'} from the monitor list?`)) return;
|
||||
try {
|
||||
|
||||
@@ -110,6 +110,22 @@
|
||||
background: var(--button-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
padding: 5px 12px;
|
||||
font-size: 0.82em;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.btn-danger:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
color: rgba(255,255,255,0.85);
|
||||
@@ -358,6 +374,10 @@
|
||||
? `<ul class="purchase-list">${quote.purchases.map(renderPurchaseItem).join('')}</ul>`
|
||||
: '<span class="purchase-meta">—</span>';
|
||||
|
||||
const deleteBtn = quote.purchased
|
||||
? `<button class="btn btn-danger" disabled title="Cannot delete a quote with linked purchases">Delete</button>`
|
||||
: `<button class="btn btn-danger" onclick="deleteQuote(${quote.id}, '${escAttr(quote.handle)}')">Delete</button>`;
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${quote.id}</td>
|
||||
@@ -370,6 +390,7 @@
|
||||
<td>${paidCell}</td>
|
||||
<td>${stagedCell}</td>
|
||||
<td>${subsStatusCell}</td>
|
||||
<td>${deleteBtn}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
@@ -389,6 +410,7 @@
|
||||
<th>Paid</th>
|
||||
<th>SUBS staged</th>
|
||||
<th>SUBS status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
@@ -430,6 +452,18 @@
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
async function deleteQuote(id, handle) {
|
||||
if (!confirm(`Delete quote #${id} for "${handle}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/tenant/quotes/${id}`, { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.message || `HTTP ${res.status}`);
|
||||
await loadQuotes();
|
||||
} catch (err) {
|
||||
alert('Error deleting quote: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
@@ -437,6 +471,10 @@
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function escAttr(str) {
|
||||
return String(str).replace(/'/g, "\\'");
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -37,6 +37,9 @@ bitcoin.initEccLib(ecc);
|
||||
const app = express();
|
||||
const HOST = process.env.PLATFORM_HOST || '127.0.0.1';
|
||||
const PORT = process.env.PLATFORM_PORT || 3000;
|
||||
/** Hostname Spaced uses to reach payment callbacks (must be reachable from the Spaced container). */
|
||||
const PLATFORM_CALLBACK_HOST =
|
||||
(process.env.PLATFORM_CALLBACK_HOST && String(process.env.PLATFORM_CALLBACK_HOST).trim()) || HOST;
|
||||
const PUBLIC_PLATFORM_HOST = process.env.PUBLIC_PLATFORM_HOST;
|
||||
const PLATFORM_MODE = process.env.PLATFORM_MODE || 'prod';
|
||||
|
||||
@@ -93,9 +96,15 @@ function getSpacesApiCertProxyPathPrefix() {
|
||||
}
|
||||
const SPACES_API_CERT_PROXY_PREFIX = getSpacesApiCertProxyPathPrefix();
|
||||
|
||||
// Callback base URL for Spaced TX callbacks — derived from PLATFORM_HOST and PLATFORM_PORT
|
||||
// Callback base URL for Spaced TX callbacks — PLATFORM_CALLBACK_HOST + PLATFORM_PORT
|
||||
const PLATFORM_PAYMENT_CALLBACK_BASE_URL = `http://${PLATFORM_CALLBACK_HOST}:${PORT}`;
|
||||
// Legacy/general callback base (cert/prove webhooks); still uses bind host unless overridden by *_PUBLIC_URL
|
||||
const PLATFORM_CALLBACK_BASE_URL = `http://${HOST}:${PORT}`;
|
||||
|
||||
function getPaymentCallbackUrl(tenantName) {
|
||||
return `${PLATFORM_PAYMENT_CALLBACK_BASE_URL}/api/payments/callback?tenant=${encodeURIComponent(tenantName)}`;
|
||||
}
|
||||
|
||||
// Commitment Configuration
|
||||
const COMMITMENT_VBYTES = 256;
|
||||
|
||||
@@ -121,6 +130,12 @@ const FALLBACK_48_BLOCK_FEERATE_SAT_VB = 1;
|
||||
// Test mode: use fallback fee rates instead of RPC calls
|
||||
const USE_TEST_FEE_RATES = process.env.USE_TEST_FEE_RATES === 'true' || process.env.USE_TEST_FEE_RATES === '1';
|
||||
|
||||
/** Drop tenant quotes older than this many days before inserting new quotes (default 14). */
|
||||
const KEEP_QUOTES_FOR_DAYS = (() => {
|
||||
const parsed = parseInt(process.env.KEEP_QUOTES_FOR_DAYS || '14', 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 14;
|
||||
})();
|
||||
|
||||
/** When TRUE, tenant SUBS connections use config subs_alt_uri instead of subs_uri. */
|
||||
const USE_SUBS_ALT_URI = String(process.env.USE_SUBS_ALT_URI || 'FALSE').toUpperCase() === 'TRUE';
|
||||
|
||||
@@ -180,6 +195,12 @@ function logEnvironmentVariables() {
|
||||
defaultValue: '3000',
|
||||
description: 'HTTP server port'
|
||||
},
|
||||
{
|
||||
name: 'PLATFORM_CALLBACK_HOST',
|
||||
value: process.env.PLATFORM_CALLBACK_HOST,
|
||||
defaultValue: '(same as PLATFORM_HOST)',
|
||||
description: 'Hostname Spaced uses for payment TX callbacks (reachable from Spaced container)'
|
||||
},
|
||||
{
|
||||
name: 'PLATFORM_MODE',
|
||||
value: process.env.PLATFORM_MODE,
|
||||
@@ -3360,10 +3381,16 @@ app.get('/tenant-payments', requireAdminOrTenant, setupTenantDatabase, (req, res
|
||||
});
|
||||
|
||||
// Serve tenant quotes page
|
||||
app.get('/tenant-quotes', requireAdminOrTenant, setupTenantDatabase, (req, res) => {
|
||||
app.get('/tenant-quotes', requireAdminOrTenant, setupTenantDatabase, async (req, res) => {
|
||||
if (req.adminUser) {
|
||||
const requestedSpace = req.query.space;
|
||||
if (!requestedSpace) {
|
||||
if (req.query.handle) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'space parameter required when handle is provided',
|
||||
});
|
||||
}
|
||||
return res.status(400).send(`
|
||||
<html>
|
||||
<head><title>Error - SpaceOps</title></head>
|
||||
@@ -3371,9 +3398,35 @@ app.get('/tenant-quotes', requireAdminOrTenant, setupTenantDatabase, (req, res)
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
res.sendFile(path.join(__dirname, 'public', 'tenant-quotes.html'));
|
||||
return;
|
||||
}
|
||||
|
||||
const handleParam = req.query.handle;
|
||||
if (handleParam && String(handleParam).trim()) {
|
||||
try {
|
||||
const spaceName = String(req.query.space || req.spaceName || req.tenantName || '').trim();
|
||||
if (!spaceName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'space is required when handle is provided',
|
||||
});
|
||||
}
|
||||
const tenantDb = await getTenantDatabase(req.tenantName);
|
||||
const result = await getTenantHandleQuoteState(tenantDb, spaceName, handleParam);
|
||||
if (!result.found) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: result.message || 'Handle not found',
|
||||
space: spaceName,
|
||||
handle: result.handle,
|
||||
});
|
||||
}
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
console.error('[quotes] Error loading handle state:', error);
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
res.sendFile(path.join(__dirname, 'public', 'tenant-quotes.html'));
|
||||
});
|
||||
|
||||
@@ -4092,10 +4145,12 @@ app.post('/spaces/:spaceName/:subspace', async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (quoteRow.state === 'taken') {
|
||||
if (quoteRow.state === 'taken' || quoteRow.state === 'unavailable') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Handle is no longer available for purchase',
|
||||
message: quoteRow.state === 'unavailable'
|
||||
? 'Handle is reserved without a preset price and cannot be purchased'
|
||||
: 'Handle is no longer available for purchase',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6139,7 +6194,7 @@ async function registerSpacedPaymentWatch(tenantName, tenantDb, txid, purchaseId
|
||||
}
|
||||
|
||||
const clientId = getPaymentClientId(tenantName);
|
||||
const callbackUrl = `${PLATFORM_CALLBACK_BASE_URL}/api/payments/callback?tenant=${encodeURIComponent(tenantName)}`;
|
||||
const callbackUrl = getPaymentCallbackUrl(tenantName);
|
||||
const allWatching = await new Promise((resolve, reject) => {
|
||||
tenantDb.all(
|
||||
`SELECT transaction_id FROM payments WHERE status = 'watching'`,
|
||||
@@ -7481,6 +7536,61 @@ function parseSubsHandleLookupResponse(httpStatus, parsedBody, spaceName, subspa
|
||||
};
|
||||
}
|
||||
|
||||
function parseTenantHandlePrice(raw) {
|
||||
if (raw === undefined || raw === null || String(raw).trim() === '') {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseInt(String(raw).trim(), 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function lookupTenantReservedHandle(tenantDb, subspace) {
|
||||
const key = String(subspace || '').trim();
|
||||
if (!key) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
tenantDb.get(
|
||||
`SELECT * FROM handles WHERE LOWER(handle) = LOWER(?) AND status = 'reserved'`,
|
||||
[key],
|
||||
(err, row) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(row || null);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function purgeExpiredTenantQuotes(tenantDb) {
|
||||
const cutoffModifier = `-${KEEP_QUOTES_FOR_DAYS} days`;
|
||||
return new Promise((resolve, reject) => {
|
||||
tenantDb.run(
|
||||
`DELETE FROM quotes
|
||||
WHERE datetime(created_at) < datetime('now', ?)
|
||||
AND id NOT IN (SELECT quote_id FROM purchases WHERE quote_id IS NOT NULL)`,
|
||||
[cutoffModifier],
|
||||
function (err) {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
if (this.changes > 0) {
|
||||
console.log(
|
||||
`[quotes] Purged ${this.changes} quote(s) older than ${KEEP_QUOTES_FOR_DAYS} days (no linked purchases)`
|
||||
);
|
||||
}
|
||||
resolve(this.changes);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getTenantConfigValueFromDb(tenantDb, key) {
|
||||
return new Promise((resolve, reject) => {
|
||||
tenantDb.get('SELECT value FROM config WHERE key = ?', [key], (err, row) => {
|
||||
@@ -7728,6 +7838,19 @@ app.all('/spaces/:spaceName/:subspace', async (req, res) => {
|
||||
} else {
|
||||
console.log(`[spaces-proxy] Handle ${responseData.handle} is available for sale; computing tenant subname price`);
|
||||
|
||||
const tenantReserved = await lookupTenantReservedHandle(tenantDb, subspace);
|
||||
const reservedPrice = parseTenantHandlePrice(tenantReserved?.price);
|
||||
|
||||
if (tenantReserved && reservedPrice == null) {
|
||||
responseData.state = 'unavailable';
|
||||
responseData.id = 0;
|
||||
delete responseData.price;
|
||||
console.log(
|
||||
`[spaces-proxy] Reserved handle ${subspace} has no preset price — state=unavailable (not purchasable)`
|
||||
);
|
||||
modifiedBody = JSON.stringify(responseData);
|
||||
} else {
|
||||
|
||||
// Get RPC fee estimates first (used for both platform fees and SPTR fees)
|
||||
let rpcEstimate1, rpcEstimate6, rpcEstimate48;
|
||||
try {
|
||||
@@ -7864,11 +7987,18 @@ app.all('/spaces/:spaceName/:subspace', async (req, res) => {
|
||||
console.log(`[spaces-proxy] Skipping fee replacement due to config error`);
|
||||
}
|
||||
|
||||
responseData.price = await computeSubnamePrice(tenantDb, {
|
||||
subspace,
|
||||
spaceName,
|
||||
handle: responseData.handle,
|
||||
});
|
||||
responseData.price = reservedPrice != null
|
||||
? reservedPrice
|
||||
: await computeSubnamePrice(tenantDb, {
|
||||
subspace,
|
||||
spaceName,
|
||||
handle: responseData.handle,
|
||||
});
|
||||
if (reservedPrice != null) {
|
||||
console.log(
|
||||
`[spaces-proxy] Using reserved handle price ${reservedPrice} sats for ${subspace} (overrides tenant pricing strategy)`
|
||||
);
|
||||
}
|
||||
|
||||
// Check for pending purchase for this handle
|
||||
if (responseData.handle) {
|
||||
@@ -7956,11 +8086,16 @@ app.all('/spaces/:spaceName/:subspace', async (req, res) => {
|
||||
let quoteId = null;
|
||||
if (responseData.handle && responseData.price !== undefined && responseData.state) {
|
||||
// If state is "taken", don't insert and set id to 0
|
||||
if (responseData.state === 'taken') {
|
||||
if (responseData.state === 'taken' || responseData.state === 'unavailable') {
|
||||
quoteId = 0;
|
||||
console.log(`[spaces-proxy] Quote state is "taken", skipping insertion and setting id to 0`);
|
||||
console.log(`[spaces-proxy] Quote state is "${responseData.state}", skipping insertion and setting id to 0`);
|
||||
} else {
|
||||
try {
|
||||
try {
|
||||
await purgeExpiredTenantQuotes(tenantDb);
|
||||
} catch (purgeErr) {
|
||||
console.error(`[spaces-proxy] Error purging expired quotes:`, purgeErr.message);
|
||||
}
|
||||
quoteId = await new Promise((resolve, reject) => {
|
||||
const insertQuery = `
|
||||
INSERT INTO quotes (handle, price, state, "1_block_fee", "6_block_fee", "48_block_fee", sptr_price, "1_block_sptr_fee", "6_block_sptr_fee", "48_block_sptr_fee")
|
||||
@@ -8004,7 +8139,7 @@ app.all('/spaces/:spaceName/:subspace', async (req, res) => {
|
||||
|
||||
// Apply coupon discount to price if a valid coupon query param is provided
|
||||
const couponParam = req.query.coupon;
|
||||
if (couponParam && responseData.price !== undefined && responseData.state !== 'taken') {
|
||||
if (couponParam && responseData.price !== undefined && responseData.state !== 'taken' && responseData.state !== 'unavailable') {
|
||||
const normalizedCoupon = couponParam.trim().toUpperCase();
|
||||
try {
|
||||
const coupon = await new Promise((resolve, reject) => {
|
||||
@@ -8045,6 +8180,7 @@ app.all('/spaces/:spaceName/:subspace', async (req, res) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (parseError) {
|
||||
console.log(`[spaces-proxy] Response is not valid JSON, skipping fee replacement: ${parseError.message}`);
|
||||
@@ -8952,18 +9088,24 @@ app.post('/api/tenant/handles/refresh', requireAdminOrTenant, setupTenantDatabas
|
||||
|
||||
app.post('/api/tenant/handles', requireAdminOrTenant, setupTenantDatabase, async (req, res) => {
|
||||
try {
|
||||
const { handle_name, status, script_pubkey } = req.body;
|
||||
const { handle_name, status, script_pubkey, price } = req.body;
|
||||
console.log(`[handle] POST /api/tenant/handles tenant=${req.tenantName} handle=${handle_name}`);
|
||||
if (!handle_name || !handle_name.trim()) {
|
||||
return res.status(400).json({ success: false, message: 'handle_name is required' });
|
||||
}
|
||||
const validStatuses = ['reserved', 'available', 'staged', 'committed', 'parked', 'published', 'unpublished'];
|
||||
const resolvedStatus = status && validStatuses.includes(status) ? status : 'reserved';
|
||||
const resolvedPrice = price === undefined || price === null || String(price).trim() === ''
|
||||
? null
|
||||
: parseTenantHandlePrice(price);
|
||||
if (price !== undefined && price !== null && String(price).trim() !== '' && resolvedPrice == null) {
|
||||
return res.status(400).json({ success: false, message: 'price must be a non-negative integer (sats)' });
|
||||
}
|
||||
const tenantDb = await getTenantDatabase(req.tenantName);
|
||||
const newId = await new Promise((resolve, reject) => {
|
||||
tenantDb.run(
|
||||
'INSERT INTO handles (handle, status, script_pubkey) VALUES (?, ?, ?)',
|
||||
[handle_name.trim(), resolvedStatus, script_pubkey || null],
|
||||
'INSERT INTO handles (handle, status, script_pubkey, price) VALUES (?, ?, ?, ?)',
|
||||
[handle_name.trim(), resolvedStatus, script_pubkey || null, resolvedPrice],
|
||||
function (err) {
|
||||
if (err) { reject(err); return; }
|
||||
resolve(this.lastID);
|
||||
@@ -9001,11 +9143,26 @@ app.post('/api/tenant/handles/upload-csv', requireAdminOrTenant, setupTenantData
|
||||
const tenantDb = await getTenantDatabase(req.tenantName);
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
for (const name of lines) {
|
||||
for (const line of lines) {
|
||||
const parts = line.split(',').map((p) => p.trim());
|
||||
const name = parts[0];
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const priceRaw = parts.length > 1 ? parts[1] : null;
|
||||
const resolvedPrice = priceRaw == null || priceRaw === ''
|
||||
? null
|
||||
: parseTenantHandlePrice(priceRaw);
|
||||
if (priceRaw != null && priceRaw !== '' && resolvedPrice == null) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: `Invalid price for handle "${name}": must be a non-negative integer (sats)`,
|
||||
});
|
||||
}
|
||||
const changes = await new Promise((resolve, reject) => {
|
||||
tenantDb.run(
|
||||
'INSERT OR IGNORE INTO handles (handle, status) VALUES (?, ?)',
|
||||
[name, 'reserved'],
|
||||
'INSERT OR IGNORE INTO handles (handle, status, price) VALUES (?, ?, ?)',
|
||||
[name, 'reserved', resolvedPrice],
|
||||
function (err) {
|
||||
if (err) { reject(err); return; }
|
||||
resolve(this.changes);
|
||||
@@ -9060,6 +9217,51 @@ app.put('/api/tenant/handles/:handleId/status', requireAdminOrTenant, setupTenan
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/tenant/handles/:handleId/price', requireAdminOrTenant, setupTenantDatabase, async (req, res) => {
|
||||
try {
|
||||
const handleId = parseInt(req.params.handleId, 10);
|
||||
const { price } = req.body;
|
||||
console.log(`[handle] PUT /api/tenant/handles/${handleId}/price tenant=${req.tenantName} price=${price}`);
|
||||
if (isNaN(handleId)) {
|
||||
return res.status(400).json({ success: false, message: 'Invalid handle ID' });
|
||||
}
|
||||
const resolvedPrice = price === undefined || price === null || String(price).trim() === ''
|
||||
? null
|
||||
: parseTenantHandlePrice(price);
|
||||
if (price !== undefined && price !== null && String(price).trim() !== '' && resolvedPrice == null) {
|
||||
return res.status(400).json({ success: false, message: 'price must be a non-negative integer (sats), or null to clear' });
|
||||
}
|
||||
const tenantDb = await getTenantDatabase(req.tenantName);
|
||||
const existing = await new Promise((resolve, reject) => {
|
||||
tenantDb.get('SELECT * FROM handles WHERE id = ?', [handleId], (err, row) => {
|
||||
if (err) { reject(err); return; }
|
||||
resolve(row);
|
||||
});
|
||||
});
|
||||
if (!existing) {
|
||||
return res.status(404).json({ success: false, message: 'Handle not found' });
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
tenantDb.run(
|
||||
'UPDATE handles SET price = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[resolvedPrice, handleId],
|
||||
(err) => { if (err) { reject(err); return; } resolve(); }
|
||||
);
|
||||
});
|
||||
const updated = await new Promise((resolve, reject) => {
|
||||
tenantDb.get('SELECT * FROM handles WHERE id = ?', [handleId], (err, row) => {
|
||||
if (err) { reject(err); return; }
|
||||
resolve(row);
|
||||
});
|
||||
});
|
||||
console.log(`[handle] PUT /api/tenant/handles/${handleId}/price response: updated`);
|
||||
res.json({ success: true, message: 'Handle price updated successfully', handle: updated });
|
||||
} catch (error) {
|
||||
console.error('[handle] Error updating handle price:', error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/tenant/handles/:handleId', requireAdminOrTenant, setupTenantDatabase, async (req, res) => {
|
||||
try {
|
||||
const handleId = parseInt(req.params.handleId, 10);
|
||||
@@ -9085,6 +9287,190 @@ app.delete('/api/tenant/handles/:handleId', requireAdminOrTenant, setupTenantDat
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Tenant quotes helpers ───────────────────────────────────────────────────
|
||||
|
||||
function normalizeTenantHandleQuery(handleParam, spaceName) {
|
||||
const raw = String(handleParam || '').trim();
|
||||
const space = String(spaceName || '').trim().replace(/^@/, '');
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
if (raw.includes('@')) {
|
||||
return raw;
|
||||
}
|
||||
if (!space) {
|
||||
return raw;
|
||||
}
|
||||
return `${raw}@${space}`;
|
||||
}
|
||||
|
||||
function enrichQuoteWithPurchasesAndSubs(quote, quotePurchases, subsStatusMap) {
|
||||
const purchaseRows = (quotePurchases || []).map((purchase) => {
|
||||
const subsInfo = subsStatusMap.get(String(purchase.handle || '').trim().toLowerCase());
|
||||
const subsHandleStatus = subsInfo?.status || null;
|
||||
return {
|
||||
id: purchase.id,
|
||||
handle: purchase.handle,
|
||||
purchase_type: purchase.purchase_type || 'subname',
|
||||
status: purchase.status,
|
||||
unified_status: purchase.unified_status,
|
||||
job_id: purchase.job_id,
|
||||
created_at: purchase.created_at,
|
||||
payment_confirmed: isPurchasePaymentConfirmed(purchase),
|
||||
subs_handle_status: subsHandleStatus,
|
||||
subs_staged: isSubsHandleStagedStatus(subsHandleStatus),
|
||||
};
|
||||
});
|
||||
|
||||
const subnamePurchase =
|
||||
purchaseRows.find((row) => (row.purchase_type || 'subname') === 'subname') || null;
|
||||
|
||||
const handleKey = String(quote?.handle || subnamePurchase?.handle || '').trim().toLowerCase();
|
||||
const subsInfo = handleKey ? subsStatusMap.get(handleKey) : null;
|
||||
const subsHandleStatus =
|
||||
subnamePurchase?.subs_handle_status || subsInfo?.status || null;
|
||||
|
||||
return {
|
||||
id: quote?.id ?? null,
|
||||
handle: quote?.handle || subnamePurchase?.handle || null,
|
||||
price: quote?.price ?? null,
|
||||
state: quote?.state ?? null,
|
||||
block_fees: quote
|
||||
? {
|
||||
'1': quote['1_block_fee'],
|
||||
'6': quote['6_block_fee'],
|
||||
'48': quote['48_block_fee'],
|
||||
}
|
||||
: null,
|
||||
created_at: quote?.created_at ?? null,
|
||||
purchased: purchaseRows.length > 0,
|
||||
purchase_count: purchaseRows.length,
|
||||
purchases: purchaseRows,
|
||||
payment_confirmed: subnamePurchase
|
||||
? subnamePurchase.payment_confirmed
|
||||
: purchaseRows.some((row) => row.payment_confirmed),
|
||||
subs_handle_status: subsHandleStatus,
|
||||
subs_staged: subnamePurchase ? subnamePurchase.subs_staged : isSubsHandleStagedStatus(subsHandleStatus),
|
||||
subs_publish_status: subsInfo?.publish_status ?? null,
|
||||
subs_parked: subsInfo?.parked ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadSubsStatusMapForSpace(tenantDb, spaceName) {
|
||||
let subsConfigured = false;
|
||||
let subsError = null;
|
||||
const subsStatusMap = new Map();
|
||||
try {
|
||||
const subsUri = await getTenantSubsUriFromDb(tenantDb);
|
||||
if (subsUri) {
|
||||
subsConfigured = true;
|
||||
const subsHandles = await fetchAllSubsHandlesForSpace(subsUri, spaceName, tenantDb);
|
||||
return {
|
||||
subsConfigured,
|
||||
subsError,
|
||||
subsStatusMap: buildSubsHandleStatusMap(subsHandles, spaceName),
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
subsError = err.message;
|
||||
}
|
||||
return { subsConfigured, subsError, subsStatusMap };
|
||||
}
|
||||
|
||||
async function loadSubsStatusForHandle(tenantDb, spaceName, handle) {
|
||||
const subsUri = await getTenantSubsUriFromDb(tenantDb);
|
||||
if (!subsUri) {
|
||||
return { subsConfigured: false, subsError: null, subsStatusMap: new Map() };
|
||||
}
|
||||
|
||||
const parts = parseHandleToSpaceAndSubspace(handle);
|
||||
if (!parts) {
|
||||
return { subsConfigured: true, subsError: null, subsStatusMap: new Map() };
|
||||
}
|
||||
|
||||
try {
|
||||
const subsResult = await fetchSubsHandleRecord(tenantDb, parts.spaceName, parts.subspace);
|
||||
const map = new Map();
|
||||
if (subsResult.ok) {
|
||||
const status = String(subsResult.handle.status || '').trim().toLowerCase();
|
||||
map.set(String(handle).trim().toLowerCase(), {
|
||||
status,
|
||||
publish_status: subsResult.handle.publish_status ?? null,
|
||||
parked: subsResult.handle.parked ?? null,
|
||||
script_pubkey: subsResult.handle.script_pubkey || null,
|
||||
});
|
||||
}
|
||||
return { subsConfigured: true, subsError: null, subsStatusMap: map };
|
||||
} catch (err) {
|
||||
return { subsConfigured: true, subsError: err.message, subsStatusMap: new Map() };
|
||||
}
|
||||
}
|
||||
|
||||
async function getTenantHandleQuoteState(tenantDb, spaceName, handleParam) {
|
||||
const handle = normalizeTenantHandleQuery(handleParam, spaceName);
|
||||
if (!handle) {
|
||||
return { found: false, message: 'handle is required' };
|
||||
}
|
||||
|
||||
const quote = await new Promise((resolve, reject) => {
|
||||
tenantDb.get(
|
||||
`SELECT * FROM quotes WHERE LOWER(handle) = LOWER(?) ORDER BY id DESC LIMIT 1`,
|
||||
[handle],
|
||||
(err, row) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(row || null);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const purchases = await new Promise((resolve, reject) => {
|
||||
tenantDb.all(
|
||||
`SELECT id, quote_id, handle, purchase_type, status, unified_status, job_id, created_at
|
||||
FROM purchases
|
||||
WHERE LOWER(handle) = LOWER(?)
|
||||
ORDER BY id ASC`,
|
||||
[handle],
|
||||
(err, rows) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(rows || []);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const { subsConfigured, subsError, subsStatusMap } = await loadSubsStatusForHandle(
|
||||
tenantDb,
|
||||
spaceName,
|
||||
handle
|
||||
);
|
||||
|
||||
const hasSubsEntry = subsStatusMap.has(String(handle).trim().toLowerCase());
|
||||
if (!quote && purchases.length === 0 && !hasSubsEntry) {
|
||||
return {
|
||||
found: false,
|
||||
message: `No quote, purchase, or SUBS record found for ${handle}`,
|
||||
handle,
|
||||
};
|
||||
}
|
||||
|
||||
const state = enrichQuoteWithPurchasesAndSubs(quote, purchases, subsStatusMap);
|
||||
|
||||
return {
|
||||
found: true,
|
||||
success: true,
|
||||
space: spaceName,
|
||||
handle,
|
||||
subs_configured: subsConfigured,
|
||||
subs_error: subsError,
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Payments Monitor API ────────────────────────────────────────────────────
|
||||
|
||||
app.get('/api/tenant/quotes', requireAdminOrTenant, setupTenantDatabase, async (req, res) => {
|
||||
@@ -9092,6 +9478,20 @@ app.get('/api/tenant/quotes', requireAdminOrTenant, setupTenantDatabase, async (
|
||||
const tenantDb = await getTenantDatabase(req.tenantName);
|
||||
const spaceName = String(req.spaceName || req.query.space || req.tenantName || '').trim();
|
||||
|
||||
const handleParam = req.query.handle;
|
||||
if (handleParam && String(handleParam).trim()) {
|
||||
const result = await getTenantHandleQuoteState(tenantDb, spaceName, handleParam);
|
||||
if (!result.found) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: result.message || 'Handle not found',
|
||||
space: spaceName,
|
||||
handle: result.handle,
|
||||
});
|
||||
}
|
||||
return res.json(result);
|
||||
}
|
||||
|
||||
const quotes = await new Promise((resolve, reject) => {
|
||||
tenantDb.all('SELECT * FROM quotes ORDER BY id DESC', (err, rows) => {
|
||||
if (err) {
|
||||
@@ -9133,57 +9533,14 @@ app.get('/api/tenant/quotes', requireAdminOrTenant, setupTenantDatabase, async (
|
||||
let subsStatusMap = new Map();
|
||||
let subsConfigured = false;
|
||||
let subsError = null;
|
||||
try {
|
||||
const subsUri = await getTenantSubsUriFromDb(tenantDb);
|
||||
if (subsUri) {
|
||||
subsConfigured = true;
|
||||
const subsHandles = await fetchAllSubsHandlesForSpace(subsUri, spaceName, tenantDb);
|
||||
subsStatusMap = buildSubsHandleStatusMap(subsHandles, spaceName);
|
||||
}
|
||||
} catch (err) {
|
||||
subsError = err.message;
|
||||
}
|
||||
({ subsConfigured, subsError, subsStatusMap } = await loadSubsStatusMapForSpace(
|
||||
tenantDb,
|
||||
spaceName
|
||||
));
|
||||
|
||||
const enrichedQuotes = quotes.map((quote) => {
|
||||
const quotePurchases = purchasesByQuoteId.get(quote.id) || [];
|
||||
const purchaseRows = quotePurchases.map((purchase) => {
|
||||
const subsInfo = subsStatusMap.get(String(purchase.handle || '').trim().toLowerCase());
|
||||
const subsHandleStatus = subsInfo?.status || null;
|
||||
return {
|
||||
id: purchase.id,
|
||||
handle: purchase.handle,
|
||||
purchase_type: purchase.purchase_type || 'subname',
|
||||
status: purchase.status,
|
||||
unified_status: purchase.unified_status,
|
||||
job_id: purchase.job_id,
|
||||
created_at: purchase.created_at,
|
||||
payment_confirmed: isPurchasePaymentConfirmed(purchase),
|
||||
subs_handle_status: subsHandleStatus,
|
||||
subs_staged: isSubsHandleStagedStatus(subsHandleStatus),
|
||||
};
|
||||
});
|
||||
|
||||
const subnamePurchase =
|
||||
purchaseRows.find((row) => (row.purchase_type || 'subname') === 'subname') || null;
|
||||
|
||||
return {
|
||||
id: quote.id,
|
||||
handle: quote.handle,
|
||||
price: quote.price,
|
||||
state: quote.state,
|
||||
block_fees: {
|
||||
'1': quote['1_block_fee'],
|
||||
'6': quote['6_block_fee'],
|
||||
'48': quote['48_block_fee'],
|
||||
},
|
||||
created_at: quote.created_at,
|
||||
purchased: purchaseRows.length > 0,
|
||||
purchase_count: purchaseRows.length,
|
||||
purchases: purchaseRows,
|
||||
payment_confirmed: subnamePurchase ? subnamePurchase.payment_confirmed : purchaseRows.some((row) => row.payment_confirmed),
|
||||
subs_handle_status: subnamePurchase?.subs_handle_status || null,
|
||||
subs_staged: subnamePurchase ? subnamePurchase.subs_staged : false,
|
||||
};
|
||||
return enrichQuoteWithPurchasesAndSubs(quote, quotePurchases, subsStatusMap);
|
||||
});
|
||||
|
||||
console.log(`[quotes] GET /api/tenant/quotes tenant=${req.tenantName} quotes=${enrichedQuotes.length}`);
|
||||
@@ -9200,6 +9557,60 @@ app.get('/api/tenant/quotes', requireAdminOrTenant, setupTenantDatabase, async (
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/tenant/quotes/:quoteId', requireAdminOrTenant, setupTenantDatabase, async (req, res) => {
|
||||
try {
|
||||
const quoteId = parseInt(req.params.quoteId, 10);
|
||||
console.log(`[quotes] DELETE /api/tenant/quotes/${req.params.quoteId} tenant=${req.tenantName}`);
|
||||
if (isNaN(quoteId)) {
|
||||
return res.status(400).json({ success: false, message: 'Invalid quote ID' });
|
||||
}
|
||||
|
||||
const tenantDb = await getTenantDatabase(req.tenantName);
|
||||
const quote = await new Promise((resolve, reject) => {
|
||||
tenantDb.get('SELECT id, handle FROM quotes WHERE id = ?', [quoteId], (err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row || null);
|
||||
});
|
||||
});
|
||||
if (!quote) {
|
||||
return res.status(404).json({ success: false, message: 'Quote not found' });
|
||||
}
|
||||
|
||||
const linkedPurchases = await new Promise((resolve, reject) => {
|
||||
tenantDb.get(
|
||||
'SELECT COUNT(*) AS count FROM purchases WHERE quote_id = ?',
|
||||
[quoteId],
|
||||
(err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row ? row.count : 0);
|
||||
}
|
||||
);
|
||||
});
|
||||
if (linkedPurchases > 0) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: `Cannot delete quote #${quoteId}: ${linkedPurchases} linked purchase(s) exist`,
|
||||
});
|
||||
}
|
||||
|
||||
const changes = await new Promise((resolve, reject) => {
|
||||
tenantDb.run('DELETE FROM quotes WHERE id = ?', [quoteId], function (err) {
|
||||
if (err) reject(err);
|
||||
else resolve(this.changes);
|
||||
});
|
||||
});
|
||||
if (changes === 0) {
|
||||
return res.status(404).json({ success: false, message: 'Quote not found' });
|
||||
}
|
||||
|
||||
console.log(`[quotes] DELETE /api/tenant/quotes/${quoteId} response: deleted handle=${quote.handle}`);
|
||||
res.json({ success: true, message: `Quote #${quoteId} deleted successfully` });
|
||||
} catch (error) {
|
||||
console.error('[quotes] Error deleting quote:', error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/tenant/payments', requireAdminOrTenant, setupTenantDatabase, async (req, res) => {
|
||||
try {
|
||||
const tenantDb = await getTenantDatabase(req.tenantName);
|
||||
@@ -9406,6 +9817,86 @@ app.delete('/api/tenant/payments/:id', requireAdminOrTenant, setupTenantDatabase
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/tenant/payments/:id/confirm', requireAdminOrTenant, setupTenantDatabase, async (req, res) => {
|
||||
try {
|
||||
const paymentId = parseInt(req.params.id, 10);
|
||||
if (isNaN(paymentId)) {
|
||||
return res.status(400).json({ success: false, message: 'Invalid payment ID' });
|
||||
}
|
||||
|
||||
const tenantDb = await getTenantDatabase(req.tenantName);
|
||||
const paymentRow = await new Promise((resolve, reject) => {
|
||||
tenantDb.get(
|
||||
'SELECT id, transaction_id, status, purchase_id FROM payments WHERE id = ?',
|
||||
[paymentId],
|
||||
(err, row) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(row || null);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
if (!paymentRow) {
|
||||
return res.status(404).json({ success: false, message: 'Payment not found' });
|
||||
}
|
||||
|
||||
if (paymentRow.status === 'confirmed') {
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Payment is already confirmed',
|
||||
already_confirmed: true,
|
||||
payment_id: paymentRow.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (paymentRow.status !== 'watching') {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: `Payment cannot be confirmed while status is "${paymentRow.status}"`,
|
||||
});
|
||||
}
|
||||
|
||||
const txidLower = String(paymentRow.transaction_id || '').trim().toLowerCase();
|
||||
if (isSimulatedPaymentTxid(txidLower)) {
|
||||
cancelSimulatedPaymentConfirmation(req.tenantName, txidLower);
|
||||
}
|
||||
|
||||
const txMeta = await tryResolveTxMetaFromSpaced(txidLower);
|
||||
const result = await confirmPaymentTxOnChain(req.tenantName, tenantDb, txidLower, {
|
||||
block_height: txMeta.block_height ?? null,
|
||||
block_hash: txMeta.block_hash ?? null,
|
||||
confirmations: txMeta.confirmations ?? null,
|
||||
allowSimulatedDefaults: true,
|
||||
});
|
||||
|
||||
if (result.changes === 0) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: 'Payment could not be confirmed (still watching)',
|
||||
});
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[payments] manual confirm tenant=${req.tenantName} payment_id=${paymentRow.id} txid=${txidLower} purchase_confirmed=${result.purchaseConfirmed}`
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Payment confirmed and watch removed',
|
||||
payment_id: paymentRow.id,
|
||||
transaction_id: txidLower,
|
||||
purchase_confirmed: result.purchaseConfirmed,
|
||||
purchase_id: paymentRow.purchase_id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[payments] Error confirming payment:', error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Callback endpoint — called by Spaced daemon or Spaces Wallet, no session auth required
|
||||
app.post('/api/payments/callback', async (req, res) => {
|
||||
try {
|
||||
@@ -10998,6 +11489,50 @@ async function getTxCallback(clientId) {
|
||||
return spacedTxCallbackRpc('gettxcallback', { client_id: clientId });
|
||||
}
|
||||
|
||||
function parseSpacedTxMetaResult(meta, tipHeight = null) {
|
||||
if (!meta || typeof meta !== 'object') {
|
||||
return {};
|
||||
}
|
||||
const blockHeight = parseOptionalPaymentInt(
|
||||
meta.block_height ??
|
||||
meta.blockheight ??
|
||||
meta.height ??
|
||||
meta.block?.height ??
|
||||
meta.block?.block_height
|
||||
);
|
||||
let confirmations = parseOptionalPaymentInt(meta.confirmations ?? meta.confirmation_count);
|
||||
const blockHashRaw = meta.block_hash ?? meta.blockhash ?? meta.block?.hash ?? null;
|
||||
const blockHash =
|
||||
blockHashRaw != null && String(blockHashRaw).trim() !== '' ? String(blockHashRaw).trim() : null;
|
||||
if (confirmations == null && blockHeight != null && tipHeight != null && tipHeight >= blockHeight) {
|
||||
confirmations = tipHeight - blockHeight + 1;
|
||||
}
|
||||
return {
|
||||
block_height: blockHeight,
|
||||
block_hash: blockHash,
|
||||
confirmations,
|
||||
};
|
||||
}
|
||||
|
||||
async function tryResolveTxMetaFromSpaced(txidLower) {
|
||||
try {
|
||||
const meta = await spacedTxCallbackRpc('gettxmeta', { txid: txidLower });
|
||||
let tipHeight = null;
|
||||
try {
|
||||
const info = await checkSpacedServerInfo();
|
||||
if (info.success) {
|
||||
tipHeight = parseOptionalPaymentInt(info.height);
|
||||
}
|
||||
} catch (_err) {
|
||||
/* ignore tip lookup errors */
|
||||
}
|
||||
return parseSpacedTxMetaResult(meta, tipHeight);
|
||||
} catch (err) {
|
||||
console.warn(`[payments] gettxmeta failed for ${txidLower}: ${err.message}`);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On startup: re-register all tenant TX callback clients and restore their watched txids.
|
||||
* Called inside app.listen after existing startup checks.
|
||||
@@ -11042,7 +11577,7 @@ async function reestablishPaymentMonitoring() {
|
||||
}
|
||||
const txids = rows.map((r) => r.transaction_id);
|
||||
const clientId = getPaymentClientId(tenantName);
|
||||
const callbackUrl = `${PLATFORM_CALLBACK_BASE_URL}/api/payments/callback?tenant=${encodeURIComponent(tenantName)}`;
|
||||
const callbackUrl = getPaymentCallbackUrl(tenantName);
|
||||
try {
|
||||
await registerTxCallback(clientId, callbackUrl);
|
||||
await updateTxWatches(clientId, txids);
|
||||
|
||||
@@ -9,6 +9,7 @@ export PLATFORM_MODE=prod
|
||||
export PUBLIC_PLATFORM_HOST=spacesops.com
|
||||
export PLATFORM_HOST=0.0.0.0
|
||||
export PLATFORM_PORT=7264
|
||||
export PLATFORM_CALLBACK_HOST=spacesops.startos
|
||||
|
||||
export SPACED_RPC_HOST=natural-dean.local
|
||||
export SPACED_RPC_PORT=52611
|
||||
|
||||
Reference in New Issue
Block a user