Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ BUZZ_BIND_ADDR=0.0.0.0:3000
RELAY_URL=ws://localhost:3000
# Stable relay signing key. Set this in dev if you want REST-created forum posts
# to keep resolving to the original author across relay restarts.
# BUZZ_RELAY_PRIVATE_KEY=<32-byte hex private key>
# BUZZ_RELAY_PRIVATE_KEY=nsec1…
# Optional: path to the web UI dist directory. When set, the relay serves
# the web frontend at / for browser requests. Leave unset for local dev
# (use `just web` for Vite HMR instead).
Expand Down Expand Up @@ -137,11 +137,11 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz
# are optional unless noted; defaults are shown in comments.
#
# Quick start:
# BUZZ_PRIVATE_KEY=<hex> BUZZ_RELAY_URL=ws://localhost:3000 buzz-acp
# BUZZ_PRIVATE_KEY=nsec1… BUZZ_RELAY_URL=ws://localhost:3000 buzz-acp

# ── Identity & auth ──────────────────────────────────────────────────────────
# Nostr private key (hex or bech32). REQUIRED — identifies the agent on the relay.
# BUZZ_PRIVATE_KEY=<32-byte hex or nsec1… private key>
# Nostr private key in NIP-19 nsec form. REQUIRED — identifies the agent on the relay.
# BUZZ_PRIVATE_KEY=nsec1…

# Relay WebSocket URL the harness connects to.
# Note: the relay itself uses RELAY_URL (above); this is the ACP harness's
Expand Down
10 changes: 5 additions & 5 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,7 @@ steps:
- id: page
if: "str_contains(trigger_text, 'production')"
action: request_approval
from: "{{trigger.author}}"
from: "{{trigger.author | npub}}"
message: "Page on-call?"
```

Expand All @@ -536,16 +536,16 @@ Note: Both `TriggerDef` and `ActionDef` use serde internally-tagged enums. Trigg
| Action | Description |
|--------|-------------|
| `send_message` | Post to the workflow's channel (or override channel) |
| `send_dm` | Direct message to a user (pubkey hex or `{{trigger.author}}`) |
| `send_dm` | Direct message to a user (`npub`; legacy hex remains accepted) |
| `set_channel_topic` | Update channel topic |
| `add_reaction` | React to the trigger message |
| `call_webhook` | HTTP POST to external URL (SSRF-protected, redirects disabled, 1 MiB response cap) |
| `request_approval` | Suspend execution; fields: `from`, `message`, `timeout` (default 24h) |
| `delay` | Pause execution (max 300 seconds) |

**Template variables:** `{{trigger.text}}`, `{{trigger.author}}`, `{{steps.ID.output.FIELD}}`. Single-pass resolution (not recursive). Unknown variables left as literal text.
**Template variables:** `{{trigger.text}}`, `{{trigger.author}}`, `{{steps.ID.output.FIELD}}`. For compatibility with persisted definitions, bare `{{trigger.author}}` retains its protocol-hex result. Use `{{trigger.author | npub}}` for canonical human-facing output; the filter is idempotent for npub input. Single-pass resolution (not recursive). Unknown variables are left as literal text.

**Condition evaluation:** `evalexpr` with `HashMapContext`. Dot notation converted to underscores (`trigger.text` → `trigger_text`). Custom functions registered: `str_contains`, `str_starts_with`, `str_ends_with`, `str_len`. 100ms timeout prevents adversarial expressions from blocking.
**Condition evaluation:** `evalexpr` with `HashMapContext`. Dot notation is converted to underscores (`trigger.text` → `trigger_text`). `trigger_author` remains protocol hex, matching bare template substitution. Portable trigger JSON serializes the author as npub and normalizes it back to internal hex when a persisted run is resumed. Custom functions registered: `str_contains`, `str_starts_with`, `str_ends_with`, `str_len`. A 100ms timeout prevents adversarial expressions from blocking.

**Concurrency:** `Arc<Semaphore>` with 100 permits. `try_acquire()` — returns `CapacityExceeded` immediately rather than queuing.

Expand Down Expand Up @@ -681,7 +681,7 @@ Subcommands:

| Subcommand | Purpose |
|------------|---------|
| `add-member` | Add a pubkey to the relay membership list (`--pubkey`, `--role`); accepts npub or hex; publishes kind:13534 roster |
| `add-member` | Add an `npub` to the relay membership list (`--pubkey`, `--role`); publishes kind:13534 roster |
| `remove-member` | Remove a pubkey from the relay membership list (`--pubkey`, optional `--role` guard); publishes kind:13534 roster |
| `list-members` | List all relay members |
| `generate-key` | Generate a new Nostr keypair (for bootstrapping) |
Expand Down
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -342,11 +342,12 @@ mesh-dev-fresh:
set -euo pipefail
./scripts/dev-reset.sh --yes
./scripts/setup-desktop-test-data.sh
export BUZZ_PRIVATE_KEY="3dbaebadb5dfd777ff25149ee230d907a15a9e1294b40b830661e65bb42f6c03"
# Canonical human/config forms; relay internals normalize these to protocol hex.
export BUZZ_PRIVATE_KEY="nsec18kawhtd4mlth0le9zj0wyvxeq7s448sjjj6qhqcxv8n9hdp0dspsaqmxsm"
export BUZZ_REQUIRE_RELAY_MEMBERSHIP=true
export BUZZ_ALLOW_NIP_OA_AUTH=true
export RELAY_OWNER_PUBKEY="e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34"
export BUZZ_RELAY_PRIVATE_KEY="0000000000000000000000000000000000000000000000000000000000000001"
export RELAY_OWNER_PUBKEY="npub1uh4udnd40xlpzt3ndnp3ndvcnd9mdtc30ph2jrd7226lprt5rv6q28qswd"
export BUZZ_RELAY_PRIVATE_KEY="nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqsmhltgl"
export BUZZ_RECONCILE_CHANNELS=true
export BUZZ_RESET_WEBVIEW_STATE=1
exec just mesh=1 dev
Expand Down
7 changes: 3 additions & 4 deletions NOSTR.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,9 +223,8 @@ Use `buzz-admin` — the operator CLI shipped in the relay image — to manage r
In a Docker Compose deployment, use `run.sh`:

```bash
# Add a member (accepts bech32 npub or 64-char hex; default role: member)
# Add a member by npub (default role: member)
./run.sh add-member npub1abc...
./run.sh add-member <64-char-hex-pubkey>
./run.sh add-member npub1abc... --role admin

