mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
projects-v5
34
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8a2c9af2db |
feat(deletion): add durable whole-community deletion (#4425)
## Summary Adds a durable, operator-controlled V1 for deleting an entire Buzz community without deleting another tenant's data. The workflow is exposed through `buzz-admin deletions`: - `sweep` records independent fleet storage-taxonomy observations - `submit`, `list`, `inspect`, and `approve` manage a deletion request - `unblock` resumes a fail-closed request after an operator records remediation identity and reason - `run` and `drain` execute bounded work Requests advance through a PostgreSQL-backed state machine and stop at `retention_pending` after logical deletion has been independently verified across PostgreSQL, object storage, and Redis. This PR ships the engine and CLI, not a continuously running worker or Kubernetes packaging. For V1, a cluster/VM administrator invokes `/usr/local/bin/buzz-admin` from the existing relay image, for example with `kubectl exec` or an equivalent container/VM exec path. ## What whole-community V1 removes For the target community, V1 removes: - rows from the allowlisted community-scoped PostgreSQL catalog, including members, profiles, authored events and bodies, DMs, reactions, mentions, memberships, tokens, workflows, moderation, audit, feedback, and rate-limit state - media sidecars and upload-attribution records under `_meta/<community>/` and `_uploads/<community>/` - Git repository pointers under `repos/<community>/` - Redis keys under `buzz:<community>:*` The community row survives as a permanent tombstone, and deletion control-plane records remain as evidence of the request, approval, execution, and result. ## Safety model Deletion is not a broad `DELETE CASCADE` followed by optimistic cleanup. The destructive boundaries are durable and fail closed. ### 1. Inventory and approval - `submit` resolves the target and freezes the schema plus summary-only storage inventory. - Approval is bound to the exact request, community, and frozen inventory digest. - Unsupported manifest versions, malformed keys inside the target's owned prefixes, live scoped-table/write-fence coverage drift, frozen-inventory mismatch, and approval mismatch block execution rather than guessing. Migration and catalog revision numbers are not authorization gates; the executor validates the live safety shape instead. - Storage inventory is server-side prefix scoped to exactly: - `_meta/<community>/` - `_uploads/<community>/` - `repos/<community>/` - The deletion path never lists the whole shared bucket and has no arbitrary per-community object cap. Its listing work is proportional to the target community's bindings, not total fleet storage. - Fleet-wide taxonomy sweeps remain independent observability. They report unknown writer shapes but do not gate deletion submission, fencing, or destructive progress. Maintainers must add deletion taxonomy coverage whenever a new community-owned object-key class is introduced; writer-coverage tests bind the current media and Git writers to that contract. ### 2. Quiesce, fence, and destructive freeze - Writes continue through submission, inventory, and approval. They stop when execution moves the target into `quiescing` and then establishes the durable fence. - Already-admitted external effects finish under heartbeated serving-write leases; the exact admitted lease may renew while the community is quiescing, but new lease acquisition is rejected. The executor drains admitted leases before destructive work. - Invite minting after quiescing begins fails as typed `AccessDenied` (HTTP 503 at the relay boundary) before an invite can be persisted. - Database triggers enforce the community write fence across the complete catalog of community-scoped tables. Startup/readiness and destructive execution validate that catalog so a newly added but unfenced table cannot silently escape. - **Named isolation assumption — fresh write snapshot.** Every writer transaction that can reach a community-fenced relation must use PostgreSQL `READ COMMITTED`; each guarded write therefore observes a statement snapshot no older than acquisition of the community deletion lock. `REPEATABLE READ` and `SERIALIZABLE` can retain a pre-fence snapshot and are unsupported for writers. The writer pool refuses non-`READ COMMITTED` sessions at connection setup, and both SQL fence functions reject an explicit per-transaction isolation override with SQLSTATE `25000`. Configuration-delivered bad isolation can surface through SQLx as a pool-acquire timeout because every `after_connect` attempt is rejected; the precise `community writes require READ COMMITTED isolation` reason remains observable when the SQL guard is reached. Read-only replica transactions are outside this assumption. - Holding the shared advisory lock until the guarded write executes is a separate liveness condition: under `READ COMMITTED`, releasing it early does not permit resurrection because the trigger rechecks the fence, but it can turn a fleet sweep into a statement-wide SQLSTATE `55000` abort. - After the fence closes writers, storage is re-enumerated into chunked side-table rows. Per-prefix counts and digests bind those concrete keys to the destructive manifest. - Manifest chunk insertion, update, and deletion are protected after freeze. This closes the race where an unbound key could otherwise appear after the manifest was committed. ### 3. Checkpointed destruction - Target-owned object bindings are deleted from the frozen destructive manifest in bounded batches with durable progress. - The concrete key list lives in chunked side-table rows rather than one request-row JSON value. It supports large communities, resumable execution, and terminal cleanup. - Missing objects are accepted as idempotent crash-window outcomes; malformed ownership, changed evidence, and unexplained target-prefix drift fail closed. - PostgreSQL purging remains scoped by `community_id`, including the guarded NIP-RS hard-delete path discovered with real Desktop kind `30078` read-state data. - Redis cleanup explicitly scans and `UNLINK`s only `buzz:<community_id>:*`. Natural expiry is insufficient because some keys, including tunnel generation counters used as fencing state, are deliberately persistent. ### 4. Independent verification - PostgreSQL logical absence is checked after purge. - The three target-owned storage prefixes are freshly inventoried again and must be empty. - Redis requires two complete empty namespace scans. - Only after all three stores pass does the request advance through `logically_verified` to `retention_pending`. ## What V1 deliberately does not erase ### Shared content-addressed storage Per-community deletion removes bindings, metadata, attribution records, and Git pointers. It does **not** physically delete fleet-shared CAS bytes that another community may still reference: - media blobs and thumbnails - Git manifests, packs, and indexes (`manifests/`, `packs/`, and `idx/`) Safe reclamation requires a separate fleet-wide reachability and retention GC. Unknown keys elsewhere in the shared bucket do not block one community's deletion; malformed or unrecognized keys inside that community's three owned prefixes still fail closed. ### External retained copies The online logical-deletion proof does not erase object versions/replicas, database backups/WAL, CDN copies, provider retention copies, or observability exports. Those require their own retention and purge controls. ### Member-only erasure This PR erases a whole community. It does not implement the different operation "erase one npub while preserving the community." Removing membership or accepting NIP-09 is not member erasure. A member-only workflow would need to find and selectively remove or redact authored event content and pubkeys, profile data, DMs, reactions, mentions, memberships/roles, tokens, workflows/subscriptions, upload attribution, moderation/audit history, repository attribution, and identity embedded in tags or JSON. It would also need explicit rules for ownership transfer, surviving replies and thread metadata, audit-chain integrity, immutable Git history, and shared-CAS reachability. That requires a pubkey-level fence and selective graph rewrite; it is a separate deletion product, not a safe extension of this whole-tenant worker. ## In scope - migration `0029_community_deletion.sql`: requests, approvals, leases, manifest chunks, checkpoints, tombstones, and the universal write-fence catalog - durable executor leases, generations, heartbeats, retry/block state, and resumable stage transitions - operator-driven `sweep`, `submit`, `list`, `inspect`, `approve`, `unblock`, `run`, and `drain` commands - serving-path fences for database writes and external effects across event ingest, media, Git, workflow, push, invites, mesh/tunnel, and related paths - target-prefix-only storage inventory, summary manifests, post-fence destructive chunks, and bounded batch deletion - exact community Redis namespace purge and two-pass absence verification - cross-community isolation, crash/resume, manifest-integrity, writer-taxonomy, and schema/migration regressions - desired-state `schema/schema.sql` support without requiring a SQLx migration ledger ## Deferred / not covered - dedicated Helm/chart worker Deployment, service account, secrets, probes, resources, and network policy - autonomous `buzz-admin deletions worker` poll loop and worker-only health server - least-privilege separation among migration, relay-serving, and destructive execution roles - fleet-wide shared-CAS physical GC - backup/provider/CDN/observability retention completion - member-only erasure - provider-native conditional-delete improvements - a general force-continue escape hatch; permanent safety failures remain fail closed unless an operator remediates the cause and records an audited `unblock` The removed continuous-worker implementation remains deferred; no remote follow-up branch is claimed by this PR. ## Validation ### Current PR head and repository state Current pushed head: `359d8402ee15f049768f54156f67b953c7a7e2ed`, rebased onto `cc9a2f783375e51a6e8d1f2f9d01d5f7e22813d1` (`origin/main` at push time). The complete PR diff is now 47 files, 9,834 additions, and 517 deletions. The bespoke source-scanner stack was removed to keep this PR scoped to community deletion. Tyler/team requested the underlying fenced-write safety behavior, not `ast-grep`, `crates/buzz-db/tests/community_fenced_writes.rs`, its 27 fixtures, or the new `scripts/lints/community_*.yml` rules. Those scanner-specific files, dependencies, Hermit links, and runner wiring are absent from the current tree. The production database write fence, startup/destructive live-catalog validation, and deletion behavior remain. Source validation on this exact SHA passed: - `cargo fmt --all -- --check` - `bash -n scripts/run-tests.sh` - `cargo nextest run -p buzz-db --all-targets`: 102 passed, 173 skipped, 0 failed - `cargo nextest run -p buzz-deletion --all-targets`: 10 passed, 9 skipped, 0 failed - `cargo nextest run -p buzz-admin --all-targets`: 1 passed, 0 failed - affected-package/all-target Clippy with warnings denied - lockfile consistency - Helm 3.16.4 lint and all 44 chart unit tests - Helm region controls using that fixture: default `BUZZ_S3_REGION=us-east-1`, explicit `eu-west-2` override, and blank-region schema rejection The prior Kubernetes battery below was run against `928992237358a3294621ac0280830b77155abc04`. It remains useful evidence for the patch-equivalent production deletion implementation, but it is **not** claimed as exact-SHA evidence for current head `359d8402ee15f049768f54156f67b953c7a7e2ed`; the current cleanup removes only scanner/test/tooling infrastructure. CI restarted for the new head after the rebase and is pending. Human review remains `CHANGES_REQUESTED`. ### Prior-head live Kubernetes deletion and safety gates The full program used one immutable image, real PostgreSQL, Redis, MinIO, and a three-relay Kubernetes release: - source: `928992237358a3294621ac0280830b77155abc04` (**prior head**) - image: `buzz-e2e:sha-928992237358` - immutable image digest: `sha256:a1a204f4618ac22d9e210be5e5290645a15d79831ae30b0e44379357c8e4a895` - evidence root: `/tmp/buzz-e2e/20260807T033025Z-928992237358-full-gates/` - evidence-manifest digest: `82875c5bc9bea7370b796a7aef3457b3a1c8306c84c59e0f7388bbb5ad30e865` Passed gates at that prior head: - **Chart/operator region:** default `us-east-1`, explicit nondefault propagation, blank-region schema rejection, live in-pod environment, and an in-pod taxonomy sweep over 18 objects with zero unknown. - **Fenced writers and lifecycle:** open-write/fence ordering; 100-attempt anti-starvation; invite, push matcher, and exhausted-reaper bystander isolation; non-`READ-COMMITTED` rejection; manifest/tombstone contracts; eight-failure stage block and audited `unblock`. - **Destructive lifecycle:** submit → approve → run → `retention_pending`; PostgreSQL tombstone and Redis/S3 verification true; zero retries/errors; terminal reruns rejected with exit 5. - **Fresh 10,001-object crash boundary:** exactly two chunks (10,000 + 1). The executor deleted chunk 0 from MinIO while its PostgreSQL stamp was row-lock-blocked, was killed with `SIGKILL`, left one object and both stamps absent, then resumed the same request under generation 2 to zero objects and terminal state. - **Independent dead-owner recovery:** a dedicated executor claimed generation 1, blocked before effects, and was killed through containerd with `SIGKILL` (no TERM cleanup). The request remained owned and unreclaimable before lease expiry; a successor claimed generation 2 after 60 seconds and completed with two attempts and zero retries. - **Three-pod socket isolation:** ordinary NIP-42 and joined huddle-audio target witnesses on every replica received exact `1008 / community deleted`; healthy-tenant witnesses on those pods remained live; deleted-host reconnect returned HTTP 404. - **Health/provenance:** all replicas independently returned ready and retained the exact image digest before/after destructive runs and an audio-enabled rolling restart; PostgreSQL, Redis, and MinIO were healthy at close. Instrument corrections were retained as evidence rather than counted as product failures: a foreground PostgreSQL forward caused an initial `PoolTimedOut`; Kubernetes pod deletion exercised graceful TERM rather than dead-owner recovery; shell-background socket witnesses died with their parent; and the first image build hit the corporate TLS proxy. Detached forwarding/witnesses, containerd `SIGKILL`, and the configured internal CA/Artifactory mirror produced the discriminating runs without weakening product security. ### Prior-head cleanup For the prior-head Kubernetes run, the Helm release was removed, namespace absence was verified, run-owned Screen sessions were absent, and that source worktree remained clean. The evidence manifest was independently recomputed and every indexed artifact passed `shasum -a 256 -c`. The current `359d8402` source worktree is also clean after the scanner-only cleanup and push. --------- Signed-off-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz> Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Signed-off-by: Kalvin Chau <kalvin@block.xyz> Signed-off-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Signed-off-by: cid <d9f92a72922bf45c17379a47d64dae84b6020397c2d5a52b5317d512068cd9d3@buzz.block.builderlab.xyz> Co-authored-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz> Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Co-authored-by: cid <d9f92a72922bf45c17379a47d64dae84b6020397c2d5a52b5317d512068cd9d3@buzz.block.builderlab.xyz> |
||
|
|
bba3e06386 |
Fix macOS attachment picker lifecycle and allow inert HTML downloads (#5569)
## Problem Canceling the native macOS file chooser leaves the composer's temporary, detached `<input type="file">` without a `change` event or an explicit cleanup path. Opening Finder again immediately creates a second detached input while WebKit may still be unwinding the first picker. The newly selected files can therefore fail to reach the upload pipeline. Drag and drop is unaffected because it bypasses this picker lifecycle. This does **not** add an automatic retry mechanism. “Retry” means the user's next attachment attempt after canceling or after a prior selection. ## Fix - give each composer hook one hidden, body-mounted file input for its lifetime instead of creating a detached one per click - reset and reconfigure that input before every open, replace its handler rather than stacking handlers, and remove it cleanly on unmount - preserve normal selection, cancel then reopen, selecting the same file again, and multi-select behavior - accept canonical `text/html` attachments while continuing to serve and render them strictly as inert downloads - keep XHTML, SVG, JavaScript, and executable MIME types blocked The picker change fixes the ownership/lifecycle bug at its source; it does not retry failed uploads, add delays, or mask errors. ## Testing - mandatory pre-push gate: branch-skew, desktop typecheck/tests/check, Rust tests, and desktop Tauri checks passed on `ea5a97adf957803935b28d63d32f9f332cf65287` - `cargo test -p buzz-media --lib` (110 passed) - `pnpm --dir desktop typecheck` - focused Biome check for the three picker files - picker Playwright regression: cancel/no selection then reopen, select the same file again, and multiple selection (run on the source commit before integration) - HTML live-relay response regression added as ignored E2E because it requires the S3-backed relay harness ## Manual verification Playwright models cancellation with Chromium's `FileChooser.setFiles([])`; it cannot exercise the native macOS Finder panel/WebKit presentation lifecycle. Before merge, manually verify in the built macOS app: 1. select a PNG normally 2. cancel, then immediately reopen and select a PNG 3. select the same PNG on a subsequent attempt 4. multi-select two PNGs --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Princess Donut <68157ebd23b3897c1991015c3038658ea916200c67d3a54620b0754d1b92f6e0@buzz.block.builderlab.xyz> Co-authored-by: Mongo <5c25403eab7271f9f94ddd4f2b270e8cac2c92e2c830c51877cca6ec974ffb3f@buzz.block.builderlab.xyz> Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz> |
||
|
|
240cdd3ea1 |
chore: mesh upgrade, clean up legacy special case code, simplify model selection for mesh (#5289)
Shared compute now has exactly two model choices: MeshLLM's virtual `mesh` model, or a model you name. Buzz picks between them in one place, and buzz-agent no longer knows meshes exist. ## What changed - **MeshLLM v0.74.0 → v0.75.1.** v0.75.0 added `degrade_to_single_model`, so a `model=mesh` request is answered by one served model when there is no committee to form, instead of failing. v0.75.1 adds Mesh-LLM#1196, which skips stale pre-0.75 runtime cache entries rather than aborting startup on them — without it, anyone who had run mesh on 0.73/0.74 could not start. - **Deleted the client-side mesh catalog probe.** buzz-agent used to poll `/v1/models` (5s TTL, 30s cooldown, two-observation debounce) to decide whether `mesh` was safe to send. MeshLLM now decides per request, so the polling, its hysteresis, and its 503 fallback are gone. - **One mapping point.** `relay_mesh_wire_model()` turns the stored value into a wire name: `auto` becomes `mesh`, a named model passes through. The spawn env, the ACP harness, and the readiness probe all use it, so they cannot disagree — previously `BUZZ_ACP_MODEL` and the probe both said `auto`, a name the mesh does not advertise. - **Removed the `nostr-relay-pool` advisory exception.** #5404 allowed RUSTSEC-2026-0243 "after mesh-llm migrates to nostr-sdk >= 0.45". v0.75.1 does, so the retired crate is gone from both lockfiles and the exception would only mask a future advisory for it. - **Deleted `scripts/ensure-mesh-native-runtime.sh`** and its six justfile call sites. It built llama.cpp from source into the runtime cache; the app already downloads the signed release runtime itself, and CI never called it. ## Why it is better **−639 lines of Rust.** Availability is decided by the node that knows the answer, per request, instead of by a client cache that could be stale for up to 30 seconds. A second worker joining now takes effect on the next request rather than after two confirming probes. ## Behaviour change A 503 on an explicit `mesh` request takes the ordinary transport retry under the same model instead of failing over to a second one — there is no second model to fail over to now. MoA repairs partial committee results internally before it reaches that point. ## Validation `crates/buzz-relay/examples/mesh_agent_e2e.rs` now sends `mesh` where it previously sent `auto` or the physical model id, so no leg was covering what Buzz actually puts on the wire. 4/4 on gemma-4-E4B, gemma-4-26B-A4B, and Qwen3-8B — including a real ACP tool call through `mesh` into buzz-dev-mcp, asserted by reading the written file back off disk. Hand-tested in the desktop app on both gemma-4 sizes: picked Auto, agent logged `model_id=mesh`, replied in channel. ## Not covered A committee that forms and then loses a worker returns 502, and that needs two workers to reproduce — not testable on one machine. --------- Signed-off-by: Michael Neale <michael.neale@gmail.com> Co-authored-by: Michael Neale <michael.neale@gmail.com> |
||
|
|
2777189d96 |
fix(channels): restore member invitations to private channels (#5493)
## Summary - restore private-channel invitations for every active member - keep owner/admin-only enforcement for elevated role grants, active role changes, and removals - preserve #4612's unrelated Desktop/mobile failure handling and hardening - add relay coverage for the ordinary actor/target role matrix (`member`, `guest`, `bot`) ## Validation - pre-push hook passed on `7de700e17642ad7e10155f9537033168d9249268`: branch skew, Desktop checks/typecheck/tests/Tauri checks, mobile tests, and Rust tests - `cargo test -p buzz-test-client --test e2e_relay --no-run` - `cargo fmt --all -- --check` - `git diff --check` - Donut and Mongo independently reviewed the cross-layer authorization behavior; Donut's role-matrix coverage finding is addressed in this revision --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz> |
||
|
|
769ac70b74 |
fix(media): require authenticated reads (#4610)
This change requires a valid signed Blossom authorization request and current relay membership for every media GET and HEAD request. It removes the unauthenticated compatibility path and updates desktop reads to send the required authorization. This blocks anonymous retrieval and access after relay-membership revocation. It does not yet bind a blob to its originating channel, so someone removed from a private channel can still read a known blob while remaining a relay member. That channel-ACL follow-up remains required before closing the full finding. ## Testing - `git diff --check origin/main...codex/security-media-read-auth` - Rebased onto `origin/main` at `5c98932` - Full CI pending Originating Buzz thread: `buzz://message?channel=3928fe05-df61-4b5d-b9c7-d623b9b10ea1&id=3c6c02312f763fbe0d2bfc33a6c1a362f91d0354f3d18b039cf7a0558c1439d1` --------- Signed-off-by: Jordan Mecom <jm@squareup.com> Signed-off-by: Alex Rosenzweig <arosenzweig@squareup.com> Signed-off-by: Eli Foster <efoster@squareup.com> Co-authored-by: Eli Foster <efoster@squareup.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
efe1893dd3 |
fix(channels): restrict private-channel invitations (#4612)
This change requires an active owner or administrator for third-party additions to private channels. The relay validator and transactional database authority enforce the same rule, including removed-member reactivation and role-change paths. Idempotent self-target behavior remains available, while ordinary members can no longer extend private-channel access to another identity. ## Testing - `git diff --check origin/main...codex/security-private-channel-invite-authority` - Rebased onto `origin/main` at `5c98932` - Full CI pending Originating Buzz thread: `buzz://message?channel=3928fe05-df61-4b5d-b9c7-d623b9b10ea1&id=3c6c02312f763fbe0d2bfc33a6c1a362f91d0354f3d18b039cf7a0558c1439d1` --------- Signed-off-by: Jordan Mecom <jm@squareup.com> Signed-off-by: Eli Foster <efoster@squareup.com> Co-authored-by: Eli Foster <efoster@squareup.com> |
||
|
|
cb9701cd30 |
feat(relay): accept kind:30621 multi-repo projects at ingest (#3171)
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> |
||
|
|
114d40d9d3 |
feat(relay): gate kind 30178 team-catalog reads behind the shared tag (#3358)
Team catalog projections (`kind:30178`) embed every member's system
prompt, so they need the same read gate personas already have: only the
author sees an unshared event. The gate was hardcoded to `kind:30175` at
six read surfaces plus the SQL pushdown, so rather than adding a second
special case it becomes kind-generic over `SHARED_GATED_KINDS = {30175,
30178}`.
## Kind 30178
New parameterized-replaceable kind, addressed by `(pubkey_o, 30178,
team_id)`. It embeds sanitized member projections instead of referencing
`kind:30175` heads — a foreign reader of a shared team could not
otherwise hydrate members whose own persona events are unshared or, for
built-ins, absent entirely. `kind:30176`'s wire body is untouched, so
device sync keeps its contract.
## Kind-generic shared gate
`buzz_core::kind` replaces `is_persona_shared_kind` /
`is_unshared_persona_event` / `persona_event_is_shared` with
`SHARED_GATED_KINDS` and the kind-agnostic `is_shared_gated_kind` /
`is_unshared_gated_event` / `event_is_shared`. Every read surface
consults the set:
| Surface | File |
|---|---|
| REQ historical delivery + `ids` lookup |
`crates/buzz-relay/src/handlers/req.rs` |
| Live fan-out | `crates/buzz-relay/src/handlers/event.rs` |
| COUNT fallback | `crates/buzz-relay/src/handlers/count.rs` |
| NIP-98 HTTP `/query`, `/count`, `/search` |
`crates/buzz-relay/src/api/bridge.rs` |
| Pre-`LIMIT` SQL pushdown | `crates/buzz-db/src/event.rs` |
The SQL clause generalizes from `kind != 30175` to `kind NOT IN (...)`
bound from `SHARED_GATED_KINDS`, still applied before `ORDER BY … LIMIT`
so a page of newer private events cannot starve an older shared one off
the candidate set. `EventQuery::persona_reader` is renamed
`shared_gated_reader` and `needs_persona_filtering` to
`needs_shared_gate_filtering` to match.
Because the `buzz-core` rename has consumers outside the relay, the four
desktop call sites of `persona_event_is_shared` travel with it:
`desktop/src-tauri/src/commands/personas/pending.rs`,
`desktop/src-tauri/src/event_sync.rs`, and two in
`desktop/src-tauri/src/managed_agents/persona_events.rs`. Each call is
unchanged apart from the name — the persona `shared` projection behaves
exactly as before.
## Ingest validation
`validate_persona_envelope` splits into two reusable pieces —
`validate_shared_tag` (exactly-two-element `["shared","true"]`, at most
one occurrence) and `single_bounded_d_tag` (exactly one `d` tag,
non-empty, `<=64` chars, no ASCII control characters or whitespace).
`validate_team_catalog_envelope` composes both; personas additionally
keep the slug grammar `^[a-z0-9][a-z0-9_-]{0,63}$`.
`kind:30178` deliberately does **not** get the slug grammar. Team ids
are UUIDs or built-in identifiers such as `builtin-team:welcome`, and
the colon is not slug-legal; rewriting ids to fit would break NIP-33
addressing against the team's own `kind:30176` head. The non-empty and
exactly-one checks are load-bearing regardless — without them generic
NIP-33 storage maps a missing `d` onto `(pubkey_o, 30178, "")` and every
team overwrites its predecessor.
The exact two-element `shared` shape is enforced because the SQL
visibility clause is JSONB containment (`tags @>
'[["shared","true"]]'`), which would match a three-element superset such
as `["shared","true","extra"]`.
`kind:30178` is also added to the `Scope::UsersWrite` allowlist and to
`is_global_only_kind`, so a stray `h` tag cannot channel-scope an
owner-authored definition.
## Deferred
`kind:30176` is deliberately not a gate member. Its writers never emit
`shared`, so catalog opt-in semantics do not describe it — it needs
owner-private reads driven by an authenticated principal set, tracked as
a separate follow-up.
## Tests
- 19 new `ingest.rs` unit tests covering the 30178 envelope (UUID and
colon `d` tags, 64-char boundary, non-ASCII bound,
empty/valueless/duplicate/missing `d`, embedded newline, `shared`
false/three-element/duplicate, scope and global-only membership).
- Persona regressions for the valueless `["d"]` shapes, since the
`d`-tag helper is shared by both validators.
- Existing `kind.rs` gate tests generalized and extended to assert the
gate applies to 30178 as it does to 30175.
- New `crates/buzz-test-client/tests/e2e_team_catalog.rs`: 9 WS-level
tests over a live relay covering author reads of unshared heads, foreign
omission from REQ, `ids`-lookup denial, COUNT existence-leak, share and
unshare transitions, and the mixed-kind filter case.
- `.github/workflows/ci.yml` adds `--test e2e_team_catalog` to the Relay
E2E job so the new suite runs.
## Docs
`docs/nips/NIP-AP.md` gains a "Team catalog projection: kind:30178"
section and an "Ingest validation: kind:30178" subsection, records the
gate as kind-generic, documents 30178 deletion vs. unshare semantics,
and adds a security note that sharing a team exposes every member's
instructions even when that member's own `kind:30175` head is unshared.
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
|
||
|
|
788b3c002b |
fix(git): channel binding tooling + author remediation for unbound repos (#3626)
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 |
||
|
|
7012d86d52 |
feat: configure S3 URL addressing style (#3400)
## 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> |
||
|
|
e2e0079101 |
fix(security): enforce durable community ban on NIP-43 relay-admin kinds 9030-9033 (#3128)
## Summary `ingest_event`'s durable write-path restriction gate exempts NIP-43 relay-admin kinds **9030–9033**, so that a *timed-out* admin keeps administrative capability. That exemption was ban-blind, and `handle_relay_admin_event` performed no restriction check of its own. A **banned** admin or owner could still add members, remove members, change member roles, and set the workspace icon by posting a signed NIP-98 request to `POST /events`. No open WebSocket required. Reported externally by **Bilal Syed** (also filed publicly as #3020 before he read `SECURITY.md`). Verified true, reproduced live, and found slightly worse than reported. Same class as BUZZ-SEC-007, which PR #1915 closed for moderation command kinds 9040–9044. That fix was never extended to the 9030 range. ## Why it worked - `handlers/ingest.rs:1639` skipped the restriction check when `is_relay_admin_kind(kind)` was true. - `handlers/relay_admin.rs` did a freshness check and a role lookup only — zero restriction reads in the file. - A ban does not remove the role: `ban_member` (`buzz-db/src/moderation.rs:314`) writes only `community_bans`, so the `relay_members` admin row survives. - The HTTP path never consulted ban state — `enforce_relay_membership` is a bare `SELECT 1 FROM relay_members`. - The ban was enforced only at the NIP-42 auth seam, which an HTTP request never crosses. **Worse than reported:** the report covered remove (9031) and icon (9033). Add (**9030**) works too, so a banned admin can *plant* new members. That matters because `moderation_authz.rs:163-170` derives "an admin cannot ban an owner or fellow admin" from `relay_members` — the very table 9030/9031 mutate. A banned admin could seed accomplices into the roster the ban was meant to stop them touching. Also of note: `moderation_authz.rs:158-165` already asserts in a comment that *"The command handler separately rejects a banned actor on every transport."* `relay_admin.rs` was the one command handler not holding that invariant. ## The fix Enforce the durable ban **inside `handle_relay_admin_event`** — the reporter's own suggested shape, and the `moderation_commands.rs:99-108` precedent. Deliberately **not** the one-token alternative of dropping `&& !is_relay_admin_kind(kind_u32)` at `ingest.rs:1639`: that would also start blocking *timed-out* admins, silently changing policy. Bans are refused; timeouts still administer, which is the entire reason the exemption exists. `handle_relay_admin_event` becomes a thin admission wrapper around an unchanged `execute_relay_admin_command` body, so no future early return inside that body can precede the check. The check therefore also necessarily precedes the freshness check. **The refusal category is part of the security contract**, so this returns a typed `RelayAdminError` rather than a string. A `blocked:` string would have kept the right wire text but returned **400** instead of **403** (`api/bridge.rs:845` vs `:858`), and would have reported a restriction-DB outage as a client error: | Variant | Ingest | Wire | HTTP | |---|---|---|---| | `Banned` | `AuthFailed` | `blocked: you are banned from this community` | **403** | | `Rejected(..)` | `Rejected` | `invalid: …` | 400 (unchanged) | | `Internal(..)` | `Internal` | `error: …` (sanitized) | **500** | ## Verification Live over real HTTP against an isolated relay, all four exempt kinds refused, DB checked after each for non-mutation: ``` [banned] 9031 remove -> 403 blocked: you are banned from this community [banned] 9030 add -> 403 blocked: you are banned from this community [banned] 9032 change role -> 403 blocked: you are banned from this community [banned] 9033 set icon -> 403 blocked: you are banned from this community ``` Victim still `member`, planted key absent, role target unchanged, icon still NULL. 9032 required a banned **owner** to be a real test, since it is owner-only. - **Mutation-tested.** The admission decision is the pure `admits_relay_admin_command(&RestrictionState)`, covered by the *default* suite. Neutering it fails `banned_actor_is_not_admitted_to_a_relay_admin_command`. The first version of this patch would have stayed green if someone deleted the check — that gap is closed. The unit test does not prove handler *wiring*; the `#[ignore]`d live E2E is what checks linkage. - **Fail-closed proven empirically**, by manual fault injection rather than assertion: renaming `community_bans.banned` out from under the running relay yields 500, no mutation, and no schema detail leaked to the client. - Negative/positive controls: timed-out admin still administers *and* is still content-write-blocked; clean admin unaffected with mutation confirmed; non-admin still gets `invalid:`/400. - Reviewed iteratively by **@Mari** over three rounds; final approval at 9/10+ on minimalness, elegance, and correctness. She also ran an independent deep regression pass on an isolated stack (odd port 44391) covering channel lifecycle, membership, messages/replies/search/edit/delete, reactions, canvas, DMs, and moderation transitions — no regressions. - `cargo fmt --all --check`, `cargo clippy -p buzz-relay --all-targets -D warnings`, `buzz-core` 229/229, `buzz-cli` 250/250, `run-tests.sh unit` all five packages green. - `buzz-relay --lib`: **756 passed / 1 failed**. The sole failure `api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo` (504 vs 200) is **pre-existing** — reproduced identically in a detached worktree at merge base `00ecf2c`. ## Notes for the reviewer - Merged `origin/main` in as a merge commit rather than rebasing, per instruction. No conflicts; the eight incoming commits touch none of the three files here. Closest neighbour is `00ecf2c` (kind:9000 NIP-29 *channel* role authz) — disjoint from this NIP-43 *relay-admin* fix. - **This does not close the class.** Two separate items remain open, deliberately excluded to keep an externally-known security fix reviewable: 1. **Command kinds dispatch before the gate.** `is_command_kind` fires at `ingest.rs:1561`, ~80 lines *before* the restriction gate, and `command_executor.rs` has no restriction read. Measured live: a banned member can still open a DM (41010 → 200). 41011/41012/30620/46030/46031 unprobed. Needs per-kind semantics enumerated first (reports allowed while banned; moderation commands allow timeouts but reject bans; ordinary writes reject both). 2. **`moderation_commands.rs` maps its own restriction-DB failure to 400, not 500**, and leaks the raw Postgres message to the client. - One correction for the public issue: its repro step 1 says `kind:9041`, which is **unban**. The ban is **9040** (`KIND_MODERATION_BAN`, `buzz-core/src/kind.rs:298`). Following the steps verbatim yields a false negative. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> --------- Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@buzz.block.builderlab.xyz> Co-authored-by: Tyler Longwell <tlongwell@block.xyz> |
||
|
|
00ecf2cac7 |
fix(security): authorize kind:9000 role changes in both directions (#3017)
## Summary
NIP-29 `kind:9000` (PUT_USER) role changes were only authorized when the
**new** role was elevated. Demotions were unauthorized, so any
authenticated user could strip a channel owner to `member` with a single
event — and the demotion was unrecoverable, since the ex-owner then
lacked the privilege to restore themselves.
Reported by @Tyler in `#buzz-security`. Verified true, plus two adjacent
defects the report flagged and one it did not.
## The defects
1. **Demotion unauthorized.** The actor check only fired when the
*requested* role was elevated. Lowering someone's role skipped it
entirely.
2. **Open channels skipped the actor check.** It was nested under
`visibility == "private"`.
3. **`add_member` had no last-owner guard** while `remove_member` did —
so a channel could be left with zero owners.
4. **(Not in the report.)** An absent `role` tag defaulted to `Member`,
so a bare self-targeted PUT_USER silently demoted the sender. No
attacker required.
## The fix
**`crates/buzz-db/src/channel.rs`** — the authority, because it also
covers the desktop/admin callers that bypass the relay validator:
- Changing an **active** member's role requires an elevated actor **in
both directions**. Re-adding at the same role stays unguarded and
idempotent (the huddle bot-add and `kind:9021` join paths depend on
this).
- Last-owner guard in `add_member`, mirroring `remove_member`.
- Keyed on the **active** role (`removed_at IS NULL`). A soft-removed
row's role is history, not live authority — otherwise soft-deleted
ownership becomes a resurrection token: a kicked owner self-rejoins via
`9021` and silently regains ownership.
- New `pg_advisory_xact_lock` on a channel-membership namespace, taken
as the first statement in both `add_member` and `remove_member`. Both
read an owner `COUNT` and then write a *different* row, so READ
COMMITTED alone lets two concurrent demotions each observe 2 owners and
together leave 0.
- `remove_member`'s `is_agent_owner` lookup moved before the transaction
opens — it borrows a second pool connection, and issuing it while
holding the lock could self-deadlock on a small pool. Safe because
`agent_owner_pubkey` is immutable (first-mint-wins).
**`crates/buzz-relay/src/handlers/side_effects.rs`**:
- Role tag is now `Option` — absent means "no role change requested"
rather than defaulting to `Member`.
- Actor-role lookup hoisted out of the `visibility == "private"` block,
so open channels are covered.
- Role-change and last-owner guards on every visibility. Rejecting here
*as well as* in the DB means clients get a real error instead of an `OK`
whose side effect then fails silently.
## Verification
**Mutation tested — every guard stubbed individually to confirm a test
actually dies.** Three of eight guards were originally uncovered and
survived being disabled with the suite fully green:
| Guard | Dying test |
|---|---|
| DB actor-auth | *survived* → **new**
`unprivileged_member_cannot_demote_a_co_owner` |
| DB last-owner | `owner_can_still_manage_roles_after_demotion_guard` |
| DB active-role (soft-remove) |
`kicked_owner_rejoins_as_member_not_owner` + 3 |
| `add_member` advisory lock |
`membership_writes_serialize_on_the_shared_channel_lock` |
| `remove_member` advisory lock | +
`remove_member_rejects_an_actor_demoted_while_it_waited` |
| relay no-role-tag preservation |
`test_nip29_put_user_without_role_tag_preserves_role` |
| relay actor-auth | *survived* → **new**
`test_nip29_relay_rejects_role_change_by_unprivileged_actor` |
| relay last-owner | *survived* → **new**
`test_nip29_relay_rejects_last_owner_self_demotion` |
The three gaps shared one cause: every existing test asserts resulting
**state** ("the role did not change"), and the DB guards enforce that
state, masking every layer above them. With a relay guard stubbed the
relay answers `accepted:true` and logs `Side effect failed: access
denied: ...` while the state assertion still passes — the entire relay
validator could be deleted unnoticed. The new relay tests assert
`accepted == false` instead, the one observable only the validator
controls. Each new test is verified in both directions: green against
the real fix, failing with its intended message when its guard alone is
stubbed.
**Test runs** (at `9461eedb`):
- `buzz-db`, serial: **210 passed / 3 failed** — the same 3 failures as
clean `main` (202/3), which are pre-existing and unrelated
(`concurrent_same_owner_create…`,
`create_community_with_owner_is_atomic…`,
`test_usage_metrics_lock_has_single_owner…`). +8 = the new tests.
- `e2e_relay --ignored`: **40 passed / 3 failed**. Clean `main` on the
same relay is 35/6 — the same 3 infra failures
(`test_invite_mint_and_claim…`, `test_subscription_limit_enforced`,
`test_unarchive_emits_member_added_notification`) plus the 3 security
tests that fail unpatched and pass here.
- `cargo fmt`, `clippy`, `git diff --check` all clean.
**Live manual drive** against a locally running relay, using raw
`nak`-signed events (the `buzz` CLI refuses malformed `kind:9000`, so
the guards have to be exercised directly):
- *Rejected:* member demotes owner; member demotes admin; self-promote
to owner; self-promote to admin; admin demotes the last owner; sole
owner self-demote; demoted ex-owner demotes last owner; private-channel
member demotes owner; non-member demotes owner in private.
- *Allowed:* bare PUT_USER with no role tag (owner keeps role);
idempotent re-add at same role; owner promotes admin→owner, then owner2
legitimately demotes owner1.
- *Resurrection defeated:* owner promotes attacker to admin → kicks them
(`9001`) → attacker self-rejoins (`9021`) → returns as **member**, not
admin, and cannot demote the owner.
- Normal ops unaffected throughout: channel creation, messaging, member
listing, and legitimate governance all work.
## Behavior change to be aware of
Huddle bot-add sends `role="bot"`. If the target is **already an active
member at a different role**, that is now a role change and requires an
elevated actor. Previously it silently re-roled them — the same privesc
primitive through a different door, so narrowing it is intended.
This does not break the huddle flow in the path that matters: the
ephemeral channel add (the one that fails hard) is performed by the
host, who *created* that channel and is therefore its owner — verified
live. The parent-channel add is already explicitly best-effort,
capturing the error into `parent_error` with a comment anticipating "may
already be member"; adding a non-member agent there still works.
Flagging it rather than burying it.
## Notes
- Commit is **signoff-only, not cryptographically signed** — `-S` fails
in this environment (git tries to load the agent npub as an SSH key
file). DCO trailers are present and correct.
- Branch was merged with `origin/main` via `--no-ff` (not rebased).
Upstream had 17 commits, none touching these files, no migration
changes.
---------
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
|
||
|
|
ab3af82871 |
feat(relay): add author-only-unless-shared read gate for kind 30175 (#2768)
Kind 30175 persona sync events carry plaintext `system_prompt` and
`respond_to_allowlist`. This PR adds **author-only-unless-shared read
semantics**: events without `["shared","true"]` are visible only to the
author; events with that tag are community-readable.
## What changed
### New read class (kind 30175)
Kind 30175 gets per-event gating at every relay read surface. The
`shared` marker is a **tag**, not a content field, so content bytes
(which double as the `source_version` drift basis) are not affected when
toggling share state.
### `event_visible_to_reader` helper (`handlers/req.rs`)
Centralizes the three per-event access predicates —
`is_author_only_event`, `is_unshared_persona_event`,
`reader_authorized_for_event` — into one `pub(crate)` fn callable from
both WS and HTTP adapters. All result-visibility sites now call this
single helper.
### NIP-98 HTTP bridge (`api/bridge.rs`)
- `POST /query` catchall: replaced the two-step author-only +
result-gated checks with `event_visible_to_reader` (now also covers the
persona shared-gate).
- `POST /count`: added `needs_persona_filtering` to the fast-path guard
(forces per-event fallback when filter can match `kind:30175`) and
replaced both fallback loops' individual checks with
`event_visible_to_reader`.
- FTS `/search` bridge helper: replaced `is_author_only_event` with
`event_visible_to_reader` as defense-in-depth (30175 is not in the FTS
allowlist today; comment at site explains the future-proofing intent).
### Ingest validation (`handlers/ingest.rs`)
`validate_persona_envelope` rejects malformed `shared` tags: wrong
value, missing value, duplicates. Accepts exactly `["shared","true"]`
and tag-absent.
### Kind helpers (`buzz-core/src/kind.rs`)
`is_persona_shared_kind`, `is_unshared_persona_event`,
`filter_can_match_persona_shared_kinds`.
### Tests (`e2e_persona.rs`)
8 unit tests in `kind.rs`, 6 in `ingest.rs`, 8 e2e tests total:
- AC-1–6 covering the gated surfaces
- `test_persona_live_fanout_shared_gate`: reworked with explicit
monotonic `created_at` timestamps (t0 < t1 < t2) and per-step head
assertions, eliminating the NIP-33 event-id tie-break race. Also asserts
foreign live subscription receives nothing on shared→unshared
transition.
- `test_persona_ingest_shared_tag_validation`: added `shared=x` and
missing-value wire-level rejection cases.
- `test_persona_mixed_kind_filter_does_not_leak`: publishes a kind-9
event and asserts it IS returned; absence-only assertion no longer
sufficient.
- `test_persona_http_query_cross_author_gate`: NIP-98 `/query`
cross-author gate (authors filter, kindless `ids` — both blocked; shared
`ids` — passes).
- `test_persona_http_count_cross_author_gate`: NIP-98 `/count`
cross-author gate (foreign sees 1/shared, author sees all, wildcard
checked).
### NIP-AP.md
Replaced aspirational "every relay read chokepoint" wording with an
enumerated list of gated surfaces including NIP-98 `/query`, `/count`,
and FTS/search with their enforcement mechanism named. Added
**Non-goal** note for side-band existence oracles
(reaction/report/deletion target resolution).
## Existing tests
All pre-existing `e2e_persona` tests use `{ids:[event_id]}` or
`{authors:[self]}` filters — author self-reads bypass the gate and are
unaffected.
## Gates
`just check` ✅ | `just test-unit` ✅ | `cargo test -p buzz-relay` ✅ (749
passed, 1 pre-existing failure in
`demo_join_forwarded_arm_round_trips_echo` — flaky on `main`, unrelated
to this PR, verified red at `origin/main` before this branch)
---------
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
|
||
|
|
2e936d439c |
relay: skip TTL deadline bump for known-permanent channels (T1a write-amp) (#2125)
Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> |
||
|
|
ca384d082d |
fix: recover community access visibility (#2074)
Signed-off-by: Kalvin Chau <kalvin@block.xyz> Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@sprout-oss.stage.blox.sqprod.co> |
||
|
|
5cfd69cb0c |
Strip media metadata on clients and reject it at the relay (#2006)
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Atish Patel <atish@squareup.com> Co-authored-by: Codex <noreply@openai.com> |
||
|
|
648cbf3610 |
feat: add invite QR and mobile direct join (#1957)
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> |
||
|
|
54638ff4bb | mesh: upgrade runtime, enforce membership, add shared compute provider (#1656) | ||
|
|
916de4e4db |
Stabilize persona and stream end-to-end tests (#1865)
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co> |
||
|
|
dfec75b3c0 |
refactor(desktop): remove vestigial MCP toolsets config (#1776)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co> |
||
|
|
b2c63291f1 |
feat(relay,desktop): canonicalize agent definitions on kind:30175 (Phase 2) (#1655)
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> |
||
|
|
74a30c3de8 |
Live thread-summary push: badge counts update on reply ingest (#1521)
Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell <tlongwell@block.xyz> |
||
|
|
6428005487 |
fix: let agent owners delete their agent's messages (relay kind:5 + desktop/mobile UX) (#1519)
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> |
||
|
|
62bb9fe8c8 |
GUI read-model overhaul: server-assembled channel windows (Correct™ pagination + relay-signed bounds) (#1500)
Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> |
||
|
|
5bfd5ca270 |
feat: per-community workspace icon set by admins, served via NIP-11 (#1463)
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> |
||
|
|
0042c8e106 |
feat(relay): allow agent owners to edit/manage agent-owned content (#1403)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co> |
||
|
+1 |
14fba21e57 |
Multi-tenant Buzz relay: community_id as a server-resolved key (comprehensive rewrite) (#1321)
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Quinn <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: Sami <sami@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> |
||
|
|
73cc31cc52 |
chore: remove LLM-slop comments across the codebase (#1277)
Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Quinn <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Perci <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell <tlongwell@block.xyz> |
||
|
|
d256935c36 |
feat: publish personas, teams, and managed agents as relay events (#939)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Will Pfleger <pfleger.will@gmail.com> |
||
|
|
38a9533439 | [codex] Make relay frame limit configurable (#1225) | ||
|
|
6780ea21e4 |
fix(relay): resubscribe agents when an archived channel is restored (#1187)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co> |
||
|
|
26563968cb |
feat(relay): add NIP-ER push scheduler with cross-pod delivery (#957)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co> |
||
|
|
79fcfd82bc |
feat(relay): implement NIP-ER event reminder support (kind:30300) (#934)
Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Will Pfleger <wpfleger@squareup.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co> |
||
|
|
d99ad131f1 |
refactor: rename sprout backend to buzz (#958)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <wpfleger@block.xyz> Signed-off-by: Will Pfleger <wpfleger@squareup.com> Signed-off-by: Will Pfleger <wpfleger96@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1fgdl5qqnh3k3f2xkqrvt7cujalhm623x4s7fdjdj5yrtp5fzjl9qrjpucw <4a1bfa0013bc6d14a8d600d8bf6392efefbd2a26ac3c96c9b2a106b0d12297ca@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub16v54tttfqacx9ycvc3k0ut0npj564ahcuajzy6qjvh57ntmsf4uq4806j2 <d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Will Pfleger <wpfleger96@gmail.com> |