feat(market): self-heal orphan stalls on dashboard mount (closes #38)

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) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-03 10:05:23 +02:00
commit 16c03d947a
2 changed files with 104 additions and 0 deletions

View file

@ -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<void> {
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<string>()
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 }
}

View file

@ -77,6 +77,7 @@ import OrderHistory from '../components/OrderHistory.vue'
import MerchantStore from '../components/MerchantStore.vue' import MerchantStore from '../components/MerchantStore.vue'
import MarketSettings from '../components/MarketSettings.vue' import MarketSettings from '../components/MarketSettings.vue'
import { auth } from '@/composables/useAuthService' import { auth } from '@/composables/useAuthService'
import { useMarketStallSelfHeal } from '../composables/useMarketStallSelfHeal'
const route = useRoute() const route = useRoute()
const marketStore = useMarketStore() const marketStore = useMarketStore()
@ -139,6 +140,7 @@ const tabs = computed(() => [
]) ])
const router = useRouter() const router = useRouter()
const { selfHealOnce } = useMarketStallSelfHeal()
// Lifecycle // Lifecycle
onMounted(() => { onMounted(() => {
@ -153,6 +155,9 @@ onMounted(() => {
return return
} }
console.log('Market Dashboard mounted') 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()
}) })
</script> </script>