refactor(marmot,cli): lift shared logic into quartz/commons
Five extractions so the Amethyst UI and the new amy CLI share one implementation for each piece of Marmot/identity behaviour instead of reimplementing it per platform. quartz additions: - MarmotGroupData.bootstrap(..) + withMergedRelays(..): factory for the metadata shape every inviter stamps on a fresh group and the outbox- merge rule every admin commit carries forward. - KeyPackageFetcher: (a) pure fetchRelaysFor union of target kind:10051, target outbox, my outbox; (b) one-shot fetchKeyPackage; (c) publish- side resolveKeyPackagePublishRelays. - resolveUserHexOrNull: single entry point for npub / nprofile / 64-hex / NIP-05 identifiers. Uses the existing Nip19Parser + Nip05Client infra so NIP-05 resolution is live over HTTP. commons additions: - MarmotManager.leafIndexOf(..): pubkey -> leaf index lookup used by every removeMember caller. - MarmotManager.addMember(nostrGroupId, keyPackageEvent, relays): convenience overload that lifts the base64 decode into the manager. - MarmotManager.buildTextMessage(..) returning a TextMessageBundle; optionally persists the outbound inner event (CLI needs persist, Amethyst relies on LocalCache loopback). - MarmotIngest.ingest(event): routes kind:1059 / kind:445 through unwrap -> processWelcome / processGroupEvent -> persist the decrypted application message. Deliberately platform-agnostic. Retrofits: - Account.addMarmotGroupMember and fetchKeyPackageAndAddMember now take a KeyPackageEvent directly; Account.keyPackagePublishRelays delegates to KeyPackageFetcher. - AccountViewModel.updateMarmotGroupMetadata uses bootstrap + merged. - AccountViewModel.sendMarmotGroupMessage builds the kind:9 template via buildTextMessage(persistOwn = false) to avoid double-persisting. - AccountSessionManager's NIP-05 login branch delegates to resolveUserHexOrNull; no more hand-rolled Nip05Id / nip05Client.get. - CLI Context exposes nip05Client and requireUserHex; syncIncoming now calls MarmotManager.ingest; all command sites use KeyPackageFetcher + the shared identifier resolver. Npubs util deleted. https://claude.ai/code/session_01M6dCKAF5Y1VyHGZPjzwDXq
This commit is contained in:
+91
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.marmot.mip00KeyPackages
|
||||
|
||||
import com.vitorpamplona.quartz.marmot.MarmotFilters
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
/**
|
||||
* Discovery helpers for MIP-00 KeyPackages.
|
||||
*
|
||||
* Pulled out of Amethyst's `Account.fetchKeyPackageAndAddMember` so the CLI
|
||||
* (and any future non-Android caller) does not re-implement the same union-
|
||||
* of-relays logic when inviting a user to a Marmot group.
|
||||
*
|
||||
* This is the lowest-useful layer — it knows *nothing* about how callers
|
||||
* discover the target user's kind:10051 (KeyPackage Relay List) or kind:10002
|
||||
* (NIP-65 outbox) — those live in platform-specific caches. Callers collect
|
||||
* those sets themselves and pass them in.
|
||||
*/
|
||||
object KeyPackageFetcher {
|
||||
/**
|
||||
* Union of the three relay sets we'd ever want to query for a given user's
|
||||
* KeyPackage, in priority order of specificity:
|
||||
*
|
||||
* 1. target's kind:10051 KeyPackage Relay List (most authoritative)
|
||||
* 2. target's kind:10002 NIP-65 outbox (where the user publishes in general)
|
||||
* 3. our own outbox (shared-relay fallback — someone publishing and us
|
||||
* reading often overlap here)
|
||||
*/
|
||||
fun fetchRelaysFor(
|
||||
targetKeyPackageRelays: Collection<NormalizedRelayUrl>,
|
||||
targetOutbox: Collection<NormalizedRelayUrl>,
|
||||
myOutbox: Collection<NormalizedRelayUrl>,
|
||||
): Set<NormalizedRelayUrl> =
|
||||
buildSet {
|
||||
addAll(targetKeyPackageRelays)
|
||||
addAll(targetOutbox)
|
||||
addAll(myOutbox)
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot fetch of a user's KeyPackage across [relays], returning the first
|
||||
* matching event or `null` if none of the relays yielded one before timeout.
|
||||
*/
|
||||
suspend fun fetchKeyPackage(
|
||||
client: INostrClient,
|
||||
targetPubKey: HexKey,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
timeoutMs: Long = 30_000,
|
||||
): KeyPackageEvent? {
|
||||
if (relays.isEmpty()) return null
|
||||
val filter = MarmotFilters.keyPackagesByAuthor(targetPubKey)
|
||||
val event = client.fetchFirst(filters = relays.associateWith { listOf(filter) }, timeoutMs = timeoutMs)
|
||||
return event as? KeyPackageEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which relays this account should publish its OWN KeyPackage to.
|
||||
*
|
||||
* Per MIP-00, a user's KeyPackages SHOULD live on the relays listed in their
|
||||
* kind:10051 KeyPackageRelayListEvent. If they haven't published one yet,
|
||||
* fall back to their NIP-65 outbox — that's where their other write-oriented
|
||||
* events land and it keeps discovery working in the common "just starting out"
|
||||
* case.
|
||||
*/
|
||||
fun publishRelaysFor(
|
||||
keyPackageRelayList: Collection<NormalizedRelayUrl>,
|
||||
myOutbox: Collection<NormalizedRelayUrl>,
|
||||
): Set<NormalizedRelayUrl> = if (keyPackageRelayList.isNotEmpty()) keyPackageRelayList.toSet() else myOutbox.toSet()
|
||||
}
|
||||
+39
@@ -111,6 +111,21 @@ data class MarmotGroupData(
|
||||
/** Whether this group has an encrypted image set */
|
||||
fun hasImage(): Boolean = imageHash != null && imageKey != null && imageNonce != null
|
||||
|
||||
/**
|
||||
* Return a copy with [newRelays] unioned into [relays], de-duplicated and order-preserving.
|
||||
*
|
||||
* Every metadata-updating commit produced by the group creator or an admin SHOULD fold
|
||||
* its author's own outbox into [relays] so new invitees learn a single canonical relay
|
||||
* set for kind:445 from the Welcome, even when the inviter's outbox has drifted since
|
||||
* the last commit. Both the UI and the CLI need this same merge rule — hence living
|
||||
* on the data class.
|
||||
*/
|
||||
fun withMergedRelays(newRelays: Collection<String>): MarmotGroupData {
|
||||
if (newRelays.isEmpty()) return this
|
||||
val merged = (relays + newRelays).distinct()
|
||||
return if (merged == relays) this else copy(relays = merged)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode this MarmotGroupData to TLS wire format bytes.
|
||||
* Mirrors the [decodeTls] format.
|
||||
@@ -185,6 +200,30 @@ data class MarmotGroupData(
|
||||
const val EXTENSION_ID: UShort = 0xF2EEu
|
||||
const val EXTENSION_ID_INT: Int = 0xF2EE
|
||||
|
||||
/**
|
||||
* Build a freshly-minted [MarmotGroupData] for a group with no prior metadata —
|
||||
* i.e. right after `MlsGroupManager.createGroup`. Creator becomes the sole admin
|
||||
* and their outbox relays are stamped as the group's relay set.
|
||||
*
|
||||
* Both the Android UI (`AccountViewModel.updateMarmotGroupMetadata`) and the
|
||||
* headless CLI need this same initial shape; keeping the factory here avoids
|
||||
* subtle drift between them.
|
||||
*/
|
||||
fun bootstrap(
|
||||
nostrGroupId: HexKey,
|
||||
creatorPubKey: HexKey,
|
||||
outboxRelays: Collection<String>,
|
||||
name: String = "",
|
||||
description: String = "",
|
||||
): MarmotGroupData =
|
||||
MarmotGroupData(
|
||||
nostrGroupId = nostrGroupId,
|
||||
name = name,
|
||||
description = description,
|
||||
adminPubkeys = listOf(creatorPubKey),
|
||||
relays = outboxRelays.distinct(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Find and decode the MarmotGroupData extension from a list of MLS extensions.
|
||||
* Returns null if no extension with type 0xF2EE is present or if decoding fails.
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip05DnsIdentifiers
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
/**
|
||||
* Resolve any user identifier the UI or a CLI might throw at us to a 64-hex
|
||||
* Nostr pubkey. Accepts:
|
||||
*
|
||||
* - raw 64-hex pubkey
|
||||
* - bech32 `npub1…`
|
||||
* - bech32 `nprofile1…` (extracts the pubkey component)
|
||||
* - bech32 `nsec1…` (derives the public key from the secret)
|
||||
* - NIP-05 internet identifier (`name@domain.tld` or bare domain)
|
||||
*
|
||||
* Returns null when the input doesn't match any of the above or when a NIP-05
|
||||
* lookup fails (network error, no match on server). Throws only on
|
||||
* [CancellationException] so callers can still use structured concurrency.
|
||||
*
|
||||
* Pass a non-null [nip05Client] to enable NIP-05 resolution. When it's null,
|
||||
* NIP-05-shaped inputs fall through and this returns null — that's the right
|
||||
* behaviour for call sites that don't have HTTP access (e.g. a pure-offline
|
||||
* context).
|
||||
*/
|
||||
suspend fun resolveUserHexOrNull(
|
||||
input: String,
|
||||
nip05Client: INip05Client? = null,
|
||||
): HexKey? {
|
||||
val trimmed = input.trim()
|
||||
if (trimmed.isEmpty()) return null
|
||||
|
||||
// First try the bech32/hex path — it's pure and synchronous.
|
||||
decodePublicKeyAsHexOrNull(trimmed)?.let { return it }
|
||||
|
||||
// NIP-05: name@domain.tld (bare `@domain` = root "_" account).
|
||||
if (looksLikeNip05(trimmed) && nip05Client != null) {
|
||||
try {
|
||||
val id = Nip05Id.parse(trimmed) ?: return null
|
||||
return nip05Client.get(id)?.pubkey
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap precheck — a plausible NIP-05 identifier has an `@` followed by at
|
||||
* least one dot. Keeps us from issuing HTTP fetches on obvious non-matches
|
||||
* like hex strings or single bech32 tokens.
|
||||
*/
|
||||
internal fun looksLikeNip05(value: String): Boolean {
|
||||
val at = value.indexOf('@')
|
||||
if (at <= 0 || at == value.length - 1) return false
|
||||
val afterAt = value.substring(at + 1)
|
||||
return afterAt.contains('.') && !afterAt.contains('@')
|
||||
}
|
||||
Reference in New Issue
Block a user