Skip to main content

nostr_bbs_auth_worker/
lib.rs

1//! nostr-bbs Auth Worker (Rust)
2//!
3//! WebAuthn registration/authentication + NIP-98 verification + pod provisioning.
4//! Port of `workers/auth-api/index.ts` (510 lines).
5//!
6//! ## Architecture
7//!
8//! - `lib.rs` -- Router, CORS, entry point
9//! - `webauthn.rs` -- WebAuthn registration + authentication handlers
10//! - `pod.rs` -- Pod provisioning and profile retrieval
11//! - `auth.rs` -- NIP-98 verification wrapper
12
13// Worker entry points are invoked via wasm-bindgen and appear unused in native builds.
14#![allow(dead_code)]
15
16mod admin;
17mod admins;
18mod auth;
19mod crypto;
20mod delegation;
21mod did;
22mod governance_api;
23mod http;
24mod invites;
25mod moderation;
26mod pod;
27mod schema;
28mod username;
29mod webauthn;
30mod welcome;
31mod wot;
32
33use worker::*;
34
35/// Build CORS headers from the `EXPECTED_ORIGIN` env var.
36fn cors_headers(env: &Env) -> Headers {
37    let origin = env
38        .var("EXPECTED_ORIGIN")
39        .map(|v| v.to_string())
40        .unwrap_or_else(|_| "https://example.com".to_string());
41
42    let headers = Headers::new();
43    headers.set("Access-Control-Allow-Origin", &origin).ok();
44    headers
45        .set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
46        .ok();
47    headers
48        .set(
49            "Access-Control-Allow-Headers",
50            "Content-Type, Authorization",
51        )
52        .ok();
53    headers.set("Access-Control-Max-Age", "86400").ok();
54    headers
55}
56
57/// Create a JSON error response that NEVER masks failures with an empty 200.
58///
59/// If the primary `Response::ok` builder fails (it never has in practice),
60/// fall through to `Response::error(...)` and unwrap with `expect` so any
61/// actual breakage is visible at the runtime layer instead of being silently
62/// turned into a 200 with an empty body.
63fn error_response(env: &Env, message: &str, status: u16) -> Response {
64    let body = format!(r#"{{"error":"{}"}}"#, message.replace('"', r#"\""#));
65    let cors = cors_headers(env);
66    match Response::ok(&body) {
67        Ok(resp) => {
68            let resp = resp.with_status(status).with_headers(cors);
69            resp.headers().set("Content-Type", "application/json").ok();
70            resp
71        }
72        Err(_) => {
73            // Hard error path. `Response::error` only fails if the static
74            // string allocation fails, which would mean the worker is in a
75            // worse state than this branch can reasonably recover from.
76            Response::error("internal", 500).expect("static error response")
77        }
78    }
79}
80
81/// Create a JSON response with CORS headers.
82fn json_response(env: &Env, body: &serde_json::Value, status: u16) -> Result<Response> {
83    let json_str = serde_json::to_string(body).map_err(|e| Error::RustError(e.to_string()))?;
84    let cors = cors_headers(env);
85    let resp = Response::ok(json_str)?
86        .with_status(status)
87        .with_headers(cors);
88    resp.headers().set("Content-Type", "application/json").ok();
89    Ok(resp)
90}
91
92/// Attach CORS headers to an existing response.
93fn with_cors(resp: Response, env: &Env) -> Response {
94    let cors = cors_headers(env);
95    resp.with_headers(cors)
96}
97
98#[event(fetch)]
99async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> {
100    // Wrap the entire handler so NO errors ever leak to the workers-rs framework.
101    // The framework formats leaked errors as raw Debug text (e.g. SerializationError(...))
102    // which is not valid JSON and confuses clients.
103    match handle_request(req, &env).await {
104        Ok(resp) => Ok(resp),
105        Err(e) => {
106            console_error!("Unhandled worker error: {e:?}");
107            Ok(error_response(&env, "Internal server error", 500))
108        }
109    }
110}
111
112/// Inner request handler. All errors are caught by the outer `fetch` wrapper.
113async fn handle_request(mut req: Request, env: &Env) -> Result<Response> {
114    // Apply the idempotent schema bootstrap on every cold start so newly
115    // added tables (moderation_actions, mod_reports, wot_entries, members,
116    // invitations, invitation_redemptions, instance_settings) exist before
117    // any handler tries to use them. Failures are swallowed inside.
118    schema::ensure_schema(env).await;
119    nostr_bbs_rate_limit::ensure_replay_schema(env, "DB").await;
120
121    // CORS preflight
122    if req.method() == Method::Options {
123        return Ok(Response::empty()?
124            .with_status(204)
125            .with_headers(cors_headers(env)));
126    }
127
128    // Rate limit: 20 requests per 60 seconds per IP
129    let ip = nostr_bbs_rate_limit::client_ip(&req);
130    if !nostr_bbs_rate_limit::check_rate_limit(env, "SESSIONS", &ip, 20, 60).await {
131        return json_response(
132            env,
133            &serde_json::json!({ "error": "Too many requests" }),
134            429,
135        );
136    }
137
138    let url = req.url()?;
139    let path = url.path();
140    let method = req.method();
141
142    // Read body bytes BEFORE routing so they are available for both NIP-98
143    // payload hash verification and route handler consumption.
144    let body_bytes: Vec<u8> = match method {
145        Method::Post | Method::Put | Method::Patch => req.bytes().await.unwrap_or_default(),
146        _ => Vec::new(),
147    };
148
149    let result = route(&req, env, path, &method, &body_bytes).await;
150
151    match result {
152        Ok(resp) => Ok(with_cors(resp, env)),
153        Err(e) => {
154            console_error!("Route error: {e:?}");
155            let msg = format!("{e:?}");
156            let status = if msg.contains("Serialization")
157                || msg.contains("JSON")
158                || msg.contains("json")
159                || msg.contains("missing field")
160                || msg.contains("parsing")
161                || msg.contains("Invalid")
162            {
163                400u16
164            } else {
165                500u16
166            };
167            let user_msg = if status == 400 {
168                "Invalid request body"
169            } else {
170                "Internal server error"
171            };
172            Ok(error_response(env, user_msg, status))
173        }
174    }
175}
176
177/// Route an incoming request to the appropriate handler.
178///
179/// `body_bytes` contains the pre-read request body (non-empty for POST/PUT/PATCH).
180/// This allows NIP-98 payload hash verification BEFORE dispatching to handlers,
181/// and avoids double-consumption of the request stream.
182async fn route(
183    req: &Request,
184    env: &Env,
185    path: &str,
186    method: &Method,
187    body_bytes: &[u8],
188) -> Result<Response> {
189    // DID document serving (public, no auth required)
190    if let Some(rest) = path.strip_prefix("/.well-known/did/nostr/") {
191        if let Some(pubkey) = rest.strip_suffix(".json") {
192            return did::handle_did_document(pubkey, env).await;
193        }
194    }
195
196    // Health check
197    if path == "/health" {
198        return json_response(
199            env,
200            &serde_json::json!({
201                "status": "ok",
202                "service": "auth-api",
203                "runtime": "workers-rs"
204            }),
205            200,
206        );
207    }
208
209    // WebAuthn Registration -- Generate options
210    if path == "/auth/register/options" && *method == Method::Post {
211        return webauthn::register_options(body_bytes, env).await;
212    }
213
214    // WebAuthn Registration -- Verify
215    if path == "/auth/register/verify" && *method == Method::Post {
216        let cf_country = req.headers().get("CF-IPCountry").ok().flatten();
217        return webauthn::register_verify(body_bytes, cf_country.as_deref(), env).await;
218    }
219
220    // WebAuthn Authentication -- Generate options
221    if path == "/auth/login/options" && *method == Method::Post {
222        return webauthn::login_options(body_bytes, env).await;
223    }
224
225    // WebAuthn Authentication -- Verify
226    if path == "/auth/login/verify" && *method == Method::Post {
227        return webauthn::login_verify(req, body_bytes, env).await;
228    }
229
230    // Credential lookup (for discoverable login)
231    if path == "/auth/lookup" && *method == Method::Post {
232        return webauthn::credential_lookup(body_bytes, env).await;
233    }
234
235    // NIP-98 protected endpoints
236    if path.starts_with("/api/") {
237        let auth_header_opt = req.headers().get("Authorization").ok().flatten();
238
239        // --- Sprint endpoints: moderation / WoT / invites / welcome ------
240        //
241        // These modules perform their own NIP-98 verification + admin/member
242        // gating via `admin::require_admin` / `require_authed` so we dispatch
243        // before the legacy `auth::verify_nip98` branch below.
244        if let Some(resp) = route_sprint_api(
245            req,
246            env,
247            path,
248            method,
249            body_bytes,
250            auth_header_opt.as_deref(),
251        )
252        .await?
253        {
254            return Ok(resp);
255        }
256
257        let auth_header = match auth_header_opt {
258            Some(h) => h,
259            None => {
260                return json_response(
261                    env,
262                    &serde_json::json!({ "error": "Authorization required" }),
263                    401,
264                )
265            }
266        };
267
268        let expected_origin = env
269            .var("EXPECTED_ORIGIN")
270            .map(|v| v.to_string())
271            .unwrap_or_else(|_| "https://example.com".to_string());
272        let request_url = format!("{expected_origin}{path}");
273
274        // Pass body bytes to NIP-98 for payload hash verification on POST/PUT.
275        // For GET/HEAD/DELETE the body is empty, so we pass None.
276        let body_for_nip98: Option<&[u8]> = match method {
277            Method::Post | Method::Put | Method::Patch => Some(body_bytes),
278            _ => None,
279        };
280
281        let result = auth::verify_nip98_replay(
282            &auth_header,
283            &request_url,
284            method_str(method),
285            body_for_nip98,
286            env,
287        )
288        .await;
289
290        match result {
291            Ok(token) => {
292                // Route authenticated requests
293                if path == "/api/profile" && *method == Method::Get {
294                    let cors = cors_headers(env);
295                    return pod::handle_profile(&token.pubkey, env, cors).await;
296                }
297            }
298            Err(_) => {
299                return json_response(
300                    env,
301                    &serde_json::json!({ "error": "Invalid NIP-98 token" }),
302                    401,
303                )
304            }
305        }
306    }
307
308    json_response(env, &serde_json::json!({ "error": "Not found" }), 404)
309}
310
311/// Dispatch to the Obelisk-polish sprint endpoints (moderation / WoT /
312/// invites / welcome). Returns `Ok(Some(resp))` if handled, `Ok(None)`
313/// otherwise so the caller can fall through to the legacy `/api/profile`
314/// handler.
315async fn route_sprint_api(
316    req: &Request,
317    env: &Env,
318    path: &str,
319    method: &Method,
320    body_bytes: &[u8],
321    auth_header: Option<&str>,
322) -> Result<Option<Response>> {
323    // Collect query pairs once for GET endpoints.
324    let query: Vec<(String, String)> = req
325        .url()
326        .map(|u| {
327            u.query_pairs()
328                .map(|(k, v)| (k.to_string(), v.to_string()))
329                .collect()
330        })
331        .unwrap_or_default();
332
333    // -- Moderation (WI-2) ----------------------------------------------
334    if matches!(path, "/api/mod/ban" | "/api/mod/mute" | "/api/mod/warn") && *method == Method::Post
335    {
336        let resp = moderation::handle_action(path, body_bytes, auth_header, env).await?;
337        return Ok(Some(resp));
338    }
339    if path == "/api/mod/report" && *method == Method::Post {
340        let resp = moderation::handle_report(body_bytes, auth_header, env).await?;
341        return Ok(Some(resp));
342    }
343    if path == "/api/mod/actions" && *method == Method::Get {
344        let resp = moderation::handle_list_actions(&query, auth_header, env).await?;
345        return Ok(Some(resp));
346    }
347    if path == "/api/mod/reports" && *method == Method::Get {
348        let resp = moderation::handle_list_reports(&query, auth_header, env).await?;
349        return Ok(Some(resp));
350    }
351    // POST /api/mod/reports/:id/action
352    if let Some(rest) = path.strip_prefix("/api/mod/reports/") {
353        if let Some(report_id) = rest.strip_suffix("/action") {
354            if *method == Method::Post && !report_id.is_empty() && !report_id.contains('/') {
355                let resp =
356                    moderation::handle_report_action(report_id, body_bytes, auth_header, env)
357                        .await?;
358                return Ok(Some(resp));
359            }
360        }
361    }
362
363    // -- Web-of-Trust (WI-3) --------------------------------------------
364    if path == "/api/wot/status" && *method == Method::Get {
365        let resp = wot::handle_status(auth_header, env).await?;
366        return Ok(Some(resp));
367    }
368    if path == "/api/wot/set-referente" && *method == Method::Post {
369        let resp = wot::handle_set_referente(body_bytes, auth_header, env).await?;
370        return Ok(Some(resp));
371    }
372    if path == "/api/wot/refresh" && *method == Method::Post {
373        let resp = wot::handle_refresh(body_bytes, auth_header, env).await?;
374        return Ok(Some(resp));
375    }
376    if matches!(path, "/api/wot/override/add" | "/api/wot/override/remove")
377        && *method == Method::Post
378    {
379        let resp = wot::handle_override(path, body_bytes, auth_header, env).await?;
380        return Ok(Some(resp));
381    }
382
383    // -- Invites (WI-4) -------------------------------------------------
384    if path == "/api/invites/create" && *method == Method::Post {
385        let resp = invites::handle_create(body_bytes, auth_header, env).await?;
386        return Ok(Some(resp));
387    }
388    if path == "/api/invites/mine" && *method == Method::Get {
389        let resp = invites::handle_list_mine(auth_header, env).await?;
390        return Ok(Some(resp));
391    }
392    // POST /api/invites/:id/revoke (must be checked before the generic preview)
393    if let Some(rest) = path.strip_prefix("/api/invites/") {
394        if let Some(invite_id) = rest.strip_suffix("/revoke") {
395            if *method == Method::Post && !invite_id.is_empty() && !invite_id.contains('/') {
396                let resp = invites::handle_revoke(invite_id, body_bytes, auth_header, env).await?;
397                return Ok(Some(resp));
398            }
399        }
400        // POST /api/invites/:code/redeem
401        if let Some(code) = rest.strip_suffix("/redeem") {
402            if *method == Method::Post && !code.is_empty() && !code.contains('/') {
403                let resp = invites::handle_redeem(code, body_bytes, auth_header, env).await?;
404                return Ok(Some(resp));
405            }
406        }
407        // GET /api/invites/:code (public preview, no auth)
408        if *method == Method::Get
409            && !rest.is_empty()
410            && !rest.contains('/')
411            && rest != "create"
412            && rest != "mine"
413        {
414            let resp = invites::handle_preview(rest, env).await?;
415            return Ok(Some(resp));
416        }
417    }
418
419    // -- Welcome bot (WI-5) --------------------------------------------
420    if path == "/api/welcome/config" && *method == Method::Get {
421        let resp = welcome::handle_get_config(auth_header, env).await?;
422        return Ok(Some(resp));
423    }
424    if path == "/api/welcome/configure" && *method == Method::Post {
425        let resp = welcome::handle_configure(body_bytes, auth_header, env).await?;
426        return Ok(Some(resp));
427    }
428    if path == "/api/welcome/set-bot-key" && *method == Method::Post {
429        let resp = welcome::handle_set_bot_key(body_bytes, auth_header, env).await?;
430        return Ok(Some(resp));
431    }
432    if path == "/api/welcome/test" && *method == Method::Post {
433        let resp = welcome::handle_test(body_bytes, auth_header, env).await?;
434        return Ok(Some(resp));
435    }
436
437    // -- Admin management (WI-8) ----------------------------------------
438    if path == "/api/admins" && *method == Method::Get {
439        let resp = admins::handle_list(auth_header, env).await?;
440        return Ok(Some(resp));
441    }
442    if path == "/api/admins/add" && *method == Method::Post {
443        let resp = admins::handle_add(body_bytes, auth_header, env).await?;
444        return Ok(Some(resp));
445    }
446    if path == "/api/admins/remove" && *method == Method::Post {
447        let resp = admins::handle_remove(body_bytes, auth_header, env).await?;
448        return Ok(Some(resp));
449    }
450
451    // -- Governance (Agent Control Surface) --------------------------------
452    if path == "/api/governance/agents" && *method == Method::Get {
453        let resp = governance_api::handle_list_agents(auth_header, env).await?;
454        return Ok(Some(resp));
455    }
456    if path == "/api/governance/agents/register" && *method == Method::Post {
457        let resp = governance_api::handle_register_agent(body_bytes, auth_header, env).await?;
458        return Ok(Some(resp));
459    }
460    if path == "/api/governance/agents/revoke" && *method == Method::Post {
461        let resp = governance_api::handle_revoke_agent(body_bytes, auth_header, env).await?;
462        return Ok(Some(resp));
463    }
464    if path == "/api/governance/cases" && *method == Method::Get {
465        let resp = governance_api::handle_list_cases(&query, auth_header, env).await?;
466        return Ok(Some(resp));
467    }
468    if let Some(case_id) = path.strip_prefix("/api/governance/cases/") {
469        if *method == Method::Get && !case_id.is_empty() && !case_id.contains('/') {
470            let resp = governance_api::handle_get_case(case_id, auth_header, env).await?;
471            return Ok(Some(resp));
472        }
473    }
474    if path == "/api/governance/roles/grant" && *method == Method::Post {
475        let resp = governance_api::handle_grant_role(body_bytes, auth_header, env).await?;
476        return Ok(Some(resp));
477    }
478    if path == "/api/governance/roles/revoke" && *method == Method::Post {
479        let resp = governance_api::handle_revoke_role(body_bytes, auth_header, env).await?;
480        return Ok(Some(resp));
481    }
482    if path == "/api/governance/roles" && *method == Method::Get {
483        let resp = governance_api::handle_list_roles(auth_header, env).await?;
484        return Ok(Some(resp));
485    }
486
487    // -- NIP-1984 standard report queue (admin view) --------------------
488    if path == "/api/moderation/reports" && *method == Method::Get {
489        let resp = moderation::handle_nip1984_reports(auth_header, env).await?;
490        return Ok(Some(resp));
491    }
492
493    // -- NIP-26 Delegation verification (stub for W6) -------------------
494    if path == "/api/delegation/verify" && *method == Method::Post {
495        let resp = delegation::handle_verify(body_bytes, auth_header, env).await?;
496        return Ok(Some(resp));
497    }
498
499    // -- Sprint v10: username reservations ------------------------------
500    if path == "/api/username/check" && *method == Method::Get {
501        let resp = username::handle_check(&query, env).await?;
502        return Ok(Some(resp));
503    }
504    if path == "/api/username/claim" && *method == Method::Post {
505        let resp = username::handle_claim(body_bytes, auth_header, env).await?;
506        return Ok(Some(resp));
507    }
508    if path == "/api/username/release" && *method == Method::Post {
509        let resp = username::handle_release(body_bytes, auth_header, env).await?;
510        return Ok(Some(resp));
511    }
512
513    Ok(None)
514}
515
516/// Map a `worker::Method` enum to its string name.
517fn method_str(m: &Method) -> &'static str {
518    match m {
519        Method::Get => "GET",
520        Method::Head => "HEAD",
521        Method::Post => "POST",
522        Method::Put => "PUT",
523        Method::Delete => "DELETE",
524        Method::Options => "OPTIONS",
525        Method::Patch => "PATCH",
526        Method::Connect => "CONNECT",
527        Method::Trace => "TRACE",
528        _ => "GET",
529    }
530}
531
532// ---------------------------------------------------------------------------
533// Tests — pure function coverage (no Worker runtime required)
534// ---------------------------------------------------------------------------
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539
540    // ── method_str ──────────────────────────────────────────────────────
541
542    #[test]
543    fn method_str_get() {
544        assert_eq!(method_str(&Method::Get), "GET");
545    }
546
547    #[test]
548    fn method_str_post() {
549        assert_eq!(method_str(&Method::Post), "POST");
550    }
551
552    #[test]
553    fn method_str_put() {
554        assert_eq!(method_str(&Method::Put), "PUT");
555    }
556
557    #[test]
558    fn method_str_delete() {
559        assert_eq!(method_str(&Method::Delete), "DELETE");
560    }
561
562    #[test]
563    fn method_str_head() {
564        assert_eq!(method_str(&Method::Head), "HEAD");
565    }
566
567    #[test]
568    fn method_str_options() {
569        assert_eq!(method_str(&Method::Options), "OPTIONS");
570    }
571
572    #[test]
573    fn method_str_patch() {
574        assert_eq!(method_str(&Method::Patch), "PATCH");
575    }
576
577    #[test]
578    fn method_str_connect() {
579        assert_eq!(method_str(&Method::Connect), "CONNECT");
580    }
581
582    #[test]
583    fn method_str_trace() {
584        assert_eq!(method_str(&Method::Trace), "TRACE");
585    }
586}
587
588/// Cron keep-warm: prevents cold starts by pinging D1.
589#[event(scheduled)]
590async fn scheduled(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) -> () {
591    let db = match env.d1("DB") {
592        Ok(db) => db,
593        Err(_) => return,
594    };
595    let _ = db
596        .prepare("SELECT 1")
597        .first::<serde_json::Value>(None)
598        .await;
599}