From 628c13c644f729a9e7e4facfa17e78ab708ce0bf Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 3 May 2026 09:55:44 +0200 Subject: [PATCH 1/2] fix(market): resolve stall_id from a-tag when content omits it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NIP-15 lists stall_id inside the JSON content of kind-30018 product events, but some publishers (older nostrmarket builds, third-party clients) omit the field and only emit the parent reference via the a-tag of the form ["a", "30017::"]. Adds resolveStallId(event, productData) which: 1. Reads productData.stall_id when present (the spec-canonical path) 2. Falls back to the a-tag prefixed "30017:" when content omits it 3. Returns 'unknown' as a sentinel that won't match any real stall Both code paths in useMarket.ts (loadProducts batch and handleProductEvent live-update) now use it. Combined with the addStall sweep from eb3393f, products eventually link to their parent stall regardless of order or which form the publisher used. This DOES NOT fix orphan products whose referenced stall genuinely isn't on the relay — those still render "Unknown Stall" because no stall exists to link to. Investigating that separately. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/modules/market/composables/useMarket.ts | 30 +++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/modules/market/composables/useMarket.ts b/src/modules/market/composables/useMarket.ts index 47ccaa0..66af02c 100644 --- a/src/modules/market/composables/useMarket.ts +++ b/src/modules/market/composables/useMarket.ts @@ -14,6 +14,32 @@ const MARKET_EVENT_KINDS = { PRODUCT: 30018 } as const +/** + * Resolve a product's parent stall id from the event. + * + * NIP-15 lists `stall_id` inside the JSON `content`, but some publishers + * (older nostrmarket builds, third-party clients) only emit the parent + * reference via an `a` tag of the form + * ["a", "30017::"] + * + * Read content first, then fall back to the tag, then a sentinel that won't + * match any real stall. Returning the tag form prevents "Unknown Stall" + * from sticking when the JSON omits the field. + */ +function resolveStallId(event: any, productData: any): string { + if (productData?.stall_id && typeof productData.stall_id === 'string') { + return productData.stall_id + } + const aTag = event.tags?.find( + (t: any) => Array.isArray(t) && t[0] === 'a' && typeof t[1] === 'string' && t[1].startsWith(`${MARKET_EVENT_KINDS.STALL}:`) + ) + if (aTag) { + const parts = aTag[1].split(':') + if (parts[2]) return parts[2] + } + return 'unknown' +} + export function useMarket() { const marketStore = useMarketStore() const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) as any @@ -315,7 +341,7 @@ export function useMarket() { try { const productData = JSON.parse(latestEvent.content) - const stallId = productData.stall_id || 'unknown' + const stallId = resolveStallId(latestEvent, productData) // Extract categories from Nostr event tags (standard approach) const categories = latestEvent.tags @@ -515,7 +541,7 @@ export function useMarket() { const productId = event.tags.find((tag: any) => tag[0] === 'd')?.[1] if (productId) { const productData = JSON.parse(event.content) - const stallId = productData.stall_id || 'unknown' + const stallId = resolveStallId(event, productData) // Extract categories from Nostr event tags (standard approach) const categories = event.tags From 16c03d947aa55adc2205e789a911c1e90d360719 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 3 May 2026 10:05:23 +0200 Subject: [PATCH 2/2] 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() })