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
|
||||
first_seen: 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
|
||||
}> {
|
||||
|
||||
@@ -306,6 +306,11 @@ async function loadNostrMarketplace() {
|
||||
trustScore: app.trust_score,
|
||||
trustTier: app.trust_tier,
|
||||
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) {
|
||||
nostrError.value = e instanceof Error ? e.message : 'Discovery failed'
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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
|
||||
class="text-xs px-2 py-0.5 rounded-full font-medium"
|
||||
:class="{
|
||||
@@ -42,6 +42,31 @@
|
||||
'bg-red-400/20 text-red-400': app.trustTier === 'untrusted',
|
||||
}"
|
||||
>{{ 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 v-if="app.relayCount" class="text-xs text-white/40">· {{ app.relayCount }} relay{{ app.relayCount !== 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
@@ -175,6 +200,35 @@ defineEmits<{
|
||||
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 p = props.installProgress
|
||||
if (!p) return 'Installing'
|
||||
|
||||
@@ -29,8 +29,20 @@ export interface MarketplaceApp {
|
||||
trustScore?: number
|
||||
trustTier?: string
|
||||
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 | {
|
||||
src: string
|
||||
alt?: string
|
||||
|
||||
Reference in New Issue
Block a user