feat(v4): add area-scoped top-editors endpoint#86
Conversation
📝 WalkthroughWalkthroughThe changes add area-scoped filtering to user activity queries by introducing a new Changes
Sequence DiagramsequenceDiagram
participant Client
participant Handler as HTTP Handler<br/>(get_by_id_top_editors)
participant Pool as Connection Pool
participant BlockingQueries as Blocking Queries
participant Database
Client->>Handler: GET /areas/{id}/top-editors?period_start=...&limit=...
Handler->>Handler: Parse & validate query params
Handler->>Handler: Look up area by id/alias
Handler->>Pool: Acquire database connection
Pool-->>BlockingQueries: Connection
BlockingQueries->>BlockingQueries: Build dynamic SQL params<br/>(excluded_ids NOT IN (...))
BlockingQueries->>Database: SELECT users with area join<br/>WHERE area_id=? AND deleted_at IS NULL<br/>ORDER BY edits DESC LIMIT ?
Database-->>BlockingQueries: Query results
BlockingQueries->>BlockingQueries: Map to Vec<SelectMostActive>
BlockingQueries-->>Handler: Results
Handler->>Handler: Map to Vec<TopEditor><br/>(extract tip_url)
Handler-->>Client: JSON response
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
GET /v4/areas/{id}/top-editors?period_start=&period_end=&limit=
Returns the same TopEditor shape as GET /v4/top-editors, but counts only
events whose target element belongs to the given area (joined through
area_element). The {id} segment accepts either the numeric area id or
the string alias (handled by select_by_id_or_alias, same as v3).
Why: the btcmap.org Activity tab on country/community pages currently
derives its "Supertaggers" list client-side by fetching osm_id for every
place in the area (one /v4/places/{id} per place — ~7940 requests per
visit on the US page) and matching against the global events feed. With
this endpoint the FE can replace all that with a single REST call.
Notes for the maintainer:
- period_start / period_end are optional here (default to an open range
via UNIX_EPOCH .. 2200-01-01). The FE has no period filter in its
current Supertaggers UI; nothing prevents callers from supplying
bounds though, and the format/parsing matches /v4/top-editors.
- The bot/spam blocklist (EXCLUDED_USER_IDS) is now declared once in
top_editors.rs and shared, so the two views can't drift apart.
- area_element rows with deleted_at IS NOT NULL are excluded from the
join — matches the convention in get_by_id_areas.
Tests:
- select_most_active_for_area unit test covers area scoping (out-of-area
events excluded), excluded_ids filter, and unknown-area returning 0.
- Three handler tests cover unknown-area 404, end-to-end scoping, and
the limit query param.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
839cbbf to
63a5d50
Compare
…gex cache
1. SQL injection: excluded_ids in select_most_active and
select_most_active_for_area were interpolated into SQL via string
formatting. Switched to numbered ?N placeholders with bound params
via Vec<Box<dyn ToSql>>. Prevents injection and enables statement
caching.
2. Limit validation: clamp limit to 1..=1000 and use saturating_add
for query_limit to prevent negative-limit DoS (SQLite treats
negative LIMIT as no limit) and i64 overflow. Applied to both
/v4/top-editors and /v4/areas/{id}/top-editors.
3. Deduplicate parse_date: areas.rs had a copy of the date-parsing
logic from top_editors.rs. Made top_editors::parse_date pub(crate)
and import it from areas.rs. Single source of truth.
4. far_future() silent fallback: replaced .unwrap_or(UNIX_EPOCH) with
.expect("constant date literal must parse") so a hypothetical
parse failure is caught loudly rather than silently returning an
empty date range.
5. Regex compiled once: extract_tip_url compiled Regex::new on every
call. Moved to a static OnceLock so it's compiled once and reused
across both endpoints.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…lias cap 1. Drop redundant query_limit: SQL WHERE NOT IN already excludes bot ids before LIMIT applies, so padding LIMIT by excluded.len() and then .take(limit) in Rust was a no-op. Pass limit directly to SQL, remove query_limit and .take() from both endpoints. 2. Move far_future to top_editors.rs: colocate with parse_date (same dedup principle from the previous round). Use time::macros::datetime! for compile-time validation — no runtime .expect() needed. 3. Alias length cap: reject id/alias strings > 128 chars with 400 in get_by_id and get_by_id_top_editors. Pre-existing gap, easy to close. 4. 400 for bad limit: replace .clamp(1, 1000) with explicit validation returning RestApiError::invalid_input for limit outside 1..=1000. Extracted as validate_limit() in top_editors.rs, shared by both endpoints. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (4)
src/rest/v4/areas.rs (3)
474-501: Optional: also assert the lower-bound validation path.The handler now returns 400 for
limit<1orlimit>1000viavalidate_limit, but there's no test covering that branch. A one-liner like requesting?limit=0and asserting 400 would lock in the contract and prevent regressions when the range is changed.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/rest/v4/areas.rs` around lines 474 - 501, Add a test case exercising the lower-bound validation for the top editors endpoint: in the existing test module (where top_editors_for_area_respects_limit is defined) add a new async test that initializes the same App with get_by_id_top_editors and sends a GET to "/{area.id}/top-editors?limit=0", then assert the response status is 400; this targets the validate_limit path invoked by get_by_id_top_editors to lock in the contract for limit<1.
112-114: Minor: extract theid.len() > 128guard into a helper.The same length-cap check is now repeated in both
get_by_idandget_by_id_top_editors. Pulling it into a tiny helper (e.g.validate_area_id_or_alias(&id)) would prevent the two sites from drifting and centralize the error message.♻️ Proposed refactor
+fn validate_id_or_alias(id: &str) -> Result<(), RestApiError> { + if id.len() > 128 { + return Err(RestApiError::invalid_input("id too long")); + } + Ok(()) +} + #[get("{id}")] pub async fn get_by_id(id: Path<String>, pool: Data<MainPool>) -> Res<GetByIdRes> { - if id.len() > 128 { - return Err(RestApiError::invalid_input("id too long")); - } + validate_id_or_alias(&id)?; ... } #[get("{id}/top-editors")] pub async fn get_by_id_top_editors(...) -> Res<Vec<TopEditor>> { - if id.len() > 128 { - return Err(RestApiError::invalid_input("id too long")); - } + validate_id_or_alias(&id)?; ... }Also applies to: 212-214
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/rest/v4/areas.rs` around lines 112 - 114, Extract the repeated length-check "id.len() > 128" into a small shared helper (e.g. validate_area_id_or_alias(&id)) and call it from get_by_id and get_by_id_top_editors; the helper should perform the guard and return Result<(), RestApiError> using the same RestApiError::invalid_input("id too long") message so both call sites share the exact validation and error text. Ensure the helper name is exported/visible to both functions and replace the inline checks in get_by_id and get_by_id_top_editors with a single call to validate_area_id_or_alias(&id).
222-229: Consider validatingperiod_start <= period_end.Today, passing
period_start=2030-01-01&period_end=2020-01-01is silently accepted and the SQLBETWEEN ?1 AND ?2just returns an empty result. Returning400 Invalid inputis friendlier to clients and matches what most range-based endpoints do. Non-blocking — the global/v4/top-editorshas the same gap, so if you want to fix it, do it in the shared helper so both endpoints benefit.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/rest/v4/areas.rs` around lines 222 - 229, The code accepts period_start and period_end (parsed via parse_date and defaulting to OffsetDateTime::UNIX_EPOCH / far_future) without checking their order; add validation after computing period_start and period_end (or in the shared helper used by both areas.rs and /v4/top-editors) to return a 400 Invalid input when period_start > period_end, using the same error type/response pattern your handlers use so clients receive a clear "period_start must be <= period_end" validation error instead of an empty SQL result.src/rest/v4/top_editors.rs (1)
103-108: Optional: considerLazyLockto tighten this pattern.On Rust 1.80+,
std::sync::LazyLocklets you drop the explicitget_or_initboilerplate. Your project runs 1.93.0, so this is available. Non-blocking — currentOnceLockform is correct.♻️ Proposed refactor
-static TIP_URL_RE: OnceLock<Regex> = OnceLock::new(); - -pub(crate) fn extract_tip_url(description: &str) -> Option<String> { - let re = TIP_URL_RE.get_or_init(|| Regex::new(r"(lightning:[^)]+)").unwrap()); - re.captures(description).map(|c| c[1].to_string()) -} +static TIP_URL_RE: std::sync::LazyLock<Regex> = + std::sync::LazyLock::new(|| Regex::new(r"(lightning:[^)]+)").unwrap()); + +pub(crate) fn extract_tip_url(description: &str) -> Option<String> { + TIP_URL_RE.captures(description).map(|c| c[1].to_string()) +}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/rest/v4/top_editors.rs` around lines 103 - 108, Replace the manual OnceLock/get_or_init pattern with std::sync::LazyLock: change the static TIP_URL_RE: OnceLock<Regex> to a static TIP_URL_RE: LazyLock<Regex> initialized with LazyLock::new(|| Regex::new(r"(lightning:[^)]+)").unwrap()), and update extract_tip_url to call the regex directly via TIP_URL_RE (e.g., TIP_URL_RE.captures(description).map(|c| c[1].to_string())); this removes the explicit get_or_init boilerplate while keeping the same behavior for TIP_URL_RE and extract_tip_url.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@src/rest/v4/areas.rs`:
- Around line 474-501: Add a test case exercising the lower-bound validation for
the top editors endpoint: in the existing test module (where
top_editors_for_area_respects_limit is defined) add a new async test that
initializes the same App with get_by_id_top_editors and sends a GET to
"/{area.id}/top-editors?limit=0", then assert the response status is 400; this
targets the validate_limit path invoked by get_by_id_top_editors to lock in the
contract for limit<1.
- Around line 112-114: Extract the repeated length-check "id.len() > 128" into a
small shared helper (e.g. validate_area_id_or_alias(&id)) and call it from
get_by_id and get_by_id_top_editors; the helper should perform the guard and
return Result<(), RestApiError> using the same RestApiError::invalid_input("id
too long") message so both call sites share the exact validation and error text.
Ensure the helper name is exported/visible to both functions and replace the
inline checks in get_by_id and get_by_id_top_editors with a single call to
validate_area_id_or_alias(&id).
- Around line 222-229: The code accepts period_start and period_end (parsed via
parse_date and defaulting to OffsetDateTime::UNIX_EPOCH / far_future) without
checking their order; add validation after computing period_start and period_end
(or in the shared helper used by both areas.rs and /v4/top-editors) to return a
400 Invalid input when period_start > period_end, using the same error
type/response pattern your handlers use so clients receive a clear "period_start
must be <= period_end" validation error instead of an empty SQL result.
In `@src/rest/v4/top_editors.rs`:
- Around line 103-108: Replace the manual OnceLock/get_or_init pattern with
std::sync::LazyLock: change the static TIP_URL_RE: OnceLock<Regex> to a static
TIP_URL_RE: LazyLock<Regex> initialized with LazyLock::new(||
Regex::new(r"(lightning:[^)]+)").unwrap()), and update extract_tip_url to call
the regex directly via TIP_URL_RE (e.g.,
TIP_URL_RE.captures(description).map(|c| c[1].to_string())); this removes the
explicit get_or_init boilerplate while keeping the same behavior for TIP_URL_RE
and extract_tip_url.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 33daee4b-cf74-4501-8887-9cf2e7d2eae5
📒 Files selected for processing (5)
src/db/main/osm_user/blocking_queries.rssrc/db/main/osm_user/queries.rssrc/main.rssrc/rest/v4/areas.rssrc/rest/v4/top_editors.rs
The activity feed endpoint was shipped without a corresponding doc (same as #86's top-editors). Adds docs/rest/v4/activity.md covering parameters (days / area / areas / places), response shape, examples, and error cases, plus a link in the v4 README under "Implemented". Includes the `?places=` parameter added in #87, so this branch should merge after that one. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The activity feed endpoint was shipped without a corresponding doc (same as #86's top-editors). Adds docs/rest/v4/activity.md covering parameters (days / area / areas / places), response shape, examples, and error cases, plus a link in the v4 README under "Implemented". Includes the `?places=` parameter added in #87, so this branch should merge after that one. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(v4): add places filter to /v4/activity endpoint Accepts ?places=id1,id2 alongside existing ?areas= and ?area= params. Place IDs union with area-derived element IDs via HashSet so events covered by both a saved area and an explicitly saved place inside it are deduped naturally. When places are provided, event and comment fetches switch from the per-area optimized query to a global query + post-filter by the combined element set; boost filtering already goes through in_filter (renamed from in_area). Motivation: the FE will introduce a /user/activity page combining a signed-in user's saved places and saved areas into one feed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(v4): update stale in_area comment to in_filter The helper was renamed when places were added to the element filter; the boost comment still referenced the old name. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(v4): reject unparseable places values with 400 Previously, ?places=foo silently dropped the invalid segment and fell through to an unfiltered global response — surprising behavior when the caller expects the filter to take effect. Switch filter_map(...ok()) to collect::<Result<_,_>>()? and return 400 Invalid Input on any non-integer segment. Mirrors how ?areas= 404s on an unknown area. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(v4): cap days window on /v4/activity at 3650 The endpoint is unauthenticated and select_created_between scans all events in the window. Without a ceiling on days, ?days=36500 forces a 100-year scan. Require 1 <= days <= 3650 (10y) and return 400 otherwise. 3650 leaves generous headroom for the area-feed UI's 30-day pagination (≈120 load-mores of headroom) while closing the unbounded-range DoS path; this also protects the pre-existing global fetch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(v4): cap places list length on /v4/activity at 500 An unbounded places list lets a caller build an arbitrarily large HashSet<i64> from query input. 500 covers realistic power-user saved lists (the planned /user/activity page on the FE); callers with more can fall back to saving the containing area. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(v4): parse places into HashSet to dedupe on input Addresses Copilot review on PR #87: - discussion_r3108284219: collect directly into HashSet<i64> rather than Vec then insert, removing the redundant conversion. - discussion_r3108284246: MAX_PLACES now caps unique IDs, so a request like places=1,1,1,... is no longer rejected on dup-inflated length. No behavior change for well-formed input; HashSet iteration order is non-deterministic but subsequent code only unions into another HashSet and final results are sorted by created_at. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(v4): cap places token count before parsing Addresses CodeRabbit review on PR #87 (discussion_r3108325028): enforce MAX_PLACES on the raw comma-separated token count before building the HashSet, so a pathological input can't allocate a large intermediate set before the guard rejects. Trade-off noted: this reverts the "unique IDs" framing from the prior commit. Legitimate clients (the planned /user/activity page joining session.savedPlaces) don't send duplicates, so a raw-token cap is correct in practice and closes the allocation window. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(v4): clarify places cap error message Addresses CodeRabbit nit on PR #87 (discussion_r3108598972): the cap is enforced on raw comma-separated tokens, but the error message said "IDs" which implies a uniqueness-aware limit. Switch to "comma-separated values" so the message matches what's actually counted. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(v4): document /v4/activity endpoint (#88) The activity feed endpoint was shipped without a corresponding doc (same as #86's top-editors). Adds docs/rest/v4/activity.md covering parameters (days / area / areas / places), response shape, examples, and error cases, plus a link in the v4 README under "Implemented". Includes the `?places=` parameter added in #87, so this branch should merge after that one. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per RFC 9110 §11.6.2, `credentials = auth-scheme 1*SP (...)` — one or
more SP is valid. split_once(' ') only handled exactly one. Replace
with split_whitespace + a "no trailing tokens" check so the extractor
accepts "Nostr <b64>" and "Nostr <b64>" consistently while still
rejecting malformed values like "Nostr b64 extra".
Addresses reviewer feedback on PR #86.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Add NIP-98 service module and NostrAuth extractor
First in a sequence of PRs adding Nostr auth. This lands the
plumbing in isolation — no endpoints wired up yet.
- `src/service/nip98.rs` encapsulates the `nostr` crate: base64
decode, kind/created_at/tag checks, Schnorr verification. Returns
the pubkey as bech32 (`npub1...`), matching the encoding used by
the existing `user.npub` column.
- `src/rest/nostr_auth.rs` defines `NostrAuth { npub: Option<String> }`
as an Actix-Web `FromRequest` extractor, mirroring the existing
`Auth` bearer extractor. Reads the `Authorization: Nostr <base64>`
header (case-insensitive scheme per RFC 9110), derives the expected
`u` tag from the actual request URL so a signature valid for one
endpoint cannot be replayed on another.
- Tests cover both modules: valid/invalid signatures, URL/method/kind
mismatches, recency drift, lowercase scheme, missing header,
Bearer-scheme rejection.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* nostr_auth: remove explicit drop(conn_info), scope it instead
Addresses reviewer feedback #4. The drop() was defensive — NLL
already handles the lifetime. Scoping the reference into a block
for URL construction is cleaner and yields identical behavior.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* nostr_auth: pin expected URL to config-provided ApiBaseUrl
Addresses reviewer feedback #2. Previously the extractor derived
the signed `u` tag's expected value from the request's connection
info, which trusts the `Host` and `X-Forwarded-*` headers. Those
are attacker-controlled on any deployment without a header-stripping
proxy in front.
An attacker who tricked a user into signing a NIP-98 event for
`http://evil.example/auth` could then replay the event against the
real server while spoofing `Host: evil.example`, and the
reconstructed URL would match — authenticating as the victim.
New design: an `ApiBaseUrl(pub String)` struct is expected in
app_data. The extractor pins the scheme+host from config and takes
only `path_and_query` from the request. Missing app_data is treated
as a fail-closed: `npub: None`, same pattern as the `Auth` bearer
extractor when the DB pool is absent.
A follow-up PR that wires the extractor into a handler will also
add the `main.rs` wiring (env-var-driven init of `ApiBaseUrl`).
Two new tests cover the new invariants:
- spoofed_host_header_is_ignored
- no_base_url_configured_yields_none
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* nostr_auth: log NIP-98 verification errors at debug level
Addresses reviewer feedback #3. Previously the `Err` arm of the
verify call silently mapped to `npub: None`, which makes triage
hard when an operator reports "Nostr login isn't working": there
was no signal distinguishing bad base64 from URL mismatch from
signature failure.
Keeps the extractor fail-closed at runtime (no behavior change)
but surfaces the reason at `tracing::debug!`, with the full URL and
method so operators can correlate against incoming request logs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* nip98: add test for event with tampered Schnorr signature
Addresses reviewer feedback #5. The existing tests covered
malformed input (bad base64, missing/wrong tags, wrong kind, stale
timestamp) but not the case of a structurally valid event whose
signature has been tampered with. This is the classic attack the
Schnorr verification is there to prevent, so it deserves a direct
assertion.
The test signs a real event, deserialises, flips one hex character
of `sig`, re-encodes, and confirms verify() rejects it. Exercises
the `event.verify()` call which the other tests never reach.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* nip98: document the missing request-body binding
Addresses reviewer feedback #1. NIP-98 defines an optional `payload`
tag that binds the signature to the SHA256 of the request body.
This module does not verify that tag today.
For the upcoming `POST /v4/auth/nostr` endpoint the body is empty,
so `payload` would always hash to a constant and add no security.
But future endpoints that do carry request bodies
(e.g. `PUT /v4/users/me/nostr`) must verify the tag to prevent body
swap attacks within the recency window.
This commit adds a prominent module-level doc comment so the next
PR author and reviewers cannot miss the limitation. No runtime
behavior change.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* nip98: tolerate multiple spaces between auth-scheme and credentials
Per RFC 9110 §11.6.2, `credentials = auth-scheme 1*SP (...)` — one or
more SP is valid. split_once(' ') only handled exactly one. Replace
with split_whitespace + a "no trailing tokens" check so the extractor
accepts "Nostr <b64>" and "Nostr <b64>" consistently while still
rejecting malformed values like "Nostr b64 extra".
Addresses reviewer feedback on PR #86.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* nostr_auth: fix port in ApiBaseUrl doc comment (8000, not 8080)
Trivial doc correction per maintainer note — the API runs on 8000
in dev, not 8080. Only the example URL in the doc comment; no code
paths affected.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* nostr_auth: use #[actix_web::test] explicitly on async tests
Plain #[test] resolves to actix-web's async-aware proc-macro here only
because `use actix_web::{test, ...}` brings it into the macro namespace
alongside the `test` module — a subtle shadow. Spelling the macro
out makes the dependency on actix's test runtime obvious to readers
and avoids relying on import order for correctness.
No behavior change; the futures were already being polled.
* nip98: reject duplicate u/method tags, tighten method check
Three review-driven changes in src/service/nip98.rs:
1. Reject duplicate u/method tags. NIP-98 doesn't explicitly require
uniqueness, but accepting duplicates would let a hostile frontend
construct an event with [u: real, u: decoy] that the user signs in
one click and the verifier accepts via the matching one. Replaces
find_tag_value with require_unique_tag, which surfaces a clear
"Duplicate '<name>' tag" error.
2. Make the method-tag comparison case-sensitive. RFC 9110 §9.1
defines HTTP method tokens as case-sensitive, and NIP-98 says the
tag value MUST be the same HTTP method. All standard methods are
uppercase, so the strict comparison costs nothing in practice and
avoids a needless deviation from the relevant specs. The previous
method_match_is_case_insensitive test is replaced with its inverse.
3. Tighten tampered_signature_rejected to assert
"Signature verification failed" only. The earlier assertion
accepted "Invalid Nostr event JSON" too, on the (incorrect)
reasoning that event.verify()'s id-hash check might reject first.
The Nostr event id is sha256 over (pubkey, created_at, kind, tags,
content) and does not include the signature, so mutating only sig
leaves the id check intact and the rejection must come from the
Schnorr step. Locking the assertion proves Schnorr actually runs.
Plus four new tests: missing_u_tag, missing_method_tag,
duplicate_u_tag, duplicate_method_tag.
* nip98: cargo fmt — collapse two assert! macros to single line
rustfmt rule: assert! with macro args that fit on one line shouldn't
span multiple lines. Pure formatting; no behavior change.
(fixup for a99e646 — squash on next rebase if desired)
* nostr: address Copilot doc-comment feedback
Two doc-only fixes from Copilot review on PR #84:
1. nip98::verify doc list said "(case-insensitive)" for the method tag
match — left over from before a99e646 tightened the comparison to
strict equality. Update to "(case-sensitive, per RFC 9110 §9.1)" so
the doc, the implementation, and the method_match_is_case_sensitive
test all agree.
2. NostrAuth's "fail closed" comment was internally contradictory:
yielding npub: None doesn't fail closed, it delegates to the
handler — handlers that treat None as anonymous fail open. Reword
to describe what the extractor actually does and call out the
handler's responsibility.
No behavior change.
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Related to: teambtcmap/btcmap.org#927
Desc
GET /v4/areas/{id}/top-editors?period_start=&period_end=&limit=
Returns the same TopEditor shape as GET /v4/top-editors, but counts only events whose target element belongs to the given area (joined through area_element). The {id} segment accepts either the numeric area id or the string alias (handled by select_by_id_or_alias, same as v3).
Why: the btcmap.org Activity tab on country/community pages currently derives its "Supertaggers" list client-side by fetching osm_id for every place in the area (one /v4/places/{id} per place — ~7940 requests per visit on the US page) and matching against the global events feed. With this endpoint the FE can replace all that with a single REST call.
Notes for the maintainer:
Tests:
🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes
New Features
/top-editorsAPI endpoint to view the most active editors within a specific area, with optional date range filtering and configurable result limits.Improvements