# Remove a member
Expand Down Expand Up @@ -262,7 +261,7 @@ docker compose exec relay buzz-admin list-members
|----------|-------|
| `DATABASE_URL` | Postgres connection string |
| `REDIS_URL` | Redis connection string |
| `BUZZ_RELAY_PRIVATE_KEY` | Hex private key — required to sign kind:13534 events |
| `BUZZ_RELAY_PRIVATE_KEY` | NIP-19 `nsec` — required to sign kind:13534 events |

### NIP-43 Admin Events (WebSocket)

Expand Down Expand Up @@ -335,7 +334,7 @@ but only admins/owners can set it. Full spec:
| Variable | Required | Default | Description |
|----------|:--------:|---------|-------------|
| `BUZZ_PUBKEY_ALLOWLIST` | ❌ | `false` | Enable pubkey allowlist for NIP-42 pubkey-only auth |
| `BUZZ_RELAY_PRIVATE_KEY` | ❌ | random | Hex secret key for relay signing (discovery events, system messages) |
| `BUZZ_RELAY_PRIVATE_KEY` | ❌ | random | NIP-19 `nsec` for relay signing (discovery events, system messages) |
| `BUZZ_REQUIRE_AUTH_TOKEN` | ❌ | `false` | Require authenticated NIP-42 for all connections |

---
Expand Down
2 changes: 1 addition & 1 deletion TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ CLI-side, only two matter for testing:
| Variable | Default | Notes |
|-------------------------|--------------------------|-------|
| `BUZZ_RELAY_URL` | `http://localhost:3000` | CLI relay base; accepts `ws(s)://` and normalises |
| `BUZZ_PRIVATE_KEY` | — (**required**) | `nsec1…` or 64-char hex |
| `BUZZ_PRIVATE_KEY` | — (**required**) | NIP-19 `nsec1…` |
| `BUZZ_AUTH_TAG` | unset | Optional NIP-OA owner attestation JSON |

