From 16c03d947aa55adc2205e789a911c1e90d360719 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 3 May 2026 10:05:23 +0200 Subject: [PATCH] feat(market): self-heal orphan stalls on dashboard mount (closes #38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stopgap for the upstream LNbits orphan-stall bug (aiolabs/lnbits#10): _create_default_merchant historically provisioned the merchant + stall in nostrmarket's internal SQLite without publishing the kind-30017 stall event to relays. Upstream fix already in c0f3743c on aiolabs/lnbits@demo, but it only helps new signups. Existing accounts whose auto-stall never made it to a relay stay orphaned (every product they author renders as "Unknown Stall"). New composable useMarketStallSelfHeal() runs once per browser session for any logged-in user landing on /market/dashboard: 1. Query the relay for kind-30017 events authored by their pubkey 2. Get LNbits's known stalls for the merchant 3. For each stall not represented on the relay, PUT it back to LNbits — the PUT path on the LNbits side already calls sign_and_send_to_nostr, so the kind-30017 event lands on the relay without any user interaction Wired from MarketDashboard.vue onMounted (after the existing fully-authed guard). Fire-and-forget, never toasts, sessionStorage gate prevents re-runs on remounts. Closes #38. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../composables/useMarketStallSelfHeal.ts | 99 +++++++++++++++++++ src/modules/market/views/MarketDashboard.vue | 5 + 2 files changed, 104 insertions(+) create mode 100644 src/modules/market/composables/useMarketStallSelfHeal.ts diff --git a/src/modules/market/composables/useMarketStallSelfHeal.ts b/src/modules/market/composables/useMarketStallSelfHeal.ts new file mode 100644 index 0000000..6a0eb65 --- /dev/null +++ b/src/modules/market/composables/useMarketStallSelfHeal.ts @@ -0,0 +1,99 @@ +import { useAuth } from '@/composables/useAuthService' +import { injectService, SERVICE_TOKENS } from '@/core/di-container' +import type { NostrmarketAPI } from '../services/nostrmarketAPI' + +const SESSION_FLAG = 'market-stall-self-heal-checked' +const STALL_EVENT_KIND = 30017 + +/** + * Detect-and-recover from the LNbits orphan-stall bug + * (aiolabs/lnbits#10): _create_default_merchant provisions the merchant + * + stall in nostrmarket's internal SQLite but historically never + * published the kind-30017 stall event to relays. The upstream fix is + * already in c0f3743c on aiolabs/lnbits@demo, but it only helps NEW + * signups. Existing accounts whose auto-stall never made it to a relay + * stay orphaned until somebody republishes — which manifests in our + * webapp as "Unknown Stall" on every product authored by them. + * + * This composable runs once per browser session (sessionStorage gate) + * for any logged-in user who lands on the merchant dashboard: + * + * 1. Ask the relay for kind-30017 events authored by their pubkey. + * 2. Ask LNbits for the merchant's known stalls. + * 3. For each stall in (2) whose id isn't represented in (1), PUT the + * stall back to LNbits. The PUT path on the LNbits side already + * calls sign_and_send_to_nostr, so the kind-30017 event lands on + * the relay without any user interaction. + * + * Silent on success. Logs to console.info on republish; console.warn on + * failure. Never toasts — this is supposed to be invisible. + * + * Tracked in aiolabs/webapp#38. + */ +export function useMarketStallSelfHeal() { + const { user } = useAuth() + const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) as any + const nostrmarketAPI = injectService(SERVICE_TOKENS.NOSTRMARKET_API) as NostrmarketAPI + + async function selfHealOnce(): Promise { + if (sessionStorage.getItem(SESSION_FLAG)) return + + const currentUser = user.value + if (!currentUser?.pubkey) return + + const wallets = (currentUser as any).wallets as Array<{ adminkey?: string; inkey?: string }> | undefined + if (!wallets?.length) return + + const adminWallet = wallets.find(w => w.adminkey) || wallets[0] + if (!adminWallet?.adminkey || !adminWallet?.inkey) return + + // Mark checked early — even on failure we don't want to retry on every + // dashboard mount during the same tab session. + sessionStorage.setItem(SESSION_FLAG, '1') + + if (!relayHub || !nostrmarketAPI) { + console.warn('[market-self-heal] Required services unavailable, skipping') + return + } + + try { + const relayEvents: Array<{ tags?: Array<[string, string?]> }> = await relayHub.queryEvents([ + { kinds: [STALL_EVENT_KIND], authors: [currentUser.pubkey] }, + ]) + const publishedStallIds = new Set() + for (const ev of relayEvents) { + const dTag = ev.tags?.find(t => Array.isArray(t) && t[0] === 'd') + const stallId = dTag?.[1] + if (stallId) publishedStallIds.add(stallId) + } + + const lnbitsStalls = await nostrmarketAPI.getStalls(adminWallet.inkey) + const orphans = lnbitsStalls.filter(s => !publishedStallIds.has(s.id)) + + if (orphans.length === 0) { + console.info( + `[market-self-heal] All ${lnbitsStalls.length} stall(s) have a relay event — no recovery needed.`, + ) + return + } + + console.info( + `[market-self-heal] Republishing ${orphans.length} orphan stall(s):`, + orphans.map(s => `${s.id} (${s.name})`), + ) + + for (const stall of orphans) { + try { + await nostrmarketAPI.updateStall(adminWallet.adminkey, stall) + console.info(`[market-self-heal] Republished ${stall.id} (${stall.name})`) + } catch (err) { + console.warn(`[market-self-heal] Failed to republish ${stall.id}:`, err) + } + } + } catch (err) { + console.warn('[market-self-heal] Self-heal check failed:', err) + } + } + + return { selfHealOnce } +} diff --git a/src/modules/market/views/MarketDashboard.vue b/src/modules/market/views/MarketDashboard.vue index ded7018..5e08423 100644 --- a/src/modules/market/views/MarketDashboard.vue +++ b/src/modules/market/views/MarketDashboard.vue @@ -77,6 +77,7 @@ import OrderHistory from '../components/OrderHistory.vue' import MerchantStore from '../components/MerchantStore.vue' import MarketSettings from '../components/MarketSettings.vue' import { auth } from '@/composables/useAuthService' +import { useMarketStallSelfHeal } from '../composables/useMarketStallSelfHeal' const route = useRoute() const marketStore = useMarketStore() @@ -139,6 +140,7 @@ const tabs = computed(() => [ ]) const router = useRouter() +const { selfHealOnce } = useMarketStallSelfHeal() // Lifecycle onMounted(() => { @@ -153,6 +155,9 @@ onMounted(() => { return } console.log('Market Dashboard mounted') + // Self-heal orphan stalls (issue #38) once per browser session. + // Fire-and-forget — never blocks the dashboard render. + void selfHealOnce() })