Compare commits
3
Commits
4f03d26aec
...
e962873696
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e962873696 | ||
|
|
187e6d06a4 | ||
|
|
2cc280edd4 |
@@ -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`
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user