---
Expand Down
1 change: 1 addition & 0 deletions admin-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
},
"dependencies": {
"@vitejs/plugin-react": "^6.0.0",
"nostr-tools": "^2.23.3",
"vite": "^8.0.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
Expand Down
38 changes: 31 additions & 7 deletions admin-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
useState,
} from "react";
import { ApiFailure, request } from "./api";
import { formatNpub, truncatePubkey } from "./pubkey";
import type {
FeedbackDetail,
FeedbackSummary,
Expand Down Expand Up @@ -114,7 +115,10 @@ function Reports() {
<span className="tag">{report.reportType}</span>
<strong>{report.communityHost}</strong>
<code>
{report.targetKind}: {short(report.target)}
{report.targetKind}:{" "}
{report.targetKind === "pubkey"
? truncatePubkey(report.targetNpub ?? report.target)
: short(report.target)}
</code>
</div>
<div className="record-date">
Expand Down Expand Up @@ -166,11 +170,17 @@ function ReportDetail({ id }: { id: string }) {
</dd>
<dt>Reporter</dt>
<dd>
<code>{report.reporterPubkey}</code>
<code>
{formatNpub(report.reporterNpub ?? report.reporterPubkey)}
</code>
</dd>
<dt>Target</dt>
<dd>
<code>{report.target}</code>
<code>
{report.targetKind === "pubkey"
? formatNpub(report.targetNpub ?? report.target)
: report.target}
</code>
</dd>
{report.targetKind === "event" ? (
<>
Expand All @@ -184,7 +194,12 @@ function ReportDetail({ id }: { id: string }) {
<p>{report.message.content}</p>
<div className="reported-message-meta">
<span>Author</span>
<code>{report.message.authorPubkey}</code>
<code>
{formatNpub(
report.message.authorNpub ??
report.message.authorPubkey,
)}
</code>
<span>Created</span>
<time>{date(report.message.createdAt)}</time>
</div>
Expand Down Expand Up @@ -328,7 +343,11 @@ function FeedbackList() {
<strong>{item.bodySummary}</strong>
<span className="record-provenance">
{item.communityHost}
<code>{short(item.submitterPubkey)}</code>
<code>
{truncatePubkey(
item.submitterNpub ?? item.submitterPubkey,
)}
</code>
</span>
</div>
</Link>
Expand Down Expand Up @@ -411,7 +430,8 @@ function FeedbackResults({
item.bodySummary,
item.communityHost,
item.category ?? "uncategorized",
item.submitterPubkey,
item.submitterNpub ?? item.submitterPubkey,
formatNpub(item.submitterNpub ?? item.submitterPubkey),
].some((value) => value.toLocaleLowerCase().includes(normalizedQuery));
});
return { communities, filtered };
Expand Down Expand Up @@ -469,7 +489,11 @@ function FeedbackDetailView({ id }: { id: string }) {
) : null}
<dt>Submitted by</dt>
<dd>
<code>{feedback.submitterPubkey}</code>
<code>
{formatNpub(
feedback.submitterNpub ?? feedback.submitterPubkey,
)}
</code>
</dd>
<dt>Event</dt>
<dd>
Expand Down
27 changes: 27 additions & 0 deletions admin-web/src/pubkey.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { nip19 } from "nostr-tools";
import { formatNpub, truncatePubkey } from "./pubkey";

const HEX = "ea9b4d7a7a78a3e3729e5568b14d764d4962be0e1f20f749bcf8d9dbbf9a9328";
const NPUB = "npub1a2d567n60z37xu57245tzntkf4yk90swrus0wjdulrvah0u6jv5qusyp60";

describe("public-key display", () => {
it("uses npub for full and compact displays", () => {
expect(formatNpub(HEX)).toBe(NPUB);
expect(formatNpub(NPUB)).toBe(NPUB);
expect(truncatePubkey(HEX)).toBe("npub1a2d56…5qusyp60");
});

it("fails closed instead of echoing malformed API values", () => {
expect(formatNpub("invalid-pubkey")).toBe("Invalid public key");
expect(formatNpub("a".repeat(63))).toBe("Invalid public key");
});

it("rejects public-key bytes that cannot lift to secp256k1", () => {
const invalidPoint = "ff".repeat(32);
expect(formatNpub(invalidPoint)).toBe("Invalid public key");
expect(formatNpub(nip19.npubEncode(invalidPoint))).toBe(
"Invalid public key",
);
});
});
61 changes: 61 additions & 0 deletions admin-web/src/pubkey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { nip19 } from "nostr-tools";

const HEX_PUBKEY = /^[0-9a-fA-F]{64}$/;
const INVALID_PUBLIC_KEY = "Invalid public key";
const SECP256K1_FIELD = BigInt(
"0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f",
);

function modPow(base: bigint, exponent: bigint, modulus: bigint): bigint {
let result = 1n;
let factor = base % modulus;
let remaining = exponent;
while (remaining > 0n) {
if (remaining & 1n) result = (result * factor) % modulus;
factor = (factor * factor) % modulus;
remaining >>= 1n;
}
return result;
}

/** BIP-340 public keys are x coordinates that must lift to secp256k1. */
function isValidXOnlyPublicKey(hex: string): boolean {
if (!HEX_PUBKEY.test(hex)) return false;
const x = BigInt(`0x${hex}`);
if (x >= SECP256K1_FIELD) return false;
const ySquared = (x * x * x + 7n) % SECP256K1_FIELD;
const y = modPow(ySquared, (SECP256K1_FIELD + 1n) >> 2n, SECP256K1_FIELD);
return (y * y) % SECP256K1_FIELD === ySquared;
}

/** Format a Nostr public key for people without changing protocol storage. */
export function formatNpub(pubkey: string): string {
const value = pubkey.trim();

if (HEX_PUBKEY.test(value)) {
const normalized = value.toLowerCase();
return isValidXOnlyPublicKey(normalized)
? nip19.npubEncode(normalized)
: INVALID_PUBLIC_KEY;
}

try {
const decoded = nip19.decode(value);
if (
decoded.type === "npub" &&
typeof decoded.data === "string" &&
isValidXOnlyPublicKey(decoded.data)
) {
return nip19.npubEncode(decoded.data.toLowerCase());
}
} catch {
// Fall through to a stable sentinel; never echo a malformed raw identity.
}

return INVALID_PUBLIC_KEY;
}

export function truncatePubkey(pubkey: string): string {
const npub = formatNpub(pubkey);
return npub.length > 20 ? `${npub.slice(0, 10)}…${npub.slice(-8)}` : npub;
}
11 changes: 11 additions & 0 deletions admin-web/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,25 @@ export interface Report {
id: string;
communityId: string;
communityHost: string;
/** Legacy protocol-hex identity. Prefer reporterNpub when present. */
reporterPubkey: string;
reporterNpub?: string;
targetKind: "event" | "pubkey" | "blob";
target: string;
targetNpub?: string;
channelId?: string;
reportType: string;
note?: string;
status: string;
resolvedBy?: string | null;
resolvedByNpub?: string | null;
createdAt: string;
}

export interface ReportedMessage {
/** Legacy protocol-hex identity. Prefer authorNpub when present. */
authorPubkey: string;
authorNpub?: string;
content: string;
createdAt: string;
deletedAt: string | null;
Expand All @@ -27,7 +34,9 @@ export interface FeedbackSummary {
id: string;
communityId: string;
communityHost: string;
/** Legacy protocol-hex identity. Prefer submitterNpub when present. */
submitterPubkey: string;
submitterNpub?: string;
category?: string;
bodySummary: string;
receivedAt: string;
Expand All @@ -38,7 +47,9 @@ export interface FeedbackDetail {
communityId: string;
communityHost: string;
eventId: string;
/** Legacy protocol-hex identity. Prefer submitterNpub when present. */
submitterPubkey: string;
submitterNpub?: string;
category?: string;
body: string;
tags: string[][];
Expand Down
Loading