Compare commits

...

2 commits

Author SHA1 Message Date
16c03d947a 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>
2026-05-03 10:05:23 +02:00
628c13c644 fix(market): resolve stall_id from a-tag when content omits it
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:<merchantPubkey>:<stallId>"].

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) <noreply@anthropic.com>
2026-05-03 09:55:44 +02:00
3 changed files with 132 additions and 2 deletions

View file

@ -14,6 +14,32 @@ const MARKET_EVENT_KINDS = {
PRODUCT: 30018 PRODUCT: 30018
} as const } 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:<merchantPubkey>:<stallId>"]
*
* 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() { export function useMarket() {
const marketStore = useMarketStore() const marketStore = useMarketStore()
const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) as any const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) as any
@ -315,7 +341,7 @@ export function useMarket() {
try { try {
const productData = JSON.parse(latestEvent.content) 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) // Extract categories from Nostr event tags (standard approach)
const categories = latestEvent.tags const categories = latestEvent.tags
@ -515,7 +541,7 @@ export function useMarket() {
const productId = event.tags.find((tag: any) => tag[0] === 'd')?.[1] const productId = event.tags.find((tag: any) => tag[0] === 'd')?.[1]
if (productId) { if (productId) {
const productData = JSON.parse(event.content) 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) // Extract categories from Nostr event tags (standard approach)
const categories = event.tags const categories = event.tags

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>