From 0b19a89b0cecfdc5ed72e9b53de1047102cda09a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 14:12:51 +0000 Subject: [PATCH] =?UTF-8?q?feat(buzz):=20geode=20BuzzMembershipPolicy=20?= =?UTF-8?q?=E2=80=94=20self-host=20the=20agent=20channel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A thin server-side policy so `amy serve --buzz` hosts a private, agent-authorized Buzz workspace on one JVM process — no Block Rust relay + Postgres/Redis/MinIO. - quartz: BuzzMembershipPolicy : FullAuthPolicy (buzz/relay/). Runs the NIP-42 handshake, then layers Buzz's two authorization rules: (1) only members (the team) may read/write; (2) NIP-OA virtual membership — an un-enrolled agent key is granted membership for its connection when its AUTH event carries an owner-signed `auth` tag whose owner is a member and whose signature authorizes that agent. An unauthenticated read is told `auth-required` (not `restricted`) so the client runs NIP-42 and retries. Deliberately does NOT emit relay-signed 39000-39003 metadata or run workflows (a policy can't emit events; the job channel doesn't need them). 8 unit tests. - cli: `amy serve --buzz [--members npubs]` composes the policy into geode's RelayEngine. Members = admins + --members. Verified end-to-end against a live `amy serve --buzz`: a member writes (published:true), an outsider is rejected (restricted: not a workspace member); plus the 8-case unit suite (member/non-member/unauth/reads/allowed-kinds/NIP-OA-agent/non-member-owner/tampered). Plan doc + cli README updated with the two relay options (amy serve --buzz vs the Rust stack) and when you'd need each. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6 --- cli/README.md | 2 +- .../2026-07-25-buzz-agent-support-channel.md | 11 +- .../amethyst/cli/commands/ServeCommand.kt | 52 +++++-- .../quartz/buzz/relay/BuzzMembershipPolicy.kt | 112 ++++++++++++++ .../buzz/relay/BuzzMembershipPolicyTest.kt | 145 ++++++++++++++++++ 5 files changed, 306 insertions(+), 16 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/relay/BuzzMembershipPolicy.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/relay/BuzzMembershipPolicyTest.kt diff --git a/cli/README.md b/cli/README.md index 63263e96f5..8da2c5c92c 100644 --- a/cli/README.md +++ b/cli/README.md @@ -460,7 +460,7 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever` | Command | What it does | |---|---| -| `amy serve [--host H] [--port N] [--path P] [--db FILE] [--admin NPUBS]` | Run a Nostr relay by embedding **geode** (the standalone Ktor relay on quartz's relay-server code). In-memory by default; `--db FILE` for SQLite. The active account is always an admin, so `amy admin ws://host:port …` works against it. Blocks until interrupted. | +| `amy serve [--host H] [--port N] [--path P] [--db FILE] [--admin NPUBS] [--buzz [--members NPUBS]]` | Run a Nostr relay by embedding **geode** (the standalone Ktor relay on quartz's relay-server code). In-memory by default; `--db FILE` for SQLite. The active account is always an admin, so `amy admin ws://host:port …` works against it. `--buzz` makes it a **private Buzz workspace relay** (`BuzzMembershipPolicy`): NIP-42 required, and only members (admins + `--members`) or NIP-OA-attested agents may read/write — so a team can self-host the agent channel on one JVM process instead of Block's Rust `buzz-relay` + Postgres/Redis/MinIO. Blocks until interrupted. | ### Identity diff --git a/cli/plans/2026-07-25-buzz-agent-support-channel.md b/cli/plans/2026-07-25-buzz-agent-support-channel.md index 737e63426c..03b66aa8f0 100644 --- a/cli/plans/2026-07-25-buzz-agent-support-channel.md +++ b/cli/plans/2026-07-25-buzz-agent-support-channel.md @@ -81,9 +81,14 @@ Keep that credential minimal; branch protection is what actually stops a bad mer ## Architecture (MVP) -1. **One `buzz-relay`** (Block's Rust relay — geode does NOT implement Buzz server - semantics: kind accept-list, `h`-scope, NIP-OA fallback, relay-signed metadata) = the - "Amethyst workspace" tenant. Team npubs enrolled as members; a maintainer is owner. +1. **The workspace relay.** For the agent job channel you have two options: + - **`amy serve --buzz --members `** (recommended to start) — a private, agent-authorized + workspace on a single JVM process via **`BuzzMembershipPolicy`** (quartz): NIP-42 required, + only members + NIP-OA-attested agents may read/write. No Rust, no Postgres/Redis/MinIO. The + job board + scheduler run on this today. It does NOT emit relay-signed NIP-29 metadata + (39000-39003) or run workflows — the job channel doesn't need them. + - **Block's Rust `buzz-relay`** — only if you want the full in-app Buzz *workspace/DM* UI + (relay-signed rosters, relay-assigned DM UUIDs) or server-run workflows. Heavier stack. 2. **One agent identity** = its own nostr key, authorized by a NIP-OA attestation the owner issues (`amy buzz attest` / `AgentAttestationScreen`). On GitHub it authenticates with a PR-only token; `main` is branch-protected. diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ServeCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ServeCommand.kt index 235437cbcd..871acb6d87 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ServeCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ServeCommand.kt @@ -26,7 +26,10 @@ import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.geode.KtorRelay import com.vitorpamplona.geode.RelayEngine +import com.vitorpamplona.quartz.buzz.relay.BuzzMembershipPolicy import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore import kotlinx.coroutines.awaitCancellation @@ -48,9 +51,12 @@ object ServeCommand { | | serve [--host H] [--port N] [--path P] in-memory by default (ephemeral); --db FILE | [--db FILE] [--admin NPUBS] for a persistent SQLite store. The account's - | pubkey is always an admin; --admin adds more + | [--buzz [--members NPUBS]] pubkey is always an admin; --admin adds more | (comma-separated npub/hex). Blocks until | interrupted. Defaults: 127.0.0.1:7447/ + | + | --buzz turns this into a private Buzz workspace relay (NIP-42 required; only members + | and NIP-OA-attested agents may read/write). Members = admins + --members. """.trimMargin() suspend fun run( @@ -66,34 +72,54 @@ object ServeCommand { val port = args.intFlag("port", 7447) val path = args.flag("path") ?: "/" val dbFile = args.flag("db") - val extraAdmins = + + fun csv(name: String) = args - .flag("admin") + .flag(name) ?.split(',') ?.map { it.trim() } ?.filter { it.isNotEmpty() } .orEmpty() + val extraAdmins = csv("admin") + val buzz = args.bool("buzz") + val extraMembers = csv("members") args.rejectUnknown() - // Resolve admin pubkeys (self + --admin) up front, then drop the + // Resolve admin + member pubkeys (self + --admin [+ --members]) up front, then drop the // Context — the embedded relay owns its own store and needs no account. - val adminPubkeys = + val (adminPubkeys, memberPubkeys) = Context.open(dataDir).use { ctx -> - buildSet { - add(ctx.identity.pubKeyHex) - extraAdmins.forEach { add(ctx.requireUserHex(it)) } - } + val admins = + buildSet { + add(ctx.identity.pubKeyHex) + extraAdmins.forEach { add(ctx.requireUserHex(it)) } + } + val members = + buildSet { + addAll(admins) + extraMembers.forEach { add(ctx.requireUserHex(it)) } + } + admins to members } // 0.0.0.0 isn't routable in a NIP-42 challenge; advertise loopback. val advertisedHost = if (host == "0.0.0.0") "127.0.0.1" else host val url = "ws://$advertisedHost:$port$path".normalizeRelayUrl() + + // --buzz locks the relay to members + NIP-OA-attested agents; otherwise a vanilla relay. + val policyBuilder: () -> IRelayPolicy = + if (buzz) { + { BuzzMembershipPolicy(url, memberPubkeys) } + } else { + { EmptyPolicy } + } + // In-memory is RelayEngine's default; only build a SQLite store for --db. val relay = if (dbFile != null) { - RelayEngine(url, store = EventStore(dbName = dbFile, relay = url), adminPubkeys = adminPubkeys) + RelayEngine(url, store = EventStore(dbName = dbFile, relay = url), policyBuilder = policyBuilder, adminPubkeys = adminPubkeys) } else { - RelayEngine(url, adminPubkeys = adminPubkeys) + RelayEngine(url, policyBuilder = policyBuilder, adminPubkeys = adminPubkeys) } val server = KtorRelay(relay, host = host, port = port, path = path).start() @@ -112,9 +138,11 @@ object ServeCommand { "path" to path, "persistent" to (dbFile != null), "admin_pubkeys" to adminPubkeys.toList(), + "buzz" to buzz, + "members" to if (buzz) memberPubkeys.toList() else null, ), ) - System.err.println("[serve] relay up at ${server.url} — Ctrl-C to stop") + System.err.println("[serve] relay up at ${server.url}${if (buzz) " (Buzz workspace, ${memberPubkeys.size} members)" else ""} — Ctrl-C to stop") // Block until the process is interrupted; the shutdown hook tears down. awaitCancellation() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/relay/BuzzMembershipPolicy.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/relay/BuzzMembershipPolicy.kt new file mode 100644 index 0000000000..e2a1ee4236 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/buzz/relay/BuzzMembershipPolicy.kt @@ -0,0 +1,112 @@ +/* + * 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.buzz.relay + +import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.tags.AuthTag +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.FullAuthPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent + +/** + * A **private, agent-authorized workspace** relay policy — the thin server half that lets + * `geode` (via `amy serve --buzz`) host a Buzz-style agent channel without Block's Rust relay + * + Postgres/Redis/MinIO stack. Built on quartz's own relay-server code. + * + * It extends [FullAuthPolicy], so it runs the full NIP-42 handshake and then layers Buzz's + * two authorization rules on top: + * + * 1. **Membership.** Only a [members] key (the team) may publish or read. A completed NIP-42 + * auth alone is not enough — auth proves identity; membership is the authorization. + * 2. **NIP-OA virtual membership.** An un-enrolled *agent* key is granted membership **for its + * connection** when its NIP-42 auth event carries an owner-signed `auth` tag + * ([com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation]) whose owner is a + * member and whose signature authorizes that agent — the same primitive Block's relay uses + * to make agents first-class without enrolling every key. The grant lives only as long as + * the connection (a fresh policy is built per connection), so revoking the owner's + * membership or dropping the socket revokes the agent. + * + * What this deliberately does NOT do (out of scope for a job channel; needs a relay that + * *emits* signed events, which a policy cannot): relay-signed NIP-29 metadata (39000-39003), + * relay-assigned DM UUIDs, and workflow execution (46xxx). See + * `cli/plans/2026-07-25-buzz-agent-support-channel.md`. + * + * [allowedKinds], when non-null, further restricts every author to those kinds (e.g. lock a + * channel down to the Buzz job + chat + reaction kinds); null accepts any kind. + */ +open class BuzzMembershipPolicy( + relay: NormalizedRelayUrl, + private val members: Set, + private val allowedKinds: Set? = null, +) : FullAuthPolicy(relay) { + /** Agent keys granted virtual membership on THIS connection via a valid NIP-OA `auth` tag. */ + private val authorizedAgents = mutableSetOf() + + /** + * Runs after the NIP-42 proof checks out (see [FullAuthPolicy.authorize]). If the auth event + * carries an owner-signed attestation authorizing this agent, and the owner is a member, + * remember the agent as a member for this connection. We never throw here — a missing or + * invalid attestation just means the key is authenticated but not (yet) authorized, which the + * membership gate below handles. + */ + override suspend fun authorize(event: RelayAuthEvent) { + val attestation = event.tags.firstNotNullOfOrNull(AuthTag::parse) ?: return + if (attestation.ownerPubKey in members && attestation.verify(event.pubKey)) { + authorizedAgents.add(event.pubKey) + } + } + + private fun isMember(pubKey: HexKey): Boolean = pubKey in members || pubKey in authorizedAgents + + /** + * The read gate as a rejection reason, or null to allow. Crucially, an *unauthenticated* + * connection is told `auth-required` (not `restricted`) so the client runs the NIP-42 + * handshake and retries — only an authenticated non-member is `restricted`. + */ + private fun readGate(): String? = + when { + authenticatedUsers.isEmpty() -> "auth-required: authenticate before reading this workspace" + authenticatedUsers.any(::isMember) -> null + else -> "restricted: not a workspace member" + } + + override fun accept(cmd: EventCmd): PolicyResult { + val author = cmd.event.pubKey + if (author !in authenticatedUsers) { + return PolicyResult.Rejected("auth-required: authenticate before publishing") + } + if (!isMember(author)) { + return PolicyResult.Rejected("restricted: not a workspace member") + } + allowedKinds?.let { + if (cmd.event.kind !in it) return PolicyResult.Rejected("restricted: kind ${cmd.event.kind} is not accepted on this workspace") + } + return PolicyResult.Accepted(cmd) + } + + override fun accept(cmd: ReqCmd): PolicyResult = readGate()?.let { PolicyResult.Rejected(it) } ?: PolicyResult.Accepted(cmd) + + override fun accept(cmd: CountCmd): PolicyResult = readGate()?.let { PolicyResult.Rejected(it) } ?: PolicyResult.Accepted(cmd) +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/relay/BuzzMembershipPolicyTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/relay/BuzzMembershipPolicyTest.kt new file mode 100644 index 0000000000..d9bc2d48dd --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/buzz/relay/BuzzMembershipPolicyTest.kt @@ -0,0 +1,145 @@ +/* + * 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.buzz.relay + +import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.OwnerAttestation +import com.vitorpamplona.quartz.buzz.oaOwnerAttestation.tags.AuthTag +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.server.backend.RequestContext +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.EmptyPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.IRelayPolicy +import com.vitorpamplona.quartz.nip01Core.relay.server.policies.PolicyResult +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertTrue + +class BuzzMembershipPolicyTest { + private val relay = RelayUrlNormalizer.normalizeOrNull("wss://work.example.com")!! + private val owner = KeyPair() + private val agent = KeyPair() + private val stranger = KeyPair() + private val ownerPub = owner.pubKey.toHexKey() + private val agentPub = agent.pubKey.toHexKey() + private val strangerPub = stranger.pubKey.toHexKey() + + private class Ctx( + override val authenticatedUsers: Set, + ) : RequestContext { + override val connectionId = 1L + override val policy: IRelayPolicy = EmptyPolicy + } + + /** A fresh policy whose connection has [authed] recorded as authenticated. */ + private fun policyOn(authed: Set): BuzzMembershipPolicy = BuzzMembershipPolicy(relay, members = setOf(ownerPub)).apply { onConnect(Ctx(authed)) {} } + + private fun event( + author: HexKey, + kind: Int = 40002, + ) = EventCmd(Event("00", author, 0, kind, emptyArray(), "", "sig")) + + private fun req() = ReqCmd("sub", emptyList()) + + private fun accepted(r: PolicyResult<*>) = r is PolicyResult.Accepted + + private fun reason(r: PolicyResult<*>) = (r as PolicyResult.Rejected).reason + + @Test + fun memberMayPublish() { + assertTrue(accepted(policyOn(setOf(ownerPub)).accept(event(ownerPub)))) + } + + @Test + fun authenticatedNonMemberIsRejected() { + val r = policyOn(setOf(strangerPub)).accept(event(strangerPub)) + assertTrue(reason(r).startsWith("restricted")) + } + + @Test + fun unauthenticatedIsRejected() { + val r = policyOn(emptySet()).accept(event(ownerPub)) + assertTrue(reason(r).startsWith("auth-required")) + } + + @Test + fun readsAreMemberGated() { + assertTrue(accepted(policyOn(setOf(ownerPub)).accept(req()))) + // Authenticated non-member: restricted. Unauthenticated: auth-required (so the client + // runs NIP-42 and retries, rather than silently getting nothing). + assertTrue(reason(policyOn(setOf(strangerPub)).accept(req())).startsWith("restricted")) + assertTrue(reason(policyOn(emptySet()).accept(req())).startsWith("auth-required")) + } + + @Test + fun allowedKindsRestrictsMembers() { + val policy = BuzzMembershipPolicy(relay, setOf(ownerPub), allowedKinds = setOf(40002)).apply { onConnect(Ctx(setOf(ownerPub))) {} } + assertTrue(accepted(policy.accept(event(ownerPub, kind = 40002)))) + assertTrue(reason(policy.accept(event(ownerPub, kind = 1))).startsWith("restricted")) + } + + @Test + fun nipOaAgentIsGrantedMembershipForTheConnection() = + runTest { + // The owner (a member) attests the un-enrolled agent key. + val attestation = OwnerAttestation.sign(agentPub, "", owner.privKey!!) + val authEvent = RelayAuthEvent("00", agentPub, 0, arrayOf(AuthTag.assemble(attestation)), "", "sig") + + val policy = policyOn(setOf(agentPub)) + policy.onAuthenticated(authEvent) // engine calls this after the NIP-42 proof verifies + + assertTrue(accepted(policy.accept(event(agentPub))), "attested agent may publish") + assertTrue(accepted(policy.accept(req())), "attested agent may read") + } + + @Test + fun attestationFromANonMemberOwnerIsIgnored() = + runTest { + // The attestation is validly signed, but by an owner who is NOT a workspace member. + val outsider = KeyPair() + val attestation = OwnerAttestation.sign(agentPub, "", outsider.privKey!!) + val authEvent = RelayAuthEvent("00", agentPub, 0, arrayOf(AuthTag.assemble(attestation)), "", "sig") + + val policy = policyOn(setOf(agentPub)) + policy.onAuthenticated(authEvent) + + assertTrue(reason(policy.accept(event(agentPub))).startsWith("restricted"), "unauthorized agent is rejected") + } + + @Test + fun tamperedAttestationIsIgnored() = + runTest { + // Owner IS a member, but the signature doesn't match the agent (tampered). + val real = OwnerAttestation.sign(strangerPub, "", owner.privKey!!) // signed for a DIFFERENT key + val forged = OwnerAttestation(ownerPub, real.conditions, real.sig) + val authEvent = RelayAuthEvent("00", agentPub, 0, arrayOf(AuthTag.assemble(forged)), "", "sig") + + val policy = policyOn(setOf(agentPub)) + policy.onAuthenticated(authEvent) + + assertTrue(reason(policy.accept(event(agentPub))).startsWith("restricted")) + } +}