Compare commits

...
3 Commits
Author SHA1 Message Date
spacesops e962873696 feat(handles): custom SIP-7 hex for take-on-chain + subs pipeline staged detection
- Add optional sip7_hex body param to take-on-chain endpoint with validation
- Add resolveCreatenumSip7Data() helper for custom TXT record hex payloads
- Add isPurchaseSubsPipelineStaged() and isQuoteSubsStaged() helpers
- Improve enrichQuoteWithPurchasesAndSubs() to consider purchase unified_status
- Simplify canExpungeTenantQuote() signature by using derived subs_staged
- Add Take On Chain modal in tenant-handles.html with SIP-7 hex input
- Remove legacy CLAUDE.md
2026-09-08 16:25:17 -04:00
spacesopsandCursor 187e6d06a4 Add expunge action for purchased staged quotes on tenant quotes page.
Allows tenants to force-remove quote, purchase, job, and payment records from SpacesOps when a handle is purchased and staged in SUBS, with an irreversible confirmation that SUBS cleanup remains manual.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 11:16:56 -04:00
spacesops 2cc280edd4 tenant conf 2026-08-25 21:40:56 -04:00
7 changed files with 865 additions and 154 deletions
+69
View File
@@ -0,0 +1,69 @@
# SpacesOps — Zed Project Instructions
## Immediate Context
- **Stack**: Node.js/Express, SQLite, Bitcoin/Taproot (BIP86), Nostr (NIP-01), SUBS name service
- **Main file**: `server.js` (~6,700 lines) — monolithic, **do not split** without explicit instruction
- **Tenant isolation**: Each space has its own `data/spaces/<name>.db` — never query across tenant DBs
- **Schema source of truth**: `lib/tenantSchema.js` — update this + `migrations/001_initial_schema.js` together on schema changes
## Critical Rules (Never Ignore)
### 1. SUBS-first Handle Policy
- The tenant `handles` table is **deprecated**. SUBS API is the source of truth.
- **Do not write to `handles`** in new flows. Read through SUBS (`GET /spaces/:space/handles/:handle`).
- Route `GET /spaces/:spaceName/:subspace` proxies to SUBS.
### 2. Schema Changes Require Dual Maintenance
- Modify tenant tables → update **both** `lib/tenantSchema.js` AND `migrations/001_initial_schema.js`
- `runTenantDDL()` applies changes to existing `data/spaces/*.db` files
### 3. Do Not Split `server.js`
- All routes, DB logic, signing, address derivation live in `server.js`
- Pre-existing exceptions only: `lib/tenantSchema.js`, `lib/messaging.js`, `lib/sip7Payload.js`, `lib/startosSsl.js`, `lib/subnamePricing.js`
### 4. Purchase Types
| Type | Quote | Key field | Notes |
|------|-------|-----------|-------|
| `subname` | Required | `quote_id` | Can bundle `include_pointer_purchase` |
| `pointer` | No | `job_id` | No coupons |
- `purchase_type` enum: `"subname"` / `"pointer"` (legacy `sptr` migrated)
- Bundled pointer: `parent_purchase_id` → subname purchase `id`
- `unified_status` recomputed on parent when bundled child updates
### 5. Fee Calculation
```
final_fee = platform_fee + (COMMITMENT_VBYTES * feerate_sat_vb)
```
- `COMMITMENT_VBYTES` hardcoded (usually 256)
- Replace SUBSD value in proxy **only if** it is less than platform minimum
### 6. Address Derivation (BIP86)
- Space XPUB path: `{derivation}/0/{purchase_id}`
- Platform default: `m/86/0/{space_id}/0/{purchase_id}`
- Libraries: `bip32`, `bitcoinjs-lib`, `tiny-secp256k1` (ECC must be initialized via `bitcoin.initEccLib(ecc)`)
## Quick Operations
```bash
# Reset all databases after schema changes
node server.js --reset-database
# Dev mode (nodemon)
npm run dev
# Health checks
curl http://localhost:3000/api/auth/status
curl http://localhost:3000/test-rpc
curl http://localhost:3000/api/config
```
## Where to Look
- Purchase flow logic → `server.js` (search for `[purchase]` log prefix)
- Tenant DDL → `lib/tenantSchema.js`
- Quote proxy/fee injection → `server.js` (search for `[spaces-proxy]`)
- Payment callbacks → `server.js` (search for `[payment-callback]`)
- Full API docs → `./*.md` files (`PURCHASE.md`, `COUPON.md`, `SUBS_API.md`, etc.)
- Env vars → `setup-spacesops-env.sh`
+241
View File
@@ -0,0 +1,241 @@
# Agent Instructions for SpacesOps
> This file provides context and rules for AI agents working in the SpacesOps codebase.
> **If you are uncertain about anything**, consult `README.md` for project overview and check `server.js` or `lib/tenantSchema.js` for canonical implementation details.
---
## Project Identity
SpacesOps is a **Node.js/Express platform** (~6,700 lines in `server.js`) for managing Bitcoin-space ownership confirmation and subspace name purchases. It is **not** a simple static-site demo. It integrates with:
- **Spaced daemon** — Bitcoin blockchain RPC (fee estimation, tx watching, wallet operations)
- **Nostr** — decentralized social protocol; operator signs ownership events (NIP-01)
- **SUBS service** — name resolution and handle management (`SUBS_URI_VALUE`)
- **CoinGecko** — price fetching for purchase quotes
---
## File Architecture
| File / Directory | Role |
|-----------------|------|
| `server.js` | **Monolithic Express app** — all routes, DB logic, Nostr signing, Taproot address derivation, purchase flow, admin/tenant APIs. **Do not split without explicit instruction.** |
| `lib/tenantSchema.js` | Shared tenant SQLite DDL; used by migrations and tenant onboarding. **Keep in sync** with `migrations/001_initial_schema.js`. |
| `lib/messaging.js` | Nostr DM thread/messaging logic |
| `lib/sip7Payload.js` | SIP-7 TXT record payload builders |
| `lib/startosSsl.js` | SUBS fetch wrapper with TLS diagnostics |
| `lib/subnamePricing.js` | Subname pricing calculation logic |
| `migrations/001_initial_schema.js` | **Single baseline migration** (version 1) for `data/local.db`; also applies `lib/tenantSchema.js` to `data/spaces/*.db` |
| `data/local.db` | Main SQLite database (config, spaces, users, schema_migrations) |
| `data/sessions.db` | Express session store |
| `data/prices.db` | CoinGecko price history |
| `data/spaces/<tenant>.db` | **Per-tenant isolated databases** (quotes, purchases, jobs, coupons, handles, payments) |
| `getprices.js`, `reportprices.js`, `listprices.js` | Standalone CoinGecko utilities |
| `spaced-createnum-api.mjs` | Node helper for `walletsendrequest` createnum with `bind_spk` |
---
## Critical Conventions
### 1. Do Not Split `server.js`
All business logic lives in `server.js`. **Do not extract routes, DB helpers, or middleware into new files unless explicitly asked to refactor.** The only pre-existing exceptions are `lib/tenantSchema.js`, `lib/messaging.js`, `lib/sip7Payload.js`, `lib/startosSsl.js`, and `lib/subnamePricing.js`.
### 2. Tenant Isolation
Each tenant (space) has a separate SQLite database under `data/spaces/<spaceName>.db`. **Never query across tenant DBs.** When working with tenant data:
- Derive the DB path from the space name
- Open a new `sqlite3.Database(...)` connection
- Use the tables defined in `lib/tenantSchema.js`: `config`, `quotes`, `jobs`, `purchases`, `coupons`, `handles`, `payments`
### 3. Schema Changes — Dual Maintenance
If you modify **tenant** table definitions, update **both**:
1. The `CREATE_*` constants in `lib/tenantSchema.js`
2. The matching steps in `migrations/001_initial_schema.js`
The migration applies `lib/tenantSchema.js` to existing `data/spaces/*.db` files via `applyTenantSchemaToAllFiles()`.
---
## Handle Source of Truth Policy (SUBS-first) ⚠️ HIGH PRIORITY
The tenant `handles` table is **deprecated**. Treat SUBS API data as the single source of truth.
### Rules for agents:
1. **Authoritative reads** — Use SUBS endpoints (`GET /spaces/:space/handles`, `GET /spaces/:space/handles/:handle`) for handle existence/status. Do not trust local `handles` rows as canonical.
2. **No new writes to `handles`** — Do not add or expand flows that insert/update/delete local `handles` rows. Exception: explicit migration/cleanup work.
3. **Read-through in UI** — Tenant/admin views should fetch statuses from SUBS, not tenant DB rows.
4. **Row alignment** — The route `GET /spaces/:spaceName/:subspace` is proxied to SUBS `GET /spaces/@spaceName/handles/:subspace`.
5. **Conflict resolution** — If local DB data and SUBS API disagree, **trust SUBS** and treat local rows as stale.
### Migration roadmap (do not skip steps):
1. Stop local `handles` writes in active flows
2. Read handles/status from SUBS in tenant/admin pages
3. Remove tenant `handles` table usage from server routes
4. Drop `handles` table from tenant schema in a dedicated migration once all callers are migrated
---
## Purchase Flow
The core business flow has two purchase types:
| Type | Quote Required | Key Field | Bundled? |
|------|---------------|-----------|----------|
| `subname` | Yes (`quote_id`) | `quote_id` | Can bundle `include_pointer_purchase` |
| `pointer` | No | `job_id` | No |
### Key endpoints (implementation in `server.js`):
1. **Quote** — `GET /spaces/:spaceName/:subspace?format=json` (proxied to SUBS; raises fees to platform minimums)
2. **Purchase** — `POST /spaces/:spaceName/:subspace` with `purchase_type`, `quote_id` (subname), `block_fee`, `price`, `conf_target` (1/6/48)
3. **Cancel** — `DELETE /spaces/:spaceName/:subspace` (subname uses `quote_id`; pointer uses `job_id`)
4. **Confirm** — `PUT /spaces/:spaceName/:subspace` (moves to `processing`; optional `transaction_id` for tx watch)
5. **Status polling** — `GET /api/purchases/:spaceName/:subspace/status`
6. **Status updates** — `PUT /api/purchases/:purchaseId/status/:statusType` (operator lifecycle: `proof_created`, `proof_batched`, `proof_committed`, `certificate_delivered`, `sptr_created`, `sptr_delivered`)
### Important field behaviors:
- `parent_purchase_id` — set on bundled pointer rows; links to subname purchase `id`
- `unified_status` — recomputed on the parent subname row when a bundled pointer child is updated
- `coupon_id` and `discount_percent` — snapshot columns on the `purchases` row
- `purchase_type` — required enum: `"subname"` or `"pointer"` (legacy `sptr` column was migrated)
---
## Fee Calculation
```
final_fee = platform_fee + (COMMITMENT_VBYTES * feerate_sat_vb)
```
- `COMMITMENT_VBYTES` is hardcoded (typically `256` in `server.js`)
- Platform fees stored in main DB `config` table: `1_block_fee`, `6_block_fee`, `48_block_fee`
- RPC calls `estimatefee [conf_target, "unset"]` against `SPACED_RPC_URL`
- `USE_TEST_FEE_RATES=true` skips RPC and uses fixed test rates (dev only)
- In proxied quote responses, a block fee is replaced **only** when SUBSD value is **less than** the calculated platform minimum
---
## Address Derivation
Payment addresses are Taproot (BIP32/BIP86), derived per-purchase using the purchase row's `purchase_id`:
- **Space-specific XPUB**: when `spaces.xpub` + `spaces.derivation` exist, derive at `{derivation}/0/{purchase_id}`
- **Platform default XPUB**: `m/86/0/{space_id}/0/{purchase_id}` (see `derivePurchasePaymentAddress` in `server.js`)
- Network inferred from prefix (`xpub` vs `tpub`)
- Libraries: `bip32`, `bitcoinjs-lib`, `tiny-secp256k1` (ECC initialized via `bitcoin.initEccLib(ecc)`)
---
## Environment Variables
### Required:
- `OPERATOR_SECRET_HEX` — 64-char hex, operator Nostr private key
- `OPERATOR_PUBLIC_HEX` — 64-char hex, operator Nostr public key
### Key optional:
| Variable | Default | Purpose |
|----------|---------|---------|
| `PLATFORM_PORT` | `3000` | Server port |
| `PLATFORM_HOST` | `127.0.0.1` | Bind address |
| `PLATFORM_MODE` | `prod` | `prod` (purple) or `test` (yellow-green theme) |
| `PLATFORM_DB_PATH` | `./data/local.db` | Main DB |
| `SPACED_RPC_URL` | `http://127.0.0.1:7225` | Spaced daemon RPC |
| `SPACED_RPC_USER` / `SPACED_RPC_PASSWORD` | `testuser` / `SomeRisk84` | RPC credentials |
| `SUBS_URI_VALUE` | `http://127.0.0.1:7244/` | SUBS service URL |
| `OPERATOR_RELAY` | `wss://relay.primal.net` | Nostr relay |
| `COINGECKO_API_KEY` | — | Required for price fetching |
| `USE_TEST_FEE_RATES` | — | `true` to skip RPC fee calls |
| `PLATFORM_ADMIN_USERNAME` | `admin` | Basic auth username |
| `PLATFORM_ADMIN_PASSWORD` | `Whatever!` | Basic auth password |
See `setup-spacesops-env.sh` for full list.
---
## Database Reset
`node server.js --reset-database` removes `local.db`, `sessions.db`, and all `data/spaces/*.db`, then reapplies migration 1. **Use after schema changes or when `schema_migrations` version > 1 blocks re-application.**
---
## Testing & Debugging
### Common test commands:
```bash
node server.js --reset-database # wipe and re-migrate
npm run dev # nodemon auto-restart
```
### Health check endpoints:
```bash
curl http://localhost:3000/api/auth/status
curl http://localhost:3000/test-rpc
curl http://localhost:3000/api/config
```
### Log prefixes to grep for:
- `[spaces-proxy]` — quote proxy, fee adjustment, pending subname check
- `[purchase]` — POST purchase, jobs, derivation
- `[tenantSchema]` — DDL/column migration events
- `[payment-callback]` — tx payment callback handling
---
## Coupon System
Per-tenant SQLite `coupons` table. Key rules:
- Discount applies to `price` only; `block_fee` is never discounted
- Code normalized to uppercase on input
- `completely_free` flag skips price entirely (client still pays block_fee)
- Validation: `POST /api/spaces/:spaceName/validate-coupon` (anonymous)
- CRUD: `/api/tenant/coupons` (admin or tenant auth)
See `COUPON.md` for full field-level docs and curl examples.
---
## Nostr / Messaging
Space ownership confirmation generates a signed Nostr event (NIP-01) downloadable by the owner. Admin/owner messaging system lives in `lib/messaging.js` with channels (`GENERAL`, `SALES`, `SUPPORT`, `DISPUTES`).
---
## External API Docs
- `PURCHASE.md` — Full purchase flow, bundled pointer, fee calculation, address derivation
- `PURCHASE_STATUS_UPDATE.md` — Operator lifecycle status API
- `COUPON.md` — Coupon CRUD and validation
- `JOB_STATUS_POLLING.md` — Polling strategy and client implementations
- `SUBS_API.md` — SUBS service HTTP API reference (external)
- `ESTIMATE_FEE.md` — Spaced JSON-RPC `estimatefee` details
- `CREATENUM.md` — `space-cli createnum --bind-spk` and Node.js equivalent
- `CERT_CALLBACK_API.md` — Webhook callback registration
- `TX_CALLBACK_API.md` — Transaction callback system
- `ADMINISTRATION.md` — Deployment, env vars, troubleshooting
---
## Agent Do's and Don'ts
### ✅ Do
- Prefer modification of existing `server.js` routes over extracting new files
- Follow the existing callback-style SQLite patterns (or Promisify where introduced)
- Re-read `lib/tenantSchema.js` after any schema change
- Use `purchase_type` consistently (`"subname"` / `"pointer"`) and handle legacy NULL gracefully
- Derive tenant DB path from space name and close connections when done
- Log with bracketed prefixes matching existing conventions
### ❌ Don't
- Don't create new route files or middleware directories
- Don't write to the tenant `handles` table in new flows
- Don't query across tenant databases
- Don't change hardcoded constants like `COMMITMENT_VBYTES` without explicit justification
- Don't touch `.gitignore` exclusions (`.db`, `.env`, `data/signed/`)
- Don't remove or rename the `sptr_*` timestamp columns until the migration roadmap is complete
- Don't remove `CLAUDE.md` unless specifically asked (it's referenced by legacy docs)
-114
View File
@@ -1,114 +0,0 @@
# SpacesOps
A Node.js/Express platform for managing space ownership confirmation and subspace name purchases. Integrates with the Spaced daemon (Bitcoin blockchain), Nostr (decentralized social protocol), and the SUBS service for name resolution.
## Architecture
- **server.js** — monolithic Express app (~6700 lines); contains all routes, DB logic, Nostr event signing, and Bitcoin Taproot address derivation
- **SQLite databases** — `data/local.db` (main), `data/sessions.db` (sessions), `data/prices.db` (price history), `data/spaces/<tenant>/` (per-tenant DBs)
- **Migrations** — single baseline [`migrations/001_initial_schema.js`](migrations/001_initial_schema.js) (version 1) for `data/local.db`; per-tenant DDL is shared with onboarding via [`lib/tenantSchema.js`](lib/tenantSchema.js). Applied automatically on startup.
- **getprices.js / reportprices.js / listprices.js** — standalone CoinGecko price utilities
## Running the Server
```bash
npm install
source setup-spacesops-env.sh # sets env defaults
export OPERATOR_SECRET_HEX=<64-char hex>
export OPERATOR_PUBLIC_HEX=<64-char hex>
npm start # production
npm run dev # nodemon (auto-restart on changes)
```
Default port: `3000` (override with `PLATFORM_PORT`).
## Key Environment Variables
| Variable | Default | Notes |
|---|---|---|
| `OPERATOR_SECRET_HEX` | — | **Required.** Operator Nostr private key |
| `OPERATOR_PUBLIC_HEX` | — | **Required.** Operator Nostr public key |
| `PLATFORM_PORT` | `3000` | Server port |
| `PLATFORM_HOST` | `127.0.0.1` | Bind address |
| `PLATFORM_MODE` | `prod` | `prod` (purple theme) or `test` (yellow-green) |
| `PLATFORM_DB_PATH` | `./data/local.db` | Main DB path |
| `PLATFORM_SESSION_SECRET` | `<session-secret>` | Change in prod |
| `SPACED_RPC_URL` | `http://127.0.0.1:7225` | Spaced daemon RPC |
| `SPACED_RPC_USER` / `SPACED_RPC_PASSWORD` | `<rpc-user>` / `<rpc-password>` | RPC credentials |
| `SUBS_URI_VALUE` | `http://127.0.0.1:7244/` | SUBS service URL |
| `OPERATOR_RELAY` | `wss://relay.primal.net` | Default Nostr relay |
| `COINGECKO_API_KEY` | — | Required for price fetching |
| `USE_TEST_FEE_RATES` | — | Set to `true` to skip RPC fee rate calls |
See `setup-spacesops-env.sh` for the full list of defaults.
## Database Migrations
Migrations run automatically on startup. The only numbered migration is **version 1** (initial schema). Tenant table definitions live in [`lib/tenantSchema.js`](lib/tenantSchema.js); keep that file in sync with any change to `001_initial_schema.js` tenant steps.
**After pulling schema changes**, reset local databases once (or production will start from a clean `data/` volume):
```bash
node server.js --reset-database # removes local.db, sessions.db, and data/spaces/*.db; reapplies migration 1
```
Then re-onboard tenants. If you keep an old `local.db` whose `schema_migrations` max version was greater than 1, the app will not re-run migration 1; use `--reset-database` or delete `data/local.db` so the baseline applies.
To roll back manually (only version `1` exists):
```bash
curl -X POST http://localhost:3000/api/migrations/rollback/1
curl http://localhost:3000/api/migrations # view status
```
## Core Business Logic
- **Space ownership confirmation** — user provides a space name; server creates a signed Nostr event (NIP-01) for download
- **Purchase flow** — quote retrieval → fee calculation (1/6/48-block fee rates from Spaced RPC) → purchase via SUBS → job created and polled
- **Taproot addresses** — BIP32 derivation path used to generate per-space Bitcoin payment addresses via `bitcoinjs-lib` + `tiny-secp256k1`
- **Multi-tenancy** — each tenant has an isolated SQLite DB; admin and tenant routes protected by Basic Auth
## API Surface (59 endpoints)
Key routes:
- `GET/POST /confirm-ownership` — ownership confirmation flow
- `GET /api/spaces` — list spaces
- `POST /spaces/:spaceName/:subspace` — purchase a subspace name
- `GET /api/jobs/:jobId?space=<name>` — poll job/purchase status
- `GET/POST /api/tenants/*` — tenant management
- `GET /prices/:coinId` — price lookup
## Price Utilities
```bash
node getprices.js # fetch from CoinGecko and store in prices.db
node reportprices.js
node listprices.js
```
Requires `COINGECKO_API_KEY`, `COINGECKO_TOKEN_COINS` (comma-separated coin IDs), and optionally `COINGECKO_FROM`/`COINGECKO_TO` (Unix timestamps).
## Notable Conventions
- All business logic lives in `server.js`; avoid splitting unless the file is refactored intentionally (exception: [`lib/tenantSchema.js`](lib/tenantSchema.js) holds shared tenant SQLite DDL used by migrations and tenant onboarding)
- Tenant isolation is enforced via separate DB connections — do not query across tenant DBs
- Commitment size is hardcoded at 256 vbytes
- `.gitignore` excludes all `*.db` files, `.env` files, and `data/signed/`
## Handle Source of Truth Policy (SUBS-first)
The tenant `handles` table is being deprecated. Treat SUBS API data as the single source of truth for handle existence and status.
- **Authoritative read paths**: use SUBS endpoints (`GET /spaces/:space/handles` and `GET /spaces/:space/handles/:handle`) as the canonical source.
- **SpacesOps route alignment**: for per-handle status lookups, `GET /spaces/:spaceName/:subspace` is proxied to SUBS `GET /spaces/@spaceName/handles/:subspace`.
- **No new writes to tenant `handles` table**: do not add or expand flows that insert/update/delete local `handles` rows except explicit migration/cleanup work.
- **UI behavior target**: tenant handles views should fetch statuses from SUBS, not from tenant DB rows.
- **Backward compatibility during migration**: where existing routes still touch `handles` (for example `/api/tenant/handles*`), changes should move behavior toward read-through SUBS and phase out local persistence.
- **Migration intent**:
1. Stop local `handles` writes in active flows.
2. Read handles/status from SUBS in tenant/admin pages.
3. Remove tenant `handles` table usage from server routes.
4. Drop `handles` table from tenant schema in a dedicated migration once callers are migrated.
When there is a conflict between local tenant DB `handles` data and SUBS API results, trust SUBS and treat local rows as stale.
+52 -27
View File
@@ -676,6 +676,19 @@
let useSubsAltUri = false;
let currentSpaceName = '';
const TENANT_SETTINGS_DISPLAY_KEYS = [
'subs_uri',
'subs_alt_uri',
'subs_uri_username',
'subs_uri_password',
'background_color',
'db_version',
'op_notification',
'spaces_mode',
'revenue_split_percent',
'sptr_price',
];
const SUBNAME_PRICING_HIDDEN_KEYS = new Set([
'subname_pricing_mode',
'subname_base_price',
@@ -683,9 +696,20 @@
'subname_testing_price_enabled',
]);
function tenantApiUrl(path) {
const spaceName = currentSpaceName || new URLSearchParams(window.location.search).get('space');
if (!spaceName) {
return path;
}
const separator = path.includes('?') ? '&' : '?';
return `${path}${separator}space=${encodeURIComponent(spaceName)}`;
}
function sortConfigKeys(keys) {
const keySet = new Set(keys);
TENANT_SETTINGS_DISPLAY_KEYS.forEach((key) => keySet.add(key));
const priority = ['subs_uri', 'subs_alt_uri', 'subs_uri_username', 'subs_uri_password'];
return keys
return Array.from(keySet)
.filter((key) => !SUBNAME_PRICING_HIDDEN_KEYS.has(key))
.filter((key) => useSubsAltUri || key !== 'subs_alt_uri')
.sort((a, b) => {
@@ -799,7 +823,7 @@
}
async function loadSubnamePricing() {
const response = await fetch('/api/tenant/subname-pricing');
const response = await fetch(tenantApiUrl('/api/tenant/subname-pricing'));
const data = await response.json();
if (checkAuthError(response, data)) {
return;
@@ -828,7 +852,7 @@
saveBtn.textContent = 'Saving...';
try {
const payload = collectSubnamePricingPayload();
const response = await fetch('/api/tenant/subname-pricing', {
const response = await fetch(tenantApiUrl('/api/tenant/subname-pricing'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
@@ -861,7 +885,7 @@
const payload = collectSubnamePricingPayload();
payload.subspace = subspace;
const response = await fetch('/api/tenant/subname-pricing/preview', {
const response = await fetch(tenantApiUrl('/api/tenant/subname-pricing/preview'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
@@ -906,7 +930,7 @@
}
// Load tenant configuration
const tenantResponse = await fetch(`/api/tenant/config`);
const tenantResponse = await fetch(tenantApiUrl('/api/tenant/config'));
const tenantData = await tenantResponse.json();
if (checkAuthError(tenantResponse, tenantData)) {
@@ -921,8 +945,13 @@
await loadSubnamePricing();
// Store config for edit mode (server backfills missing keys from DEFAULT_SUBNAME_BASE_PRICE)
// Store config for edit mode (server backfills missing keys on read)
currentConfig = tenantData.configuration.config || {};
TENANT_SETTINGS_DISPLAY_KEYS.forEach((key) => {
if (!currentConfig[key]) {
currentConfig[key] = { value: '', created_at: null, updated_at: null };
}
});
originalConfig = JSON.parse(JSON.stringify(currentConfig));
// Update page background color
@@ -977,11 +1006,14 @@
keys.forEach(key => {
const configItem = document.createElement('div');
configItem.className = 'config-item';
const label = document.createElement('span');
label.className = 'config-label';
label.textContent = formatConfigKey(key) + ':';
const value = document.createElement('span');
value.className = 'config-value';
if (key === 'subs_uri') {
const labelGroup = document.createElement('div');
labelGroup.className = 'config-label-group';
@@ -996,35 +1028,28 @@
} else {
configItem.appendChild(label);
}
const value = document.createElement('span');
value.className = 'config-value';
// Special handling for background_color
if (key === 'background_color') {
const colorValue = document.createElement('span');
colorValue.id = 'background-color-value';
colorValue.textContent = config[key].value || backgroundColor;
colorValue.textContent = config[key]?.value || backgroundColor;
const colorPreview = document.createElement('span');
colorPreview.className = 'color-preview';
colorPreview.id = 'color-preview';
colorPreview.style.backgroundColor = config[key].value || backgroundColor;
colorPreview.style.backgroundColor = config[key]?.value || backgroundColor;
value.appendChild(colorValue);
value.appendChild(colorPreview);
} else if (key === 'sptr_price') {
// Special handling for sat-denominated prices
value.textContent = (config[key].value || 'Not set') + ' sats';
value.textContent = (config[key]?.value || 'Not set') + ' sats';
} else if (key === 'subs_uri_password') {
value.textContent = config[key].value ? '••••••••' : 'Not set';
value.textContent = config[key]?.value ? '••••••••' : 'Not set';
} else {
value.textContent = config[key].value || 'Not set';
}
if (key !== 'subs_uri') {
configItem.appendChild(label);
value.textContent = config[key]?.value || 'Not set';
}
configItem.appendChild(value);
container.appendChild(configItem);
});
@@ -1037,7 +1062,7 @@
btn.textContent = 'Testing…';
}
try {
const res = await fetch('/api/tenant/subs-uri/test', {
const res = await fetch(tenantApiUrl('/api/tenant/subs-uri/test'), {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
@@ -1157,10 +1182,10 @@
input.type = 'password';
input.value = '';
input.autocomplete = 'new-password';
input.placeholder = config[key].value ? 'Leave blank to keep current' : 'Not set';
input.placeholder = config[key]?.value ? 'Leave blank to keep current' : 'Not set';
} else {
input.type = 'text';
input.value = config[key].value || '';
input.value = config[key]?.value || '';
}
valueCell.appendChild(input);
@@ -1235,7 +1260,7 @@
// Update all changed values
const updatePromises = updates.map(async (update) => {
const response = await fetch('/api/tenant/config', {
const response = await fetch(tenantApiUrl('/api/tenant/config'), {
method: 'POST',
headers: {
'Content-Type': 'application/json'
+131 -4
View File
@@ -145,6 +145,84 @@
transform: none;
}
.take-on-chain-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
display: none;
align-items: center;
justify-content: center;
z-index: 10000;
padding: 20px;
box-sizing: border-box;
}
.take-on-chain-overlay.show {
display: flex;
}
.take-on-chain-modal {
background: white;
border-radius: 12px;
padding: 28px;
max-width: 520px;
width: 100%;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25);
}
.take-on-chain-modal h2 {
margin: 0 0 12px;
color: var(--text-primary);
font-size: 1.35em;
font-weight: 600;
}
.take-on-chain-modal p {
margin: 0 0 18px;
color: var(--text-secondary);
font-size: 0.95em;
line-height: 1.5;
}
.take-on-chain-modal label {
display: block;
margin-bottom: 6px;
font-size: 0.85em;
font-weight: 600;
color: var(--text-primary);
}
.sip7-hex-input {
font-family: 'Courier New', monospace;
font-size: 0.85em;
padding: 10px 12px;
border: 2px solid var(--border-color);
border-radius: 6px;
width: 100%;
box-sizing: border-box;
}
.sip7-hex-input:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 3px var(--focus-shadow);
}
.sip7-hex-hint {
display: block;
margin-top: 6px;
font-size: 0.8em;
color: var(--text-secondary);
line-height: 1.4;
}
.take-on-chain-modal-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 22px;
}
.btn-success {
background: #27ae60;
color: white;
@@ -548,6 +626,23 @@
</style>
</head>
<body>
<div id="takeOnChainOverlay" class="take-on-chain-overlay" onclick="if (event.target === this) closeTakeOnChainModal()">
<div class="take-on-chain-modal" role="dialog" aria-labelledby="takeOnChainModalTitle" aria-modal="true">
<h2 id="takeOnChainModalTitle">Take On-Chain</h2>
<p id="takeOnChainModalDesc"></p>
<label for="takeOnChainSip7Hex">SIP-7 hex (optional)</label>
<input type="text" id="takeOnChainSip7Hex" class="sip7-hex-input"
placeholder="Leave blank for default handle TXT record"
autocomplete="off"
spellcheck="false" />
<span class="sip7-hex-hint">Blank attaches the default handle TXT record. Paste a custom SIP-7 record-set hex to override.</span>
<div class="take-on-chain-modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeTakeOnChainModal()">Cancel</button>
<button type="button" class="btn btn-primary" id="takeOnChainConfirmBtn" onclick="confirmTakeOnChain()">Broadcast createnum</button>
</div>
</div>
</div>
<div class="handles-container">
<div class="handles-header">
<h1>Handle Management</h1>
@@ -954,15 +1049,45 @@
}
}
async function takeOnChain(id, name) {
let pendingTakeOnChain = null;
function takeOnChain(id, name) {
const space = getSpaceName();
const fullHandle = space ? `${name}@${space.replace(/^@/, '')}` : name;
if (!confirm(`Take "${fullHandle}" on-chain via createnum?\n\nThis broadcasts a wallet transaction with estimated fees.`)) {
return;
pendingTakeOnChain = { id, name, fullHandle, space };
document.getElementById('takeOnChainModalDesc').textContent =
`Take "${fullHandle}" on-chain via createnum? This broadcasts a wallet transaction with estimated fees.`;
document.getElementById('takeOnChainSip7Hex').value = '';
document.getElementById('takeOnChainOverlay').classList.add('show');
document.getElementById('takeOnChainSip7Hex').focus();
}
function closeTakeOnChainModal() {
pendingTakeOnChain = null;
document.getElementById('takeOnChainOverlay').classList.remove('show');
}
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && pendingTakeOnChain) {
closeTakeOnChainModal();
}
});
async function confirmTakeOnChain() {
if (!pendingTakeOnChain) return;
const { id, fullHandle, space } = pendingTakeOnChain;
const sip7Hex = (document.getElementById('takeOnChainSip7Hex').value || '').trim();
const confirmBtn = document.getElementById('takeOnChainConfirmBtn');
closeTakeOnChainModal();
confirmBtn.disabled = true;
try {
const qs = space ? `?space=${encodeURIComponent(space)}` : '';
const res = await fetch(`/api/tenant/handles/${id}/take-on-chain${qs}`, { method: 'POST' });
const body = sip7Hex ? { sip7_hex: sip7Hex } : {};
const res = await fetch(`/api/tenant/handles/${id}/take-on-chain${qs}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!data.success) throw new Error(data.message);
const handle = allHandles.find(h => h.id === id);
@@ -977,6 +1102,8 @@
);
} catch (err) {
showNotification('Error taking handle on-chain: ' + err.message, 'error');
} finally {
confirmBtn.disabled = false;
}
}
+56 -3
View File
@@ -138,6 +138,23 @@
background: #219a52;
}
.btn-expunge {
background: #e67e22;
color: white;
padding: 5px 12px;
font-size: 0.82em;
margin-right: 6px;
}
.btn-expunge:hover {
background: #cf711d;
}
.btn-expunge:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.action-cell {
display: flex;
gap: 6px;
@@ -288,6 +305,14 @@
`Quote history for ${spaceParam} — purchases and SUBS staging status`;
}
function tenantApiUrl(path) {
if (!spaceParam) {
return path;
}
const separator = path.includes('?') ? '&' : '?';
return `${path}${separator}space=${encodeURIComponent(spaceParam)}`;
}
window.addEventListener('DOMContentLoaded', async () => {
await applyTheme();
await loadQuotes();
@@ -401,6 +426,10 @@
? `<button class="btn btn-release" onclick="releaseHandle('${escAttr(quote.handle)}')" title="Remove failed purchase records so this handle can be quoted again">Release</button>`
: '';
const expungeBtn = quote.can_expunge
? `<button class="btn btn-expunge" onclick="expungeQuote(${quote.id}, '${escAttr(quote.handle)}')" title="Remove quote and linked SpacesOps records (SUBS handle must be removed manually)">Expunge</button>`
: '';
return `
<tr>
<td>${quote.id}</td>
@@ -413,7 +442,7 @@
<td>${paidCell}</td>
<td>${stagedCell}</td>
<td>${subsStatusCell}</td>
<td><div class="action-cell">${releaseBtn}${deleteBtn}</div></td>
<td><div class="action-cell">${releaseBtn}${expungeBtn}${deleteBtn}</div></td>
</tr>
`;
}).join('');
@@ -478,7 +507,7 @@
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 res = await fetch(tenantApiUrl(`/api/tenant/quotes/${id}`), { method: 'DELETE' });
const data = await res.json();
if (!data.success) throw new Error(data.message || `HTTP ${res.status}`);
await loadQuotes();
@@ -487,12 +516,36 @@
}
}
async function expungeQuote(id, handle) {
const confirmed = confirm(
`Expunge quote #${id} for "${handle}"?\n\n` +
'This permanently deletes the quote, linked purchases, jobs, and payment records from SpacesOps. ' +
'This action is irreversible.\n\n' +
'The handle remains staged in SUBS. The space owner must remove the staged handle manually in SUBS ' +
'before it can be purchased again.\n\n' +
'Click OK to expunge, or Cancel to keep the records.'
);
if (!confirmed) return;
try {
const res = await fetch(tenantApiUrl(`/api/tenant/quotes/${id}/expunge`), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const data = await res.json();
if (!data.success) throw new Error(data.message || `HTTP ${res.status}`);
alert(data.message || `Quote #${id} expunged successfully.`);
await loadQuotes();
} catch (err) {
alert('Error expunging quote: ' + err.message);
}
}
async function releaseHandle(handle) {
if (!confirm(
`Release "${handle}"?\n\nThis deletes all quotes, purchases, jobs, and payment watches for this handle so it can be purchased again. Handles with confirmed payment or SUBS staging cannot be released.`
)) return;
try {
const res = await fetch('/api/tenant/handles/release', {
const res = await fetch(tenantApiUrl('/api/tenant/handles/release'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ handle }),
+316 -6
View File
@@ -2062,6 +2062,13 @@ function offboardTenant(tenantName) {
/** Default config keys backfilled for tenants onboarded before a key existed. */
const TENANT_CONFIG_DEFAULTS = {
db_version: '1.0.0',
background_color: '#f8f9fa',
op_notification: 'false',
subs_uri: SUBS_URI_VALUE,
spaces_mode: 'public',
revenue_split_percent: '5',
sptr_price: '10000',
...TENANT_SUBNAME_PRICING_DEFAULTS,
};
@@ -10054,6 +10061,27 @@ app.put('/api/tenant/handles/:handleId/price', requireAdminOrTenant, setupTenant
}
});
function resolveCreatenumSip7Data(sip7HexRaw, fullHandle) {
const trimmed = String(sip7HexRaw ?? '').trim();
if (!trimmed) {
return buildSip7TxtPayload([{ key: 'handle', value: fullHandle }]);
}
const hex = trimmed.replace(/^0x/i, '');
if (!/^[0-9a-fA-F]+$/.test(hex)) {
throw new Error('sip7_hex must be hexadecimal');
}
if (hex.length % 2 !== 0) {
throw new Error('sip7_hex must have an even number of hex digits');
}
const buf = Buffer.from(hex, 'hex');
if (buf.length === 0) {
throw new Error('sip7_hex cannot decode to empty data');
}
return buf;
}
app.post('/api/tenant/handles/:handleId/take-on-chain', requireAdminOrTenant, setupTenantDatabase, async (req, res) => {
try {
const handleId = parseInt(req.params.handleId, 10);
@@ -10094,10 +10122,15 @@ app.post('/api/tenant/handles/:handleId/take-on-chain', requireAdminOrTenant, se
}
const fullHandle = normalizeTenantHandleQuery(handleRow.handle, spaceName);
const sip7Data = buildSip7TxtPayload([{ key: 'handle', value: fullHandle }]);
let sip7Data;
try {
sip7Data = resolveCreatenumSip7Data(req.body?.sip7_hex, fullHandle);
} catch (sip7Err) {
return res.status(400).json({ success: false, message: sip7Err.message });
}
console.log(
`[handle] POST take-on-chain tenant=${req.tenantName} wallet=${getSpacedWalletName()} handle=${fullHandle} id=${handleId} spk=${scriptPubkey.slice(0, 16)}…`
`[handle] POST take-on-chain tenant=${req.tenantName} wallet=${getSpacedWalletName()} handle=${fullHandle} id=${handleId} spk=${scriptPubkey.slice(0, 16)}… sip7_bytes=${sip7Data.length}${req.body?.sip7_hex ? ' (custom hex)' : ' (default handle TXT)'}`
);
const { txid, result } = await spacedWalletCreateNumWithBindSpkEstimateFee(scriptPubkey, { data: sip7Data });
@@ -10170,6 +10203,27 @@ app.delete('/api/tenant/handles/:handleId', requireAdminOrTenant, setupTenantDat
// ─── Tenant quotes helpers ───────────────────────────────────────────────────
/** Purchase unified_status values that imply the handle is in the SUBS staging pipeline. */
const PURCHASE_SUBS_PIPELINE_STAGED_STATUSES = new Set([
'certificate_pending',
'proof_created',
'proof_batched',
'proof_committed',
'certificate_delivered',
]);
function isPurchaseSubsPipelineStaged(purchase) {
const unified = String(purchase?.unified_status || '').trim().toLowerCase();
return PURCHASE_SUBS_PIPELINE_STAGED_STATUSES.has(unified);
}
function isQuoteSubsStaged({ subsHandleStatus, subnamePurchaseRow }) {
return (
isSubsHandleStagedStatus(subsHandleStatus) ||
isPurchaseSubsPipelineStaged(subnamePurchaseRow)
);
}
function normalizeTenantHandleQuery(handleParam, spaceName) {
const raw = String(handleParam || '').trim();
const space = String(spaceName || '').trim().replace(/^@/, '');
@@ -10186,6 +10240,9 @@ function normalizeTenantHandleQuery(handleParam, spaceName) {
}
function enrichQuoteWithPurchasesAndSubs(quote, quotePurchases, subsStatusMap) {
const rawSubnamePurchase =
(quotePurchases || []).find((row) => (row.purchase_type || 'subname') === 'subname') || null;
const purchaseRows = (quotePurchases || []).map((purchase) => {
const subsInfo = subsStatusMap.get(String(purchase.handle || '').trim().toLowerCase());
const subsHandleStatus = subsInfo?.status || null;
@@ -10211,6 +10268,11 @@ function enrichQuoteWithPurchasesAndSubs(quote, quotePurchases, subsStatusMap) {
const subsHandleStatus =
subnamePurchase?.subs_handle_status || subsInfo?.status || null;
const quoteSubsStaged = isQuoteSubsStaged({
subsHandleStatus,
subnamePurchaseRow: rawSubnamePurchase,
});
return {
id: quote?.id ?? null,
handle: quote?.handle || subnamePurchase?.handle || null,
@@ -10231,7 +10293,7 @@ function enrichQuoteWithPurchasesAndSubs(quote, quotePurchases, subsStatusMap) {
? subnamePurchase.payment_confirmed
: purchaseRows.some((row) => row.payment_confirmed),
subs_handle_status: subsHandleStatus,
subs_staged: subnamePurchase ? subnamePurchase.subs_staged : isSubsHandleStagedStatus(subsHandleStatus),
subs_staged: quoteSubsStaged,
subs_publish_status: subsInfo?.publish_status ?? null,
subs_parked: subsInfo?.parked ?? null,
can_release: canReleaseTenantHandle({
@@ -10239,14 +10301,24 @@ function enrichQuoteWithPurchasesAndSubs(quote, quotePurchases, subsStatusMap) {
payment_confirmed: subnamePurchase
? subnamePurchase.payment_confirmed
: purchaseRows.some((row) => row.payment_confirmed),
subs_staged: subnamePurchase
? subnamePurchase.subs_staged
: isSubsHandleStagedStatus(subsHandleStatus),
subs_staged: quoteSubsStaged,
subs_handle_status: subsHandleStatus,
}),
can_expunge: canExpungeTenantQuote({
state: quote?.state,
subs_staged: quoteSubsStaged,
}),
};
}
function canExpungeTenantQuote({ state, subs_staged }) {
const quoteState = String(state || '').trim().toLowerCase();
if (quoteState !== 'purchased') {
return false;
}
return Boolean(subs_staged);
}
function canReleaseTenantHandle({ purchased, payment_confirmed, subs_staged, subs_handle_status }) {
if (payment_confirmed) {
return false;
@@ -10483,6 +10555,211 @@ async function releaseTenantHandleRecords(tenantDb, tenantName, handle, spaceNam
return { ok: true, counts };
}
async function expungeTenantQuoteRecords(tenantDb, tenantName, quoteId, spaceName) {
const quote = await new Promise((resolve, reject) => {
tenantDb.get('SELECT * FROM quotes WHERE id = ?', [quoteId], (err, row) => {
if (err) reject(err);
else resolve(row || null);
});
});
if (!quote) {
return { ok: false, status: 404, message: `Quote #${quoteId} not found` };
}
const quotePurchases = await new Promise((resolve, reject) => {
tenantDb.all(
`SELECT * FROM purchases WHERE quote_id = ? ORDER BY id ASC`,
[quoteId],
(err, rows) => {
if (err) reject(err);
else resolve(rows || []);
}
);
});
const subnamePurchases = quotePurchases.filter(
(row) => (row.purchase_type || 'subname') === 'subname'
);
const subnamePurchaseIds = subnamePurchases.map((row) => row.id);
let pointerChildren = [];
if (subnamePurchaseIds.length > 0) {
const placeholders = subnamePurchaseIds.map(() => '?').join(', ');
pointerChildren = await new Promise((resolve, reject) => {
tenantDb.all(
`SELECT * FROM purchases WHERE parent_purchase_id IN (${placeholders})`,
subnamePurchaseIds,
(err, rows) => {
if (err) reject(err);
else resolve(rows || []);
}
);
});
}
const allPurchases = [...quotePurchases];
const seenPurchaseIds = new Set(quotePurchases.map((row) => row.id));
for (const row of pointerChildren) {
if (!seenPurchaseIds.has(row.id)) {
allPurchases.push(row);
seenPurchaseIds.add(row.id);
}
}
const handleForSubs = quote.handle || subnamePurchases[0]?.handle || null;
let subsStatusMap = new Map();
if (handleForSubs) {
({ subsStatusMap } = await loadSubsStatusForHandle(tenantDb, spaceName, handleForSubs));
}
const enriched = enrichQuoteWithPurchasesAndSubs(quote, quotePurchases, subsStatusMap);
if (!canExpungeTenantQuote(enriched)) {
return {
ok: false,
status: 409,
message: `Quote #${quoteId} cannot be expunged: requires purchased state and a staged SUBS handle`,
};
}
const allPurchaseIds = allPurchases.map((row) => row.id);
const jobIds = [...new Set(allPurchases.map((row) => row.job_id).filter((id) => id != null))];
const jobLabels = jobIds.map((jobId) => `job:${jobId}`);
let paymentRows = [];
if (allPurchaseIds.length > 0 || jobLabels.length > 0) {
const clauses = [];
const params = [];
if (allPurchaseIds.length > 0) {
clauses.push(`purchase_id IN (${allPurchaseIds.map(() => '?').join(', ')})`);
params.push(...allPurchaseIds);
}
if (jobLabels.length > 0) {
clauses.push(`label IN (${jobLabels.map(() => '?').join(', ')})`);
params.push(...jobLabels);
}
paymentRows = await new Promise((resolve, reject) => {
tenantDb.all(
`SELECT id, transaction_id, status FROM payments WHERE ${clauses.join(' OR ')}`,
params,
(err, rows) => {
if (err) reject(err);
else resolve(rows || []);
}
);
});
}
for (const payment of paymentRows) {
if (payment.status === 'watching' && isSimulatedPaymentTxid(payment.transaction_id)) {
cancelSimulatedPaymentConfirmation(tenantName, payment.transaction_id);
}
}
const hadWatchingPayments = paymentRows.some((row) => row.status === 'watching');
const counts = {
quote_id: quoteId,
handle: quote.handle || handleForSubs,
payments_deleted: 0,
jobs_deleted: 0,
purchases_deleted: 0,
quotes_deleted: 0,
};
await new Promise((resolve, reject) => {
tenantDb.serialize(() => {
tenantDb.run('BEGIN TRANSACTION');
function runNext(steps) {
if (steps.length === 0) {
tenantDb.run('COMMIT', (commitErr) => {
if (commitErr) reject(commitErr);
else resolve();
});
return;
}
const step = steps.shift();
tenantDb.run(step.sql, step.params, function (runErr) {
if (runErr) {
tenantDb.run('ROLLBACK');
reject(runErr);
return;
}
step.onChanges(this.changes);
runNext(steps);
});
}
const steps = [];
if (paymentRows.length > 0) {
const paymentIds = paymentRows.map((row) => row.id);
steps.push({
sql: `DELETE FROM payments WHERE id IN (${paymentIds.map(() => '?').join(', ')})`,
params: paymentIds,
onChanges(changes) {
counts.payments_deleted = changes;
},
});
}
if (allPurchaseIds.length > 0) {
const purchasePlaceholders = allPurchaseIds.map(() => '?').join(', ');
steps.push({
sql: `DELETE FROM purchases WHERE id IN (${purchasePlaceholders}) OR parent_purchase_id IN (${purchasePlaceholders})`,
params: [...allPurchaseIds, ...allPurchaseIds],
onChanges(changes) {
counts.purchases_deleted = changes;
},
});
}
if (jobIds.length > 0) {
steps.push({
sql: `DELETE FROM jobs WHERE id IN (${jobIds.map(() => '?').join(', ')})`,
params: jobIds,
onChanges(changes) {
counts.jobs_deleted = changes;
},
});
}
steps.push({
sql: 'DELETE FROM quotes WHERE id = ?',
params: [quoteId],
onChanges(changes) {
counts.quotes_deleted = changes;
},
});
runNext(steps);
});
});
if (hadWatchingPayments) {
const remaining = await new Promise((resolve, reject) => {
tenantDb.all(
`SELECT transaction_id FROM payments WHERE status = 'watching'`,
(err, rows) => {
if (err) reject(err);
else resolve((rows || []).map((row) => row.transaction_id));
}
);
});
const clientId = getPaymentClientId(tenantName);
try {
if (remaining.length > 0) {
await updateTxWatches(clientId, remaining);
} else {
await spacedTxCallbackRpc('unregistertxcallback', { client_id: clientId });
}
} catch (rpcErr) {
console.error(`[expunge] Spaced RPC update after expunge for quote #${quoteId}: ${rpcErr.message}`);
}
}
return { ok: true, counts };
}
async function loadSubsStatusMapForSpace(tenantDb, spaceName) {
let subsConfigured = false;
let subsError = null;
@@ -10738,6 +11015,39 @@ app.delete('/api/tenant/quotes/:quoteId', requireAdminOrTenant, setupTenantDatab
}
});
app.post('/api/tenant/quotes/:quoteId/expunge', requireAdminOrTenant, setupTenantDatabase, async (req, res) => {
try {
const quoteId = parseInt(req.params.quoteId, 10);
const spaceName = String(req.spaceName || req.query.space || req.tenantName || '').trim();
console.log(`[expunge] POST /api/tenant/quotes/${req.params.quoteId}/expunge tenant=${req.tenantName}`);
if (isNaN(quoteId)) {
return res.status(400).json({ success: false, message: 'Invalid quote ID' });
}
const tenantDb = await getTenantDatabase(req.tenantName);
const result = await expungeTenantQuoteRecords(tenantDb, req.tenantName, quoteId, spaceName);
if (!result.ok) {
return res.status(result.status || 400).json({
success: false,
message: result.message || 'Expunge failed',
});
}
const { counts } = result;
console.log(
`[expunge] Quote #${quoteId} (${counts.handle}): quotes=${counts.quotes_deleted} purchases=${counts.purchases_deleted} jobs=${counts.jobs_deleted} payments=${counts.payments_deleted}`
);
res.json({
success: true,
message: `Expunged quote #${quoteId} (${counts.purchases_deleted} purchase(s), ${counts.jobs_deleted} job(s), ${counts.payments_deleted} payment(s) removed). Remove the staged handle in SUBS manually.`,
...counts,
});
} catch (error) {
console.error('[expunge] Error expunging quote:', error);
res.status(500).json({ success: false, message: error.message });
}
});
app.post('/api/tenant/handles/release', requireAdminOrTenant, setupTenantDatabase, async (req, res) => {
try {
const handleParam = req.body?.handle || req.query.handle;