Skip to content

feat(v4): add area-scoped top-editors endpoint#86

Merged
escapedcat merged 3 commits into
masterfrom
feature/area-top-editors-rest
Apr 19, 2026
Merged

feat(v4): add area-scoped top-editors endpoint#86
escapedcat merged 3 commits into
masterfrom
feature/area-top-editors-rest

Conversation

@escapedcat

@escapedcat escapedcat commented Apr 17, 2026

Copy link
Copy Markdown
Collaborator

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:

  • 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

Summary by CodeRabbit

Release Notes

  • New Features

    • Introduced a new /top-editors API endpoint to view the most active editors within a specific area, with optional date range filtering and configurable result limits.
  • Improvements

    • Added validation for API parameters, including area ID length checks and editor count limits (1–1000 range).
    • Enhanced performance through optimization of spam/bot filtering mechanisms.

@coderabbitai

coderabbitai Bot commented Apr 17, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

The changes add area-scoped filtering to user activity queries by introducing a new select_most_active_for_area database function that joins with an area element table, exposing it through async wrappers and a corresponding REST v4 endpoint (GET {id}/top-editors). Supporting helper functions for validation and date parsing are also centralized and refactored.

Changes

Cohort / File(s) Summary
Database Layer – Area-Scoped Queries
src/db/main/osm_user/blocking_queries.rs, src/db/main/osm_user/queries.rs
Refactored select_most_active to use dynamic SQL parameter binding via ToSql trait. Added new select_most_active_for_area function with area join filter and async wrapper; both handle dynamic NOT IN (...) exclusions.
REST Endpoint – Area Top Editors
src/rest/v4/areas.rs
Added new GET {id}/top-editors endpoint with optional period_start, period_end, limit query parameters. Added input validation for id length (max 128 chars) to both new and existing get_by_id endpoint. Includes 3 integration tests.
Helper Functions & Shared Utilities
src/rest/v4/top_editors.rs
Centralized bot/spam filtering via EXCLUDED_USER_IDS constant. Promoted parse_date, validate_limit, far_future, and extract_tip_url to pub(crate) visibility. Optimized extract_tip_url with cached regex via OnceLock. Added validate_limit with range enforcement (1..=1000).
Route Registration
src/main.rs
Registered new Actix-Web endpoint rest::v4::areas::get_by_id_top_editors in v4 areas scope.

Sequence Diagram

sequenceDiagram
    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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • feature(area): acitvity feed json #79: Follows the same pattern of adding area-scoped database query functions with async wrappers and extending REST endpoints to use those area-filtered queries.

Suggested reviewers

  • bubelov

Poem

🐰 Hops of joy through areas new,
Top editors sorted by their view,
Queries dance with joins so grand,
Helpers shared across the land! 🌿✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 76.19% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately describes the main change: adding a new area-scoped endpoint for viewing top editors, which is the core objective of this changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/area-top-editors-rest

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

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>

This comment was marked as resolved.

escapedcat and others added 2 commits April 17, 2026 14:13
…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>
@escapedcat escapedcat marked this pull request as ready for review April 17, 2026 07:06
@escapedcat escapedcat requested a review from bubelov April 17, 2026 07:07

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 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<1 or limit>1000 via validate_limit, but there's no test covering that branch. A one-liner like requesting ?limit=0 and 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 the id.len() > 128 guard into a helper.

The same length-cap check is now repeated in both get_by_id and get_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 validating period_start <= period_end.

Today, passing period_start=2030-01-01&period_end=2020-01-01 is silently accepted and the SQL BETWEEN ?1 AND ?2 just returns an empty result. Returning 400 Invalid input is friendlier to clients and matches what most range-based endpoints do. Non-blocking — the global /v4/top-editors has 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: consider LazyLock to tighten this pattern.

On Rust 1.80+, std::sync::LazyLock lets you drop the explicit get_or_init boilerplate. Your project runs 1.93.0, so this is available. Non-blocking — current OnceLock form 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

📥 Commits

Reviewing files that changed from the base of the PR and between 2606688 and 81ebe7e.

📒 Files selected for processing (5)
  • src/db/main/osm_user/blocking_queries.rs
  • src/db/main/osm_user/queries.rs
  • src/main.rs
  • src/rest/v4/areas.rs
  • src/rest/v4/top_editors.rs

@escapedcat escapedcat merged commit e36979b into master Apr 19, 2026
2 checks passed
@escapedcat escapedcat deleted the feature/area-top-editors-rest branch April 19, 2026 04:26
escapedcat added a commit that referenced this pull request Apr 20, 2026
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>
escapedcat added a commit that referenced this pull request Apr 20, 2026
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>
escapedcat added a commit that referenced this pull request Apr 21, 2026
* 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>
escapedcat added a commit that referenced this pull request May 9, 2026
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>
escapedcat added a commit that referenced this pull request May 9, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants