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:
Claude
2026-04-21 20:43:00 +00:00
parent 3fa45a47c6
commit 89198ab24e
17 changed files with 556 additions and 220 deletions
@@ -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()
}
@@ -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.
@@ -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('@')
}