feat(buzz): geode BuzzMembershipPolicy — self-host the agent channel

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011mApqAbr8vkLC7gUDjavu6
This commit is contained in:
Claude
2026-07-26 14:12:51 +00:00
parent a7d5fbfa8a
commit 0b19a89b0c
5 changed files with 306 additions and 16 deletions
+1 -1
View File
@@ -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
@@ -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 <npubs>`** (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.
@@ -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()
@@ -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<HexKey>,
private val allowedKinds: Set<Int>? = null,
) : FullAuthPolicy(relay) {
/** Agent keys granted virtual membership on THIS connection via a valid NIP-OA `auth` tag. */
private val authorizedAgents = mutableSetOf<HexKey>()
/**
* 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<EventCmd> {
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<ReqCmd> = readGate()?.let { PolicyResult.Rejected(it) } ?: PolicyResult.Accepted(cmd)
override fun accept(cmd: CountCmd): PolicyResult<CountCmd> = readGate()?.let { PolicyResult.Rejected(it) } ?: PolicyResult.Accepted(cmd)
}
@@ -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<HexKey>,
) : 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<HexKey>): 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"))
}
}