diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index 1e299b4191..3e06aa5b72 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.amethyst.cli.stores.ConcordStore import com.vitorpamplona.amethyst.cli.stores.StoredCommunity import com.vitorpamplona.amethyst.cli.stores.StoredHeldRoot +import com.vitorpamplona.amethyst.cli.stores.StoredMintedInvite import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.actions.ConcordReceive import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry @@ -265,6 +266,20 @@ object ConcordCommands { val ack = ctx.publish(minted.bundleEvent, relaysFor(ctx, sc)) RawEventSupport.publishGuard(ack, minted.bundleEvent.id)?.let { return it } + // Keep the link signer + token so a later Refounding can refresh THIS coordinate rather + // than orphaning the link at a dead epoch — the liveness half of stranded recovery (A2). + ConcordStore(dataDir.concordFile).upsert( + sc.copy( + mintedInvites = + sc.mintedInvites + + StoredMintedInvite( + linkSignerPrivKey = minted.linkSignerPrivKey.toHexKey(), + token = minted.token.toHexKey(), + createdAt = TimeUtils.now(), + ), + ), + ) + Output.emit( mapOf( "url" to minted.url, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt index a9e8b0d2d8..342ae2501d 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt @@ -322,7 +322,44 @@ object ConcordModCommands { build.newControlKeys.address.hexToByteArray(), newControlRoot, ) - ConcordStore(dataDir.concordFile).upsert(ConcordCommands.storedFrom(loaded.community, adopted)) + val stored = ConcordCommands.storedFrom(loaded.community, adopted) + ConcordStore(dataDir.concordFile).upsert(stored) + + // 6. Refresh every link we minted, at its OWN coordinate, so it now resolves to the new + // epoch. This is the liveness half of stranded recovery (A2): a member this Refounding + // left out has no rekey blob and no message to miss, so re-resolving their link is the + // only way back — and it only works if the bundle moves with the community instead of + // being orphaned at a dead epoch. Minting a fresh link would not help them; the link + // they hold is the one that must move. + // + // Safe for every link because recovery is ban-gated at the epoch being left, and step 1 + // banned everyone being removed — so a removed member's own `recover` is refused even + // though their link now resolves. + val refreshedInvite = + ConcordActions.inviteFor( + stored.communityId, + stored.owner, + stored.ownerSalt, + stored.root, + stored.rootEpoch, + stored.name, + stored.relays, + stored.controlPk.ifBlank { null }, + ) + var refreshed = 0 + for (link in stored.mintedInvites) { + runCatching { + val event = + ConcordActions.remintBundleAt( + linkSignerPrivKey = link.linkSignerPrivKey.hexToByteArray(), + token = link.token.hexToByteArray(), + invite = refreshedInvite, + createdAt = TimeUtils.now(), + ) + ctx.publish(event, relays) + refreshed++ + } + } Output.emit( mapOf( @@ -333,6 +370,7 @@ object ConcordModCommands { "recipients" to recipients.size, "control_wraps" to build.controlWraps.size, "rekey_wraps" to build.rekeyWraps.size, + "invites_refreshed" to refreshed, ), ) return 0 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt index d2de85a258..e08bfcc8f8 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt @@ -53,6 +53,22 @@ data class StoredCommunity( // rekey has no message to miss: re-resolving this link is the only way back. Blank for a direct // invite or a community joined before amy stored it. val inviteRef: String = "", + // Invite links WE minted for this community, kept so a Refounding can re-publish each bundle at + // its own coordinate for the new epoch. Without this the link a member joined through points at + // a dead epoch forever and stranded recovery can never fire (A2). Holds link-signer secrets, so + // it sits beside `root`/`controlRoot` in the same already-secret file. + val mintedInvites: List = emptyList(), +) + +/** + * One invite link this account minted: enough to re-sign at its addressable coordinate later. The + * coordinate is the link signer's pubkey, so keeping the private key is what lets a Refounding + * refresh the link (and, in future, revoke it) instead of orphaning it. + */ +data class StoredMintedInvite( + val linkSignerPrivKey: String = "", + val token: String = "", + val createdAt: Long = 0, ) /** A past community_root for a specific epoch, mirroring quartz `HeldRoot`. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index e70fe18364..6263c65add 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -436,6 +436,29 @@ object ConcordActions { relays: List? = null, ): MintedInviteLink = ConcordInviteBundle.mintLink(base, invite, createdAt, relays) + /** + * Re-publishes a bundle at an **existing** link's coordinate, carrying [invite] refreshed for the + * current epoch (CORD-05 §1). The kind-33301 bundle is addressable and authored by the link + * signer, so re-signing with the same [linkSignerPrivKey] and re-encrypting under the same + * [token] replaces what is there — every holder of that link keeps working, now pointing at the + * new root. + * + * This is what makes stranded recovery live: a member a Refounding left out has no rekey blob and + * no message to miss, and re-resolving their link is the only way back — which requires the + * community to re-mint at the *same* coordinate rather than issuing a fresh link. Minting a new + * link leaves the old one pointing at a dead epoch forever. + * + * Safe to call for every live link because recovery is ban-gated at the epoch being left + * (CORD-06, A2): a member the Refounding removed was banned on the way out, so their own + * `recover` is refused even though their link now resolves. + */ + fun remintBundleAt( + linkSignerPrivKey: ByteArray, + token: ByteArray, + invite: CommunityInvite, + createdAt: Long, + ): Event = ConcordInviteBundle.build(linkSignerPrivKey, token, invite, createdAt) + /** Parses a shareable invite URL into its pointer + private fragment. */ fun parseInviteLink(url: String): ParsedInviteLink? = ConcordInviteLink.parseUrl(url)