refactor(concord): invite bundle (33301) gets its own addressable event

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzJ2Cwo8tg4oZq43oRa3ig
This commit is contained in:
Claude
2026-07-11 12:00:42 +00:00
parent cbeff0e8d8
commit bc365c8ca2
4 changed files with 84 additions and 8 deletions
@@ -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.
@@ -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. */
@@ -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<Array<String>>,
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<ConcordInviteBundleEvent>.() -> Unit = {},
) = eventTemplate(KIND, encryptedInvite, createdAt) {
dTag("")
addUnique(VskTag.assemble(ControlEntityKind.INVITE_LIVE))
initializer()
}
}
}
@@ -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)