Buzz renders one card per `kind:30617`, so a project spanning several
repositories has no representation.
[NIP-MP](https://github.com/block/buzz/pull/3163) defines `kind:30621`
as an addressable container holding a group's name, description, channel
binding, and member coordinates. This adds the kind to `buzz-core` and
its structural validation to the relay ingest path.
## Event shape
```json
{
"kind": 30621,
"tags": [
["d", "platform"],
["name", "Platform"],
["description", "Relay, desktop, and mobile."],
["a", "30617:<owner-a-hex>:buzz"],
["a", "30617:<owner-b-hex>:buzz-infra"],
["buzz-channel", "<channel-uuid>"],
["buzz-visibility", "listed"]
]
}
```
## Validation at ingest
| Rule | Behavior |
|------|----------|
| `d` tag | exactly one, non-empty (length already bounded by the
generic `D_TAG_MAX_LEN` check) |
| member `a` tag arity | exactly 2 or 3 elements per NIP-01's `a` tag
grammar; a 4th element has no defined meaning and is rejected |
| member `a` tag coordinate | must parse as
`30617:<lowercase-64-hex-owner>:<non-empty-d>` |
| duplicate members | rejected on exact string match of the canonical
coordinate |
| member cap | 64, counted over raw `a` tags |
| metadata cardinality | at most one each of `name`, `description`,
`buzz-channel`, `buzz-visibility` |
| metadata length | `name` ≤ 256 bytes, `description` ≤ 2048 bytes,
`buzz-channel` ≤ 256 bytes, `buzz-visibility` ≤ 256 bytes |
| zero members | valid |
| unknown tags | ignored |
Rejection order is normative so a client can predict which rule fires:
`d`-cardinality → `d`-empty → member-cap → member-arity → coordinate
parse → member-duplicate → metadata cardinality → metadata length.
## Design notes
**No membership authorization.** Members are `a` tags, so one project
may name repositories owned by different pubkeys — the entire point of
the kind. That is safe because membership grants nothing: push policy
reads a repository's own `kind:30617` (`api/git/policy.rs`) and never a
project. `buzz-channel` is a metadata reference, not a routing
directive, so projects are classified global-only.
**Owner-only editing is free.** NIP-33 addressing keys replacement on
`(pubkey, kind, d)`, so one signer can never overwrite another's
project. No relay-side permission check exists or is needed, and
`test_project_same_d_under_two_authors_are_independent` pins it.
**Duplicates are rejected, not deduped.** A relay cannot rewrite tags
inside a signed event without invalidating its id and signature, so the
alternative to rejection is a stored duplicate-member head that every
consumer must apply a first-wins rule to.
**The cap is checked before the duplicate set is built.** Counting raw
`a` tags rather than distinct coordinates means an event naming one
coordinate thousands of times is refused on count, instead of being
bounded only by the relay frame limit.
**No side-effect handler.** Generic NIP-33 replacement and generic
NIP-09 coordinate soft-delete already cover replacement and deletion;
`kind:30621` needs no entry in `is_side_effect_kind`.
## Generic NIP-09 fix carried along
`soft_delete_by_coordinate` (`crates/buzz-db/src/event.rs`) previously
deleted the live coordinate head regardless of the tombstone's own
`created_at`, so a delayed or replayed `a`-tag deletion signed between
two versions destroyed the newer replacement. NIP-09 scopes an `a`-tag
deletion to versions at or before the deletion request, so the `UPDATE`
now carries `created_at <= $5` and `handle_a_tag_deletion` threads the
deletion event's `created_at` through.
The bug predates `kind:30621` and affected every
parameterized-replaceable kind on the generic path — `kind:30617`
repository announcements included — so the fix lands there rather than
as a project special case. `events.created_at` is immutable per row, so
the predicate guarantees a tombstone can never erase a version newer
than itself; the UPDATE re-evaluates its WHERE clause after any lock
wait. Under READ COMMITTED, a same-coordinate replacement racing the
deletion may cause the deletion to evaluate before the new head lands,
returning `Ok(false)` — but that outcome is state-identical to the
deletion having arrived first, a valid Nostr ordering Nostr never fixes.
The return value feeds only a debug log. No coordinate-level lock is
needed.
## Coverage
32 unit tests in `crates/buzz-relay/src/handlers/ingest.rs` pin the
envelope contract (accept: minimal, cross-owner, zero-member, same repo
`d` under two owners, colon-bearing repo `d`, cap boundary, unknown
tags, relay hint on member `a` tag, max-length metadata, stranger-owned
member, uninterpreted metadata values, non-empty content; reject: every
rule above plus valueless `d`/`a` tags). A fixture-driven test
(`project_envelope_validates_all_shared_fixtures`) runs every case in
the shared `NIP-MP.fixtures.json` oracle (11 accept + 20 reject) against
`validate_project_envelope`, so any future change that breaks a case
turns the test suite red.
6 `#[ignore]`d e2e tests in
`crates/buzz-test-client/tests/e2e_project.rs` cover behavior that only
exists past storage — coordinate round-trip, newer-wins replacement, two
authors sharing a `d`, an `a`-tag tombstone that removes the project
while leaving referenced `kind:30617`s intact, and a tombstone
timestamped between V1 and V2 that must leave V2 live. The negative e2e
case asserts on the rejection message so a refusal for an unrelated
reason cannot satisfy it; that is what proves the validator is reachable
from the live write path rather than merely correct in isolation. The
new e2e binary is wired into the Relay E2E job.
The timestamp predicate is additionally pinned at the storage layer by
`coordinate_delete_spares_head_newer_than_the_deletion` in
`crates/buzz-db/src/lib.rs`, which asserts both directions: a stale
tombstone deletes nothing and leaves the newer head readable, and a
tombstone at the head's own timestamp still deletes it. This test is
wired into the Backend Integration job.
Related: #3163 (the NIP-MP spec and shared conformance fixtures).
Independent — either can merge first.
---------
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Closes#3527.
Repos announced via vanilla NIP-34 (kind:30617 without a `buzz-channel`
tag) 404 forever: the SEC-005 read gate requires a channel-membership
ACL, and nothing tells the author why or how to fix it. Per the ruling
in the originating thread, this ships **bind/rebind tooling plus a
narrow author-only remediation carve-out** — the shelved owner-circle
approach is intentionally absent.
## Relay
- **`api/git/binding.rs` (new):** shared tri-state binding resolver —
`Bound(uuid)` / `NotBound` / `Broken`. First-tag, fail-closed: a
malformed `buzz-channel` tag is `Broken`, never conflated with "no tag".
Both gates use it.
- **Read gate (`transport.rs`):** a **never-bound** repo read by **its
own announcement author** still returns 404 (status byte-identical to
the generic denial) but the body carries remediation: `run: buzz repos
bind --id <repo> --channel <channel-uuid> — …`. This leaks nothing — the
author announced the repo, and only the author can rebind (30617 is
keyed by `(author, d)`). `Broken` bindings stay generic-denial for
everyone, including the author (revocation shape).
Bound-to-nonexistent-channel stays generic (phase 1; ingest validation
is phase 2).
- **Push gate (`policy.rs`):** unbound denial now returns
`GIT_NO_CHANNEL_BINDING_BODY`. A deploy-skew test pins that the body
carries both the new token (`no_channel_binding`) and the legacy phrase
(`"no channel binding"`) so already-shipped desktops keep matching.
**(Review r1, blocker 2)** `Broken` no longer collapses into "unbound":
it denies 403 `invalid channel binding` for *everyone — including the
announcement owner —* **before** the owner short-circuit, matching the
read gate's fail-closed posture. The remediation token stays
NotBound-only.
- **`ingest.rs`:** side-effect failure `warn!` → `error!` — prod runs
`RUST_LOG=error`, so these failures were invisible during triage.
## Contract
- **`buzz-core/git_perms.rs`:** `GIT_NO_CHANNEL_BINDING_TOKEN` /
`GIT_NO_CHANNEL_BINDING_BODY` consts as the declared cross-component
contract; relay tests and desktop matcher both build on them.
## CLI
- **`buzz repos bind --id <repo> --channel <uuid>`** — rebinds an
existing announcement, preserving other tags.
- **(Review r1, blocker 1)** **`--channel` on `buzz repos create`** —
optional; injects exactly one shape-validated `buzz-channel` tag at
creation via a pure `build_create_announcement` builder, so the primary
create command stops producing repos the relay 404s. UUID
existence/membership stays the relay's authority at git-access time
(same TOCTOU posture as `repos bind`). Overlaps with #3594 (open, head
6bbe38459) — happy to reconcile whichever lands first; this branch also
carries the bind path and tag preservation.
## Desktop
- **Rust:** new `commands/project_git_merge_error.rs` (extracted from
`project_git_workflow.rs` to respect the 1000-line ratchet); maps the
token to a structured `no_channel_binding` error carrying the bind
command.
- **TS:** new `features/projects/lib/projectBranchErrors.ts` + tests —
dual matcher (new token AND legacy spaced phrase);
`ProjectBranchDialogs.tsx` uses it.
## Tests / verification (at head f914c7066, base 581baa625)
- Workspace `cargo test` green; `clippy -D warnings` clean; desktop Rust
1859 pass; TS 3780 pass; tsc/biome/file-size checks pass. Pre-push hooks
re-ran all suites at the pushed head.
- Postgres-gated `sec005_read_gate_tests`: all 6 pass, including
`read_gate_gives_author_of_unbound_repo_remediation_body` — asserts 404
status, `text/plain` content-type, and exact body bytes, distinguishing
remediation from generic denial (a blind `is_err()` can't).
- **New (review r1):** `buzz-cli` emitted-event tests —
`create_with_channel_emits_exactly_one_binding_tag`,
`create_without_channel_emits_no_binding_tag`,
`create_rejects_malformed_channel_uuid` (266/266 pass). Postgres-gated
`push_gate_denies_owner_through_broken_binding` — owner +
malformed-first/valid-second binding → 403 generic body without the
remediation token; never-bound control stays 200, pinning the denial to
`Broken` specifically.
- e2e git tests now bind announcements to a real channel via a
`create_test_channel` helper.
---------
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
## Summary
- add one strict `BUZZ_S3_ADDRESSING_STYLE=path|virtual` setting shared
by media and Git/CAS storage
- preserve path-style defaults for bundled Compose/Helm MinIO while
supporting Railway's virtual-hosted bucket contract
- fail startup on invalid or non-Unicode values before dependency
connection, and validate the Helm value with the same two choices
- document operator mappings and why endpoint and bucket remain separate
for routing and SigV4 signing
## Best-practice rationale
AWS documents both URL forms and favors virtual-hosted addressing for
S3, while compatibility endpoints such as the bundled MinIO deployment
can require path style. `rust-s3` defaults to virtual/subdomain
addressing and provides `with_path_style()` for the explicit
compatibility case.
Some providers buckets only support as virtual-hosted bucket styles.
This PR therefore uses one explicit, provider-neutral switch rather than
endpoint heuristics or fallback behavior, while retaining `path` as
Buzz's backward-compatible default.
Sources:
-
https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html
- https://docs.rs/rust-s3/0.37.0/s3/bucket/struct.Bucket.html
- https://docs.railway.com/storage-buckets#url-style
-
https://github.com/minio/minio/blob/master/docs/config/README.md#domain
## Validation
- `cargo fmt --all`
- `cargo check --workspace --all-targets`
- targeted `buzz-media` and `buzz-relay` parsing/client-construction
tests for defaults, strict errors, and both URL styles
- Helm unittest: 45/45 passed
- Compose config/render validation passed
- local MinIO path-mode relay startup passed the Git A3 conformance
probe and became ready
- unreachable object storage failed startup and readiness never opened
- push hooks completed the broader Rust and desktop suites successfully
---------
Signed-off-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz>
Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Co-authored-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz>
Fixes all six HIGH findings from the buzz security report, one commit
per finding. Independently reviewed to approval by Max at `0158ae542`,
plus a deep isolated live pass (clean-room compose stack, weird ports,
full product matrix) at the same head — see the buzz-security thread for
evidence. `fe65c07c3` merges current `origin/main` on top (new commit,
no rebase), inheriting the nostr 0.44.6 bump (#3135) and relay-admin ban
gate (#3128).
## Findings and fixes
| Finding | Commit | Fix |
|---|---|---|
| 003 — quinn-proto RUSTSEC-2026-0185 | `e5dcdec72` | Bump quinn-proto
0.11.14 → 0.11.16 (lockfile-only) |
| 002/004 — linkify-it quadratic-parse DoS (GHSA-22p9-wv53-3rq4,
GHSA-v245-v573-v5vm) | `923b3c20f` | pnpm override `linkify-it: ^5.0.2`;
`pnpm why` confirms a single 5.0.2 copy |
| 001 — media reads served unauthenticated by default | `0f277e3e2` |
Helm `requireMediaGetAuth` defaults to `true` + rendered-chart test
pinning the default |
| 006 — removed workflow owners retain webhook-exfiltration authority |
`4749bd56c` | Fail-closed per-fire authority gate (current owner/admin
membership) on **all four** trigger doors (on_event, scheduler
pre-claim, manual trigger, webhook — masked as generic 404), save-time
gate for `call_webhook` defs, durable disable-on-removal wired to kinds
9001 + 9022 |
| 005 — git Smart-HTTP reads ignore channel membership | `e648f2dba` +
`0158ae542` | `authorize_git_read`: caller's **current active
membership** in the repo's bound channel, checked before any
hydration/subprocess on all three read doors (`info_refs` for both
services + `upload_pack` POST). Uniform generic 404 denials (no
membership probing), no repo-owner bypass, first-`buzz-channel`-tag
binding semantics fail closed on ambiguous duplicates (mutation-verified
test). Resolution follows the live kind:30617 announcement, so
deleted/replaced announcements deny immediately. The committed
`e2e-git-perms.sh` guest scenario previously asserted the vulnerability
— now asserts denial. |
## Behavior changes to be aware of
1. **Unbound repos fail closed for git reads.** `buzz repos create`
emits no `buzz-channel` tag, so CLI-created repos without a binding are
unreadable via git HTTP. Correct per finding 005's fail-closed posture;
a follow-up could bind CLI-created repos at creation time.
2. **006 is conservative:** a workflow disabled on owner removal does
not auto-re-enable if the owner is re-added — explicit re-enable
required.
3. Merge conflict resolution in `fe65c07c3`: kept main's
`@radix-ui/react-dismissable-layer` 1.1.19 bump alongside the linkify-it
security override (`pnpm-workspace.yaml` + lockfile).
## Verification at the merge head `fe65c07c3` (same shell)
- buzz-relay `--lib`: 761 passed / 1 failed — the lone red is the known
pre-existing `mesh_demo::demo_join_forwarded_arm_round_trips_echo` 504
flake, present on main
- SEC-005 module incl. PG behavioral matrix: 8/8 (removed-member,
never-member, owner-no-bypass, deleted-30617, malformed/ambiguous
binding, owner-mismatch all denied)
- buzz-workflow 153/0, buzz-db 84/0; `clippy --all-targets -D warnings`
+ `fmt --check` clean
- Desktop JS 3637/3637, tsc clean, biome clean,
file-size/px-text/pubkey-truncation gates clean
- `helm lint` + `helm unittest` (40/40) on `deploy/charts/buzz`
- All five pre-push hooks green (desktop-check, desktop-test,
rust-tests, desktop-tauri-test, branch-skew)
Prior review evidence at `0158ae542` (pre-merge): Max's independent
exact-head approval + clean-room live regression pass
(`WORK_LOGS/2026-07-27_SECURITY_HIGH_LIVE_TEST.md` in his workspace).
Max will re-run the deep local pass at this post-merge head before
merge.
---------
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>