feat(relay): add admin HTTP routes for member restriction management - #7302
Conversation
🔐 Codex Security Review
Review SummaryOverall Risk: NONE
FindingsNo concrete security, correctness, or reliability findings were identified. Notes
Generated by Codex Security Review | |
Adds three routes to the /api/admin/v1 plane that expose the existing
ban/timeout DB layer (moderation.rs) via HTTP — completing the admin
surface for un-ban, un-timeout, and restriction listing:
GET /members/restrictions?communityId={uuid}
DELETE /members/{pubkey}/ban?communityId={uuid}
DELETE /members/{pubkey}/timeout?communityId={uuid}
The DB methods (list_community_restrictions, unban_community_member,
untimeout_community_member) and the community_bans schema already exist
from Phase 1; these routes are the only missing piece. The WebSocket
paths (kind 9041 / 9043) continue to work unchanged.
Route contract:
- All three require admin auth (GET: read-only, accepted in both nip98
and disabled modes; DELETE: require_mutation_principal).
- communityId resolves directly to CommunityId::from_uuid — no
host-lookup needed; the admin plane is already operator-scoped.
- Mutations write an audit row (action: unban / untimeout) and return
204 on success, 409 when no active restriction exists for the target.
- Pubkey path params are validated as 64-char hex and reject with 404
on malformed input (consistent with the operators route convention).
Response shape: MemberRestrictionRecord maps BanRecord fields to a
camelCase JSON envelope with Vec<u8> pubkeys hex-encoded as strings.
BanRecord remains a pure DB row type with no Serialize derive.
Tests:
- restriction_record_converts_ban_record_pubkeys_to_hex: pure unit test
pinning the hex-encoding and field mapping of BanRecord → JSON.
- list_restrictions_rejects_missing_credential: GET without credential
returns 401 (no DB access).
- unban_member_rejects_missing_credential: DELETE /ban without
credential returns 401 (no DB access).
- untimeout_member_rejects_missing_credential: DELETE /timeout without
credential returns 401 (no DB access).
- unban_member_returns_409_when_no_active_ban: signed DELETE against a
community with no ban row returns 409 [requires Postgres].
- untimeout_member_returns_409_when_no_active_timeout: signed DELETE
against a community with no timeout row returns 409 [requires Postgres].
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Three issues corrected in the admin restriction management routes:
IMPORTANT 1 — audit must be atomic with the lift.
Both DELETEs previously committed the ban/timeout lift (unban_community_member /
untimeout_community_member) and then inserted the audit row in a separate
autocommit. An audit-insert failure after a successful lift produced a dishonest
state: enforcement changed, NIP-98 replay consumed, and a fresh retry would get
409 — an audit-contract violation (VISION_MODERATION.md).
Fix: two new transactional helpers in buzz-db — unban_member_with_audit and
untimeout_member_with_audit — run the conditional-lift UPDATE and the
moderation_actions INSERT in one SQL transaction. If the INSERT fails, the UPDATE
rolls back and the restriction stays active. The HTTP handlers now call the
single transactional method instead of two separate autocommits.
The Db trait exposes two new wrapper methods:
unban_community_member_with_audit
untimeout_community_member_with_audit
MINOR — expired-ban predicate.
unban_member (both the free function and its Db wrapper) used
WHERE banned = true rather than the active-ban predicate used by all read
paths (banned AND (ban_expires_at IS NULL OR ban_expires_at > now())).
An expired ban would return 204 + audit instead of the agreed 409.
Fix: corrected WHERE clause in unban_member and both transactional helpers.
IMPORTANT 2 — tests now bind the success contracts.
New Postgres-gated tests (all #[ignore = "requires Postgres"]):
In buzz-relay api::admin:
- list_restrictions_returns_active_bans_and_timeouts: seeds ban+timeout,
GET returns both as JSON with correct fields.
- unban_member_returns_204_clears_ban_and_inserts_audit: seeds active ban,
DELETE returns 204, ban cleared, audit row has actor/target/authority,
other community's ban untouched (tenant isolation).
- untimeout_member_returns_204_clears_timeout_and_inserts_audit: same
pattern for timeout.
- unban_member_returns_409_for_expired_ban: expired ban → 409, no audit row
inserted (rollback evidence via HTTP path).
In buzz-db store/moderation:
- unban_with_audit_rolls_back_lift_when_audit_insert_fails: calls
unban_member_with_audit with an invalid actor_authority to trigger the DB
CHECK constraint on moderation_actions.actor_authority, verifies Err
returned and ban remains active (direct rollback proof).
Non-blocking: GET /members/restrictions is unbounded (no pagination). Acceptable
for the rostered-admin surface now; noted as known follow-up in PR body.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ependence and community isolation Restriction independence: seed the target with both a ban and an active timeout; assert that unban clears only the ban (timeout survives), and that untimeout clears only the timeout (ban survives). A regression widening either UPDATE to clear both restrictions would fail the new co-existing-restriction assertion. Community isolation for untimeout: seed the same pubkey with an active timeout in a second community; assert it stays active after the untimeout. A regression dropping the community_id = $1 predicate would clear the second-community timeout and fail the new cross-community assertion. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
af24d21 to
ec81e97
Compare
…dant migrate in tests Seven clippy::unnecessary_map_or errors in tests added by this PR: - map_or(true, |r| !r.banned) → is_none_or(|r| !r.banned) - map_or(false, |t| t > ...) (×3) → is_some_and(|t| t > ...) - map_or(false, |r| r.banned) (×2) → is_some_and(|r| r.banned) - map_or(true, |r| r.muted_until.map_or(true,…)) → is_none_or(…is_none_or…) Six db.migrate() calls removed from the new Postgres-backed tests. The postgres-test framework (postgres-test-setup.sh) builds the per-test database from schema/schema.sql via pgschema, not by running sqlx migrations. Calling db.migrate() on a desired-state clone fails with 42710 "type channel_type already exists" because migration 0001 attempts to re-create types already present in the schema. The per-test clone is fully migrated before the test binary runs; no explicit migration call is required. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…test fixtures The 4 tests that insert ban/timeout fixtures via db.ban_community_member or db.timeout_community_member were constructing a community_uuid = Uuid::new_v4() and calling db.ensure_configured_community(&host) — which inserts a community row with a DB-generated UUID that is NOT community_uuid. Inserting into community_bans then triggers enforce_community_write_fence, which queries communities WHERE id = community_uuid, finds nothing, and raises 'community ... is missing'. Fix: capture the EnsuredCommunityRecord returned by ensure_configured_community and use its .id (a CommunityId backed by the DB-assigned UUID) as the community throughout each test. Shadow community_uuid with *community.as_uuid() where the UUID is needed in URL format strings. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
|
P1 — bound and paginate the new restriction-list endpoint Verified at exact head
This conflicts with the repository rule to “Bound every resource, loop, and process tree” ( Please add:
Severity: P1. This endpoint is NIP-98 roster-gated, so this is a remotely repeatable availability cost rather than a demonstrated confidentiality or data-loss issue. The authorization, tenant predicates, transactional lift-and-audit behavior, active/expired semantics, and independent ban/timeout handling otherwise looked sound at this SHA. |
Add a capped `limit` query param (default and max 200), stable
keyset pagination (`ORDER BY updated_at DESC, pubkey DESC` with a
compound keyset predicate), and a `{ items, nextCursor }` response
envelope to `GET /api/admin/v1/members/restrictions`.
The tie-breaker on `pubkey` (BYTEA) makes the cursor deterministic
when multiple rows share the same `updated_at`. The opaque cursor
token is base64url of `{updated_at_micros}_{pubkey_hex}`.
The existing `list_community_restrictions` (unbounded) is kept
intact for the embedded dashboard bridge consumer
(`GET /moderation/restricted`), which returns a bare JSON array
whose response contract must not change.
New PostgreSQL regression test seeds 5 rows, forces two to share an
identical `updated_at` to exercise the tie-breaker, walks pages of
size 2 to exhaustion, and asserts exactly-once coverage.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The route-level default must be 200 (the max) — the shared `limit()` helper defaults to 50, which is wrong for this route. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Upgrade list_restrictions_pagination_exhaustive to be falsifiable on the default/max cap: - Seed 201 rows: 199 with distinct timestamps (newest first) + a tied pair at an identical older timestamp pinned to sort positions 200-201, straddling the default 200-row page boundary. - First route request omits limit: asserts exactly 200 items + a non-null nextCursor. Reverting the one-liner to limit(query.limit)? produces 50 instead of 200, failing this assertion. - Asserts limit=201 -> 400. - Walks remaining pages; asserts exactly-once coverage over all 201 pubkeys. The tied pair at positions 200-201 deterministically exercises the tie-breaker at the page boundary. Also fix RestrictionsPage.next_cursor field doc: null only when the returned page is smaller than limit; an exactly full final page emits a non-null cursor and the next request returns the empty terminal page. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…n test The tied-pair comment incorrectly claimed 0xE1/0xE2 sort "below" 0x01..0xC7 under pubkey DESC (they sort above). The older timestamp is what pins them to positions 200/201. Correct and clarify the comment; note the pair's internal order under pubkey DESC. PR body Tests section updated to describe the shipped 201-row / default-200 boundary test. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Source review clear
Reviewed head 86a5ebb506f57b4bc400488204ff34f6f2e2ebb0 against base 75f101d8b4f5b4b26f9891b73b87c67fedac25a0. No actionable code, product, or security blocker found. This is a review comment, not approval.
- Contract and authority: these routes extend the existing deployment-admin HTTP plane. The shared NIP-98 gate binds method and full query-bearing URL, resolves deployment Operator/Moderator authority, and retains Host/Origin and replay checks. Both DELETEs reject disabled-mode mutations; community-local roles do not grant deployment access.
- State and compatibility: traced both lifts through the scoped conditional UPDATE and atomic audit INSERT, schema action/authority vocabularies, and the admission/write consumers of the same restriction rows. Ban and timeout remain independent; absent/expired restrictions do not produce a lift audit. The existing community command path and
/moderation/restrictedbare-array response remain compatible. Keyset order, strict continuation predicate, timestamp precision, and the documented extra empty page after an exactly-full final page agree. Pagination is not a snapshot across concurrent changes. - Validation limits: reviewed the changed source and tests, exact-base product/architecture/testing guidance, and independent authorization and DB review lanes. No PR code was checked out, built, tested, imported, or executed; runtime and CI outcomes are not certified by this review. Client UI and lift-notification behavior were not changed here. Additional route-specific malformed-cursor/disabled-mode tests and concurrent-lift coverage would strengthen the suite, but source review found no concrete defect to block on.
* origin/main: (81 commits) fix(mobile): avoid opening empty threads on message tap (#7756) fix(workflows): make deletion persistent and retryable (#7735) fix(mobile): preserve thread replies through refresh failures (#7757) fix(mobile): keep iOS message menu actions responsive after rebuilds (#7758) fix(relay): exclude ephemeral activity from message quota (#7736) release: push gateway chart 0.3.1 (#7749) fix(push): label plaintext push gateway service as HTTP (#7717) Replace personal and internal data in desktop test fixtures (#7748) Add mobile VISION (#7710) fix(mobile): keep relay sessions stable during push lease updates (#7745) fix(desktop): keep managed agent avatars usable across communities (#7732) fix(mobile): fail open when age checks are unavailable (#7714) fix(ci): don't run desktop tests for purely mobile client changes (#7709) fix(mobile): temporarily disable age gating (#7708) feat(db): expose connection setup metrics (#7286) Isolate S3 storage metrics from the relay (#7543) fix(web): route mobile invite downloads to app stores (#7702) feat(mobile): show build number with version in settings (#7697) release(mobile-infra): buzz-push-gateway 0.3.0 (#7685) Add authenticated WebSocket recovery telemetry (#7546) ... Signed-off-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
The pagination loop in the restrictions cursor test used a `loop { match
... None => break }` pattern that clippy -D warnings flags as
`clippy::while_let_loop`. Rewrote it as a `let ... else { break }` form,
which is idiomatic and clippy-clean.
This was the root cause of the four red CI lanes (Rust Lint + Windows
Rust on both the PR and a secondary run) at the prior head.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…et_loop Loop body and termination semantics are identical to the prior loop/let-else form; this is the idiomatic Rust pattern clippy recommends. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Source review clear
Reviewed head 319c97874b4f1c9c51bd75e70da93bb91e7fa652 against base a61239f0d8036aff58176f5c0ce7f080c66e21b7, carrying forward the prior source clearance at 86a5ebb506f57b4bc400488204ff34f6f2e2ebb0. No actionable regression found. This is a review comment, not approval.
- Delta: one main merge plus two test-only lint fixes. The net branch change is a pagination-test loop rewritten as
while let; cursor advancement, termination guard, page bound, and exhaustive/tie-break assertions remain intact. Production code in the two PR-owned files is unchanged since the prior clearance, not production code throughout the merged repository. - Integration: checked main’s changed NIP-98 verifier, ban-enforcement state transition, test-state setup, schema, and legacy restriction consumers against the existing contract. Deployment authority and disabled-mode mutation rejection, community-scoped atomic lift/audit, independent ban/timeout state, bounded keyset pagination, and the legacy bare-array response remain compatible. Pagination remains intentionally non-snapshot; no new UI or lift-notification requirement is imposed.
- Evidence and limits: source-only review on the pinned Blox host with exact-base product/testing/architecture guidance and an independent metadata lane. No PR code was checked out, built, tested, imported, or executed. The exact-head CI snapshot at 2026-09-21 18:02:44Z had 54 successful and 26 skipped checks, with none failing or pending. Existing optional coverage suggestions are not promoted into new blockers.
…n-surface * origin/main: feat(relay): add admin HTTP routes for member restriction management (#7302) fix(relay): fire kick live side effects at convergence; persist target; fence re-add race with held lock (#7298) feat(relay): add atomic complete read-state snapshots (#7572) fix(desktop): register macOS badges for new and existing installs (#7783) fix(mobile): avoid opening empty threads on message tap (#7756) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
…ecurity * origin/main: (22 commits) feat(relay): add admin HTTP routes for member restriction management (#7302) fix(relay): fire kick live side effects at convergence; persist target; fence re-add race with held lock (#7298) feat(relay): add atomic complete read-state snapshots (#7572) fix(desktop): register macOS badges for new and existing installs (#7783) fix(mobile): avoid opening empty threads on message tap (#7756) fix(workflows): make deletion persistent and retryable (#7735) fix(mobile): preserve thread replies through refresh failures (#7757) fix(mobile): keep iOS message menu actions responsive after rebuilds (#7758) fix(relay): exclude ephemeral activity from message quota (#7736) release: push gateway chart 0.3.1 (#7749) fix(push): label plaintext push gateway service as HTTP (#7717) Replace personal and internal data in desktop test fixtures (#7748) Add mobile VISION (#7710) fix(mobile): keep relay sessions stable during push lease updates (#7745) fix(desktop): keep managed agent avatars usable across communities (#7732) fix(mobile): fail open when age checks are unavailable (#7714) fix(ci): don't run desktop tests for purely mobile client changes (#7709) fix(mobile): temporarily disable age gating (#7708) feat(db): expose connection setup metrics (#7286) Isolate S3 storage metrics from the relay (#7543) ... Signed-off-by: Alex Rosenzweig <arosenzweig@squareup.com>
…ness-overload * origin/main: (74 commits) feat(relay): add admin HTTP routes for member restriction management (#7302) fix(relay): fire kick live side effects at convergence; persist target; fence re-add race with held lock (#7298) feat(relay): add atomic complete read-state snapshots (#7572) fix(desktop): register macOS badges for new and existing installs (#7783) fix(mobile): avoid opening empty threads on message tap (#7756) fix(workflows): make deletion persistent and retryable (#7735) fix(mobile): preserve thread replies through refresh failures (#7757) fix(mobile): keep iOS message menu actions responsive after rebuilds (#7758) fix(relay): exclude ephemeral activity from message quota (#7736) release: push gateway chart 0.3.1 (#7749) fix(push): label plaintext push gateway service as HTTP (#7717) Replace personal and internal data in desktop test fixtures (#7748) Add mobile VISION (#7710) fix(mobile): keep relay sessions stable during push lease updates (#7745) fix(desktop): keep managed agent avatars usable across communities (#7732) fix(mobile): fail open when age checks are unavailable (#7714) fix(ci): don't run desktop tests for purely mobile client changes (#7709) fix(mobile): temporarily disable age gating (#7708) feat(db): expose connection setup metrics (#7286) Isolate S3 storage metrics from the relay (#7543) ... Signed-off-by: tornquist <tornquist@squareup.com>
…rcement * origin/main: (87 commits) feat(relay): add admin HTTP routes for member restriction management (#7302) fix(relay): fire kick live side effects at convergence; persist target; fence re-add race with held lock (#7298) feat(relay): add atomic complete read-state snapshots (#7572) fix(desktop): register macOS badges for new and existing installs (#7783) fix(mobile): avoid opening empty threads on message tap (#7756) fix(workflows): make deletion persistent and retryable (#7735) fix(mobile): preserve thread replies through refresh failures (#7757) fix(mobile): keep iOS message menu actions responsive after rebuilds (#7758) fix(relay): exclude ephemeral activity from message quota (#7736) release: push gateway chart 0.3.1 (#7749) fix(push): label plaintext push gateway service as HTTP (#7717) Replace personal and internal data in desktop test fixtures (#7748) Add mobile VISION (#7710) fix(mobile): keep relay sessions stable during push lease updates (#7745) fix(desktop): keep managed agent avatars usable across communities (#7732) fix(mobile): fail open when age checks are unavailable (#7714) fix(ci): don't run desktop tests for purely mobile client changes (#7709) fix(mobile): temporarily disable age gating (#7708) feat(db): expose connection setup metrics (#7286) Isolate S3 storage metrics from the relay (#7543) ... Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com> # Conflicts: # crates/buzz-auth/src/nip98.rs # crates/buzz-relay/Cargo.toml
Two fixes required after merging origin/main: 1. crates/buzz-relay/src/api/bridge.rs: restore pub(crate) visibility on verify_bridge_auth and verify_bridge_auth_with_options. PR #7302 added git/settings.rs as a new crate-internal caller; our F2 commit had narrowed both to private, breaking the build. Docstring updated to reflect the broader crate-visible contract. 2. crates/buzz-auth/src/nip98.rs: correct payload tag no-content contract. Our F1 implementation treated a one-element ["payload"] tag (no content) as "no binding" (None), but main's test contract and the NIP-98 spec treat a present-but-empty tag as a structurally invalid claim that must be rejected. Updated implementation to reject empty/missing hash on a present tag. Removed the incorrect payload_tag_no_content_treated_as_absent test; main's payload_tag_without_hash_rejected_with_body covers this case. Updated section comment to reflect the corrected contract. Both tests that now fail (demo_join_forwarded_arm_round_trips_echo, trace_context_lookup_does_not_enable_callsites) are pre-existing failures present before this PR's changes — verified against prior merge base. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…-history * origin/main: feat(relay): add admin HTTP routes for member restriction management (#7302) fix(relay): fire kick live side effects at convergence; persist target; fence re-add race with held lock (#7298) feat(relay): add atomic complete read-state snapshots (#7572) fix(desktop): register macOS badges for new and existing installs (#7783) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
…-dev * origin/main: fix(desktop): bound startup request bursts and recover quota refusals (#7790) fix(audit): frame hash inputs with TLV (#7492) fix(admin): allow cold storage worker DB startup (#7770) feat(relay): add admin HTTP routes for member restriction management (#7302) fix(relay): fire kick live side effects at convergence; persist target; fence re-add race with held lock (#7298) feat(relay): add atomic complete read-state snapshots (#7572) fix(desktop): register macOS badges for new and existing installs (#7783) fix(mobile): avoid opening empty threads on message tap (#7756) fix(workflows): make deletion persistent and retryable (#7735) fix(mobile): preserve thread replies through refresh failures (#7757) fix(mobile): keep iOS message menu actions responsive after rebuilds (#7758) fix(relay): exclude ephemeral activity from message quota (#7736) release: push gateway chart 0.3.1 (#7749) fix(push): label plaintext push gateway service as HTTP (#7717) Replace personal and internal data in desktop test fixtures (#7748) Add mobile VISION (#7710) fix(mobile): keep relay sessions stable during push lease updates (#7745) Signed-off-by: Tom Brow <tomb@block.xyz>
What
Three admin HTTP routes under
GET|DELETE /api/admin/v1/members/...:GET /members/restrictions?communityId=<uuid>[&limit={1-200}][&cursor=<token>]— lists active bans and timeouts with stable keyset paginationDELETE /members/{pubkey}/ban?communityId=<uuid>— lifts an active ban; 409 if no active restrictionDELETE /members/{pubkey}/timeout?communityId=<uuid>— lifts an active timeout; 409 if no active restrictionAuth
All three routes pass through the existing NIP-98 authorization gate. Both DELETEs call
require_mutation_principal; GET follows the established read-authorization pattern.Atomicity
Both DELETE routes use transactional DB helpers (
unban_member_with_audit/untimeout_member_with_audit) that perform the conditional lift UPDATE and themoderation_actionsINSERT in one SQL transaction. If the audit INSERT fails the lift rolls back; the restriction stays active.Expired-ban predicate
unban_memberWHERE clause requiresbanned AND (ban_expires_at IS NULL OR ban_expires_at > now())— matches all read paths. An already-expired ban returnsfalse→ 409.Pagination (GET /members/restrictions)
GET /members/restrictionsreturns{ "items": [...], "nextCursor": "<token>"|null }.limit— page size 1–200 (default 200), enforced as a SQLLIMIT. Returns 400 for values outside range.cursor— opaque continuation token from a prior page'snextCursor. Omit for the first page.ORDER BY updated_at DESC, pubkey DESC. Thepubkeytie-breaker ensures a deterministic cursor even when multiple rows share the sameupdated_at.{updated_at_micros}_{pubkey_hex}. Treat as opaque.nextCursorisnullonly when the returned page is smaller thanlimit. An exactly-full final page emits a non-null cursor; the subsequent request returns an empty page withnull.list_community_restrictionsDB method (unbounded) is kept intact for the embedded dashboard bridge consumer (GET /moderation/restricted), whose bare-array response contract must not change.Tests
All Postgres-gated tests are in the
postgres_testsmodule (api/admin/mod.rs):list_restrictions_returns_active_bans_and_timeouts— seeds a ban + timeout, GET returns both;nextCursoris null when all records fit in one pagelist_restrictions_pagination_exhaustive— seeds 201 rows: 199 with distinct timestamps (1s apart, newest first) plus a tied pair sharing an identical older timestamp (now()-1000s), pinned to sort positions 200/201 so the default-200 page boundary splits the tie. Asserts: (1)limit=201→ 400; (2) a no-limitrequest returns exactly 200 items + non-nullnextCursor(falsifiable binding of default=200 and SQL cap — reverting the route default to the shared helper's 50 fails this assertion); (3) continuation walk to exhaustion with exactly-once coverage over all 201 pubkeys, exercising the tie-breaker at the page boundary.unban_member_returns_204_clears_ban_and_inserts_audit— 204, ban cleared, timeout survives (restriction independence), audit row verified, tenant isolation confirmeduntimeout_member_returns_204_clears_timeout_and_inserts_audit— 204, timeout cleared, ban survives (restriction independence), second-community timeout survives (community_id predicate), audit row verifiedunban_member_returns_409_for_expired_ban— expired ban → 409, no audit rowunban_with_audit_rolls_back_lift_when_audit_insert_fails(buzz-db) — forced CHECK constraint failure proves Err + ban still active (rollback proof)