feat(marketplace-ui): show whether an app's authorship was actually proven
Demo images / Build & push demo images (push) Successful in 3m27s
Demo images / Build & push demo images (push) Successful in 3m27s
The backend verifies DID signatures as of f0c289a4, but the card only rendered
`trust_tier` / `trust_score`, so the verdict reached the frontend and died
there. Adds a badge next to the existing trust pill.
Deliberately a *separate* badge rather than folding into the trust tier: the
score blends relay count, provenance and policy compliance, while this answers
one narrow question — did the author prove control of the key their `author.did`
names. Merging them would hide the distinction that the signature layer exists
to draw.
- `valid` → green "signed" with a lock glyph
- `missing` → neutral grey "unsigned" (an unsigned publisher is unproven, not
hostile, so it reads as absence rather than alarm)
- `invalid` → red "bad signature". Discovery drops these before they reach the
cache, so it should be unreachable; rendered anyway so the UI
fails visibly rather than silently if that ever changes.
Two fail-safe details:
- The mapping defaults a missing field to `{status:'missing'}` rather than
leaving it undefined. A node on an older backend returns no field at all, and
"we couldn't check" must never render as "signed".
- The `invalid` arm is typed in the RPC client for the same reason: an
unhandled status falls through to "unsigned", not to the green badge.
The tooltip carries the meaning the two-word badge can't. "Signed" is easy to
misread as "safe", so it says what was actually proven — who published it — and
explicitly that this is not a statement about the app being safe.
Verified: vue-tsc clean, build green, and the new strings are present in the
freshly built Marketplace chunk (the build can silently no-op).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f0c289a415
commit
cd5d7daeae
@@ -1084,6 +1084,16 @@ class RPCClient {
|
|||||||
relay_count: number
|
relay_count: number
|
||||||
first_seen: string
|
first_seen: string
|
||||||
nostr_pubkey: string
|
nostr_pubkey: string
|
||||||
|
/**
|
||||||
|
* Whether the author proved control of the key their `author.did` names.
|
||||||
|
* `invalid` manifests are dropped during discovery and should never
|
||||||
|
* appear here; typed anyway so the UI fails safe rather than falling
|
||||||
|
* through to "signed" if that ever changes.
|
||||||
|
*/
|
||||||
|
signature?:
|
||||||
|
| { status: 'valid' }
|
||||||
|
| { status: 'missing' }
|
||||||
|
| { status: 'invalid'; reason: string }
|
||||||
}>
|
}>
|
||||||
relay_count: number
|
relay_count: number
|
||||||
}> {
|
}> {
|
||||||
|
|||||||
@@ -306,6 +306,11 @@ async function loadNostrMarketplace() {
|
|||||||
trustScore: app.trust_score,
|
trustScore: app.trust_score,
|
||||||
trustTier: app.trust_tier,
|
trustTier: app.trust_tier,
|
||||||
relayCount: app.relay_count,
|
relayCount: app.relay_count,
|
||||||
|
// Default to `missing` rather than leaving it undefined: a node running
|
||||||
|
// an older backend returns no field at all, and "we couldn't check" must
|
||||||
|
// never render as "signed".
|
||||||
|
signature: app.signature ?? { status: 'missing' as const },
|
||||||
|
authorDid: app.manifest.author.did,
|
||||||
}))
|
}))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
nostrError.value = e instanceof Error ? e.message : 'Discovery failed'
|
nostrError.value = e instanceof Error ? e.message : 'Discovery failed'
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Trust badge for Nostr community apps -->
|
<!-- Trust badge for Nostr community apps -->
|
||||||
<div v-if="app.trustTier" class="flex items-center gap-2 mb-3">
|
<div v-if="app.trustTier" class="flex items-center flex-wrap gap-2 mb-3">
|
||||||
<span
|
<span
|
||||||
class="text-xs px-2 py-0.5 rounded-full font-medium"
|
class="text-xs px-2 py-0.5 rounded-full font-medium"
|
||||||
:class="{
|
:class="{
|
||||||
@@ -42,6 +42,31 @@
|
|||||||
'bg-red-400/20 text-red-400': app.trustTier === 'untrusted',
|
'bg-red-400/20 text-red-400': app.trustTier === 'untrusted',
|
||||||
}"
|
}"
|
||||||
>{{ app.trustTier }}</span>
|
>{{ app.trustTier }}</span>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Authorship, which is a different question from the trust score: the
|
||||||
|
score blends relay count, provenance and policy compliance, while this
|
||||||
|
says only whether the author proved control of the key their DID names.
|
||||||
|
-->
|
||||||
|
<span
|
||||||
|
v-if="app.signature"
|
||||||
|
class="text-xs px-2 py-0.5 rounded-full font-medium inline-flex items-center gap-1"
|
||||||
|
:class="{
|
||||||
|
'bg-green-400/20 text-green-400': app.signature.status === 'valid',
|
||||||
|
'bg-white/10 text-white/50': app.signature.status === 'missing',
|
||||||
|
'bg-red-400/20 text-red-400': app.signature.status === 'invalid',
|
||||||
|
}"
|
||||||
|
:title="signatureTooltip"
|
||||||
|
>
|
||||||
|
<svg v-if="app.signature.status === 'valid'" class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else-if="app.signature.status === 'invalid'" class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
{{ signatureLabel }}
|
||||||
|
</span>
|
||||||
|
|
||||||
<span class="text-xs text-white/40">Score: {{ app.trustScore }}/100</span>
|
<span class="text-xs text-white/40">Score: {{ app.trustScore }}/100</span>
|
||||||
<span v-if="app.relayCount" class="text-xs text-white/40">· {{ app.relayCount }} relay{{ app.relayCount !== 1 ? 's' : '' }}</span>
|
<span v-if="app.relayCount" class="text-xs text-white/40">· {{ app.relayCount }} relay{{ app.relayCount !== 1 ? 's' : '' }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,6 +200,35 @@ defineEmits<{
|
|||||||
launch: [app: MarketplaceApp]
|
launch: [app: MarketplaceApp]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const signatureLabel = computed(() => {
|
||||||
|
switch (props.app.signature?.status) {
|
||||||
|
case 'valid': return 'signed'
|
||||||
|
case 'invalid': return 'bad signature'
|
||||||
|
default: return 'unsigned'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The badge is two words; the tooltip carries the meaning. "signed" is easy to
|
||||||
|
* read as "safe", so say what was actually proven — that the author holds the
|
||||||
|
* key their DID names — and nothing more.
|
||||||
|
*/
|
||||||
|
const signatureTooltip = computed(() => {
|
||||||
|
const sig = props.app.signature
|
||||||
|
const did = props.app.authorDid
|
||||||
|
const shortDid = did && did.length > 24 ? `${did.slice(0, 16)}…${did.slice(-6)}` : did
|
||||||
|
switch (sig?.status) {
|
||||||
|
case 'valid':
|
||||||
|
return `Authorship verified: signed by the key ${shortDid ?? 'in author.did'}. `
|
||||||
|
+ 'This proves who published it, not that the app is safe.'
|
||||||
|
case 'invalid':
|
||||||
|
return `Signature did not verify: ${sig.reason}`
|
||||||
|
default:
|
||||||
|
return 'No author signature — the publisher\'s identity is unproven. '
|
||||||
|
+ 'The app may still be fine; nothing has been demonstrated about who wrote it.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const installProgressMessage = computed(() => {
|
const installProgressMessage = computed(() => {
|
||||||
const p = props.installProgress
|
const p = props.installProgress
|
||||||
if (!p) return 'Installing'
|
if (!p) return 'Installing'
|
||||||
|
|||||||
@@ -29,8 +29,20 @@ export interface MarketplaceApp {
|
|||||||
trustScore?: number
|
trustScore?: number
|
||||||
trustTier?: string
|
trustTier?: string
|
||||||
relayCount?: number
|
relayCount?: number
|
||||||
|
/**
|
||||||
|
* DID-signature verdict for relay-discovered apps. `undefined` for curated
|
||||||
|
* and local apps, which don't travel through the marketplace protocol at all.
|
||||||
|
*/
|
||||||
|
signature?: AppSignature
|
||||||
|
/** The `author.did` the signature was checked against, for the tooltip. */
|
||||||
|
authorDid?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AppSignature =
|
||||||
|
| { status: 'valid' }
|
||||||
|
| { status: 'missing' }
|
||||||
|
| { status: 'invalid'; reason: string }
|
||||||
|
|
||||||
export type AppScreenshot = string | {
|
export type AppScreenshot = string | {
|
||||||
src: string
|
src: string
|
||||||
alt?: string
|
alt?: string
|
||||||
|
|||||||
Reference in New Issue
Block a user