From bc365c8ca2922ee3b745f38363fcbeb4d5d5097a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 12:00:42 +0000 Subject: [PATCH] refactor(concord): invite bundle (33301) gets its own addressable event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third refactor slice (plan: quartz/plans/2026-07-11-concord-event-classes.md). The kind-33301 public invite bundle is now a proper addressable Event class: - New cord05Invites/bundle/ConcordInviteBundleEvent (BaseAddressableEvent), with a build{} template that emits the ['d',''] + ['vsk','6'] tags via the standard DTag ext and the shared VskTag (ControlEntityKind.INVITE_LIVE). Registered kind 33301 -> ConcordInviteBundleEvent in EventFactory so fetched bundles parse as the class. - ConcordInviteBundle.build now signs ConcordInviteBundleEvent.build(...) with the per-link key; the raw TAG_D/TAG_VSK/VSK_LIVE constants are gone. Tag order (d, vsk) is preserved so the bundle event id / naddr are unchanged — the cord05Invites tests (bundle round-trip + join flow) pass. Remaining invite kinds: 3313 direct-invite (rumor has empty tags; p/k index is on the giftwrap) and 13303 invite-list (unimplemented stub) — tracked in the plan. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig --- .../plans/2026-07-11-concord-event-classes.md | 8 +- .../cord05Invites/ConcordInviteBundle.kt | 9 +-- .../bundle/ConcordInviteBundleEvent.kt | 73 +++++++++++++++++++ .../quartz/utils/EventFactory.kt | 2 + 4 files changed, 84 insertions(+), 8 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/bundle/ConcordInviteBundleEvent.kt diff --git a/quartz/plans/2026-07-11-concord-event-classes.md b/quartz/plans/2026-07-11-concord-event-classes.md index 6797caa577..82e57ac9a4 100644 --- a/quartz/plans/2026-07-11-concord-event-classes.md +++ b/quartz/plans/2026-07-11-concord-event-classes.md @@ -77,8 +77,12 @@ control, rekey, guestbook, invites, voice), not the standard-Nostr aliases. ext; dropped `MESSAGE/REACTION/DELETE/COMMENT` aliases. (commit c2cbd602) 2. ✅ Control plane 3308 → `cord04Roles/control/ControlEditionEvent` package + `tags/` (vsk/eid/ev/ep/vac) + ext; `EventFactory` registers 3308. (commit dc2abc0e) -3. Invites 33301 / 3313 / 13303. **← next** -4. Guestbook (3306/3309/3312) + voice (23313/23311) + rekey (3303). +3. Invites: ✅ 33301 bundle → `cord05Invites/bundle/ConcordInviteBundleEvent` + (addressable, reuses `DTag` + `VskTag`; registered in `EventFactory`). Remaining: + 3313 direct-invite → `ConcordDirectInviteEvent` (the rumor has empty tags; the + `p`/`k` index lives on the giftwrap). 13303 invite-list has no implementation yet + (a bare `ConcordKinds` constant) — build it when the private-invite feature lands. +4. Guestbook (3306/3309/3312) + voice (23313/23311) + rekey (3303). **← next** 5. Envelope seals (20013/20014) / wrap (1059/21059). 6. Shrink `ConcordKinds` to only kinds without their own event class; audit `EventFactory` coverage. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt index 0d301703a4..88fc9e9702 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt @@ -21,8 +21,8 @@ package com.vitorpamplona.quartz.concord.cord05Invites import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation -import com.vitorpamplona.quartz.concord.events.ConcordKinds import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull import com.vitorpamplona.quartz.nip01Core.core.toHexKey @@ -51,10 +51,7 @@ class MintedInviteLink( * bundle. Pinned to the Concord v2 reference client. */ object ConcordInviteBundle { - const val KIND = ConcordKinds.INVITE_BUNDLE - const val TAG_D = "d" - const val TAG_VSK = "vsk" - const val VSK_LIVE = "6" + const val KIND = ConcordInviteBundleEvent.KIND private fun json(invite: CommunityInvite) = ConcordJson.instance.encodeToString(CommunityInvite.serializer(), invite) @@ -68,7 +65,7 @@ object ConcordInviteBundle { val bundleKey = ConcordKeyDerivation.inviteBundleKey(token) val content = Nip44.v2.encrypt(json(invite), bundleKey).encodePayload() val signer = NostrSignerSync(KeyPair(privKey = linkSignerPrivKey)) - return signer.signNormal(createdAt, KIND, arrayOf(arrayOf(TAG_D, ""), arrayOf(TAG_VSK, VSK_LIVE)), content) + return signer.sign(ConcordInviteBundleEvent.build(content, createdAt)) } /** Decrypts a kind-33301 bundle [event] with the link [token], or null if it isn't a valid bundle. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/bundle/ConcordInviteBundleEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/bundle/ConcordInviteBundleEvent.kt new file mode 100644 index 0000000000..014237912b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/bundle/ConcordInviteBundleEvent.kt @@ -0,0 +1,73 @@ +/* + * 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.concord.cord05Invites.bundle + +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VskTag +import com.vitorpamplona.quartz.concord.cord04Roles.control.vsk +import com.vitorpamplona.quartz.concord.events.ConcordKinds +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * The public invite bundle (CORD-05): a kind-33301 **addressable** event whose + * content is a [com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite] + * NIP-44-encrypted under the bundle key derived from the link's 16-byte unlock + * token. Tagged `["d",""]` (so `link_signer` has exactly one live bundle) and + * `["vsk","6"]` ([ControlEntityKind.INVITE_LIVE]). + * + * A relay that indexes the naddr never holds the token, so it can never open the + * bundle. Minting/parsing/validation live in + * [com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteBundle]; this is the + * wire event it builds and that [com.vitorpamplona.quartz.utils.EventFactory] parses. + */ +class ConcordInviteBundleEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + override fun isContentEncoded() = true + + /** The versioned sub-kind marker (`vsk`), expected to be [ControlEntityKind.INVITE_LIVE]. */ + fun versionedSubKind() = tags.vsk() + + companion object { + const val KIND = ConcordKinds.INVITE_BUNDLE + + /** Builds the addressable bundle template carrying the already-encrypted [encryptedInvite]. */ + fun build( + encryptedInvite: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, encryptedInvite, createdAt) { + dTag("") + addUnique(VskTag.assemble(ControlEntityKind.INVITE_LIVE)) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 4a16657327..4957e42c81 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -24,6 +24,7 @@ package com.vitorpamplona.quartz.utils import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent +import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent import com.vitorpamplona.quartz.experimental.agora.FundraiserEvent import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent import com.vitorpamplona.quartz.experimental.attestations.proficiency.AttestorProficiencyEvent @@ -607,6 +608,7 @@ class EventFactory { RequestToVanishEvent.KIND -> RequestToVanishEvent(id, pubKey, createdAt, tags, content, sig) ConcordCommunityListEvent.KIND -> ConcordCommunityListEvent(id, pubKey, createdAt, tags, content, sig) ControlEditionEvent.KIND -> ControlEditionEvent(id, pubKey, createdAt, tags, content, sig) + ConcordInviteBundleEvent.KIND -> ConcordInviteBundleEvent(id, pubKey, createdAt, tags, content, sig) SealedRumorEvent.KIND -> SealedRumorEvent(id, pubKey, createdAt, tags, content, sig) SearchRelayListEvent.KIND -> SearchRelayListEvent(id, pubKey, createdAt, tags, content, sig) SimpleGroupListEvent.KIND -> SimpleGroupListEvent(id, pubKey, createdAt, tags, content, sig)