Skip to main content

nostr_bbs_pod_worker/
lib.rs

1//! nostr-bbs Pod Worker (Rust)
2//!
3//! Per-user Solid pod storage backed by R2 + KV, with NIP-98 authentication,
4//! WAC (Web Access Control) enforcement, LDP container support, ACL CRUD,
5//! pod provisioning, WebID profile management, remoteStorage compatibility,
6//! Solid Notifications (webhooks), HTTP 402 payments (Web Ledgers spec),
7//! and `did:nostr` DID document resolution.
8//!
9//! Payments: `/pay/` routes provide balance queries, multi-chain TXO deposits,
10//! and metered resource access via `did:nostr:<pubkey>` identities. Users and
11//! agents are indistinguishable at the protocol level.
12//!
13//! Port of `workers/pod-api/index.ts`.
14
15// Worker entry points are invoked via wasm-bindgen and appear unused in native builds.
16#![allow(dead_code)]
17
18mod acl;
19mod auth;
20mod conditional;
21mod container;
22mod content_negotiation;
23mod contexts;
24mod did;
25mod notifications;
26mod patch;
27mod payments;
28mod provision;
29mod quota;
30mod remote_storage;
31mod storage;
32mod webid;
33
34use acl::{
35    coerce_required_mode_for_acl, evaluate_access, find_effective_acl, wac_allow_header, AccessMode,
36};
37use base64::Engine as _;
38use worker::*;
39
40/// Maximum request body size: 50 MB.
41const MAX_BODY_SIZE: u64 = 50 * 1024 * 1024;
42
43/// Regex-equivalent pattern for pod routes: `/pods/{64 hex chars}{optional path}`.
44/// We parse manually instead of pulling in `regex` to keep the WASM binary small.
45fn parse_pod_route(path: &str) -> Option<(&str, &str)> {
46    let rest = path.strip_prefix("/pods/")?;
47    if rest.len() < 64 {
48        return None;
49    }
50    let (pubkey, remainder) = rest.split_at(64);
51    // Validate hex characters
52    if !pubkey.bytes().all(|b| b.is_ascii_hexdigit()) {
53        return None;
54    }
55    // Remainder must be empty or start with '/'
56    if !remainder.is_empty() && !remainder.starts_with('/') {
57        return None;
58    }
59    let resource_path = if remainder.is_empty() { "/" } else { remainder };
60    Some((pubkey, resource_path))
61}
62
63/// Check whether a resource path targets an `.acl` sidecar document.
64fn is_acl_path(path: &str) -> bool {
65    path.ends_with(".acl")
66}
67
68/// Check whether a resource path targets the pod provisioning endpoint.
69fn is_provision_path(path: &str) -> bool {
70    path == "/.provision"
71}
72
73/// Map a `worker::Method` enum to its string name.
74fn method_str(m: &Method) -> &'static str {
75    match m {
76        Method::Get => "GET",
77        Method::Head => "HEAD",
78        Method::Post => "POST",
79        Method::Put => "PUT",
80        Method::Delete => "DELETE",
81        Method::Options => "OPTIONS",
82        Method::Patch => "PATCH",
83        Method::Connect => "CONNECT",
84        Method::Trace => "TRACE",
85        _ => "GET",
86    }
87}
88
89/// Build CORS headers from the `EXPECTED_ORIGIN` env var.
90fn cors_headers(env: &Env) -> Headers {
91    let origin = env
92        .var("EXPECTED_ORIGIN")
93        .map(|v| v.to_string())
94        .unwrap_or_else(|_| "https://example.com".to_string());
95
96    let headers = Headers::new();
97    headers.set("Access-Control-Allow-Origin", &origin).ok();
98    headers
99        .set(
100            "Access-Control-Allow-Methods",
101            "GET, PUT, POST, DELETE, PATCH, HEAD, OPTIONS",
102        )
103        .ok();
104    headers
105        .set(
106            "Access-Control-Allow-Headers",
107            "Content-Type, Authorization, Slug, If-Match, If-None-Match, Range",
108        )
109        .ok();
110    headers.set("Access-Control-Max-Age", "86400").ok();
111    headers
112        .set(
113            "Access-Control-Expose-Headers",
114            "ETag, Accept-Ranges, Content-Range, Link, Location, WAC-Allow",
115        )
116        .ok();
117    headers
118}
119
120/// Append LDP Link headers and ACL link to a response.
121///
122/// For non-`.acl` resources, includes `Link: <{path}.acl>; rel="acl"`.
123fn add_ldp_headers(headers: &Headers, is_container: bool, resource_path: &str) {
124    let mut link_parts = Vec::new();
125
126    if is_container {
127        link_parts.push("<http://www.w3.org/ns/ldp#BasicContainer>; rel=\"type\"".to_string());
128        link_parts.push("<http://www.w3.org/ns/ldp#Resource>; rel=\"type\"".to_string());
129    } else {
130        link_parts.push("<http://www.w3.org/ns/ldp#Resource>; rel=\"type\"".to_string());
131    }
132
133    // Add rel="acl" link for non-.acl resources
134    if !is_acl_path(resource_path) {
135        let acl_link = format!("<{resource_path}.acl>; rel=\"acl\"");
136        link_parts.push(acl_link);
137    }
138
139    headers.set("Link", &link_parts.join(", ")).ok();
140    headers.set("Accept-Ranges", "bytes").ok();
141}
142
143/// Add WAC-Allow header to a response.
144fn add_wac_allow(
145    headers: &Headers,
146    acl_doc: Option<&acl::AclDocument>,
147    agent_uri: Option<&str>,
148    resource_path: &str,
149) {
150    let value = wac_allow_header(acl_doc, agent_uri, resource_path);
151    headers.set("WAC-Allow", &value).ok();
152}
153
154/// Add Cache-Control header to a response based on resource path.
155///
156/// Media paths under `/media/` are treated as content-addressed and immutable
157/// (1-year cache, immutable directive). All other resources use a short
158/// `max-age=300` with `must-revalidate` since they are mutable Solid pod
159/// resources (profile cards, ACLs, type indexes, etc.).
160fn add_cache_control(headers: &Headers, resource_path: &str) {
161    let value = if resource_path.starts_with("/media/") {
162        "public, max-age=31536000, immutable"
163    } else {
164        "public, max-age=300, must-revalidate"
165    };
166    headers.set("Cache-Control", value).ok();
167}
168
169/// Create a JSON error response with CORS headers.
170fn json_error(env: &Env, message: &str, status: u16) -> Result<Response> {
171    let body = serde_json::json!({ "error": message });
172    let json_str = serde_json::to_string(&body).map_err(|e| Error::RustError(e.to_string()))?;
173    let cors = cors_headers(env);
174    let resp = Response::ok(json_str)?
175        .with_status(status)
176        .with_headers(cors);
177    resp.headers().set("Content-Type", "application/json").ok();
178    Ok(resp)
179}
180
181/// Sprint v10: lightweight token-bucket rate limit for `/.well-known/nostr.json`.
182///
183/// Counts requests per IP per 60-second bucket. Returns `true` if the request
184/// is allowed, `false` if the bucket is full. KV failures fail-open (we'd
185/// rather serve a few extra hits than silently 429 every legitimate client
186/// when KV is degraded).
187const NIP05_RL_LIMIT: u32 = 60;
188const NIP05_RL_WINDOW_SECS: u64 = 60;
189
190async fn rl_nostr_json(kv: &worker::kv::KvStore, ip: &str) -> bool {
191    let bucket = (js_sys::Date::now() as u64) / (NIP05_RL_WINDOW_SECS * 1000);
192    let key = format!("rl:nostr_json:{ip}:{bucket}");
193
194    let current: u32 = match kv.get(&key).text().await {
195        Ok(Some(val)) => val.parse().unwrap_or(0),
196        _ => 0,
197    };
198    if current >= NIP05_RL_LIMIT {
199        return false;
200    }
201
202    let next = (current + 1).to_string();
203    if let Ok(builder) = kv.put(&key, &next) {
204        let _ = builder.expiration_ttl(NIP05_RL_WINDOW_SECS).execute().await;
205    }
206    true
207}
208
209/// Build a did:nostr DID document (Tier 3) for the given x-only pubkey hex.
210///
211/// Delegates to `crate::did::render_did_document_tier3`, which calls through
212/// to the canonical `solid_pod_rs::did_nostr_types` module (upstream since
213/// v0.4.0-alpha.8).
214fn build_did_nostr_document(pubkey_hex: &str, pod_base: &str) -> serde_json::Value {
215    match did::NostrPubkey::from_hex(pubkey_hex) {
216        Ok(pk) => {
217            let pod_url = format!("{pod_base}/pods/{pubkey_hex}/");
218            let webid_url = format!("{pod_url}profile/card#me");
219            did::render_did_document_tier3(
220                &pk,
221                Some(&webid_url),
222                &pod_url,
223                None, // relay URL: not included at Tier 3 without lookup
224                None, // governance URL: set at instance config level
225                None, // display name: not known at DID resolution time
226            )
227        }
228        Err(_) => serde_json::json!({ "error": "invalid pubkey" }),
229    }
230}
231
232/// Create a JSON success response with CORS headers.
233fn json_ok(env: &Env, body: &serde_json::Value, status: u16) -> Result<Response> {
234    let json_str = serde_json::to_string(body).map_err(|e| Error::RustError(e.to_string()))?;
235    let cors = cors_headers(env);
236    let resp = Response::ok(json_str)?
237        .with_status(status)
238        .with_headers(cors);
239    resp.headers().set("Content-Type", "application/json").ok();
240    Ok(resp)
241}
242
243#[event(fetch)]
244async fn fetch(mut req: Request, env: Env, _ctx: Context) -> Result<Response> {
245    nostr_bbs_rate_limit::ensure_replay_schema(&env, "REPLAY_DB").await;
246    payments::ensure_payment_schema(&env, "REPLAY_DB").await;
247
248    // CORS preflight
249    if req.method() == Method::Options {
250        return Ok(Response::empty()?
251            .with_status(204)
252            .with_headers(cors_headers(&env)));
253    }
254
255    let url = req.url()?;
256    let path = url.path();
257
258    // Health check
259    if path == "/health" {
260        return json_ok(
261            &env,
262            &serde_json::json!({
263                "status": "ok",
264                "service": "pod-api",
265                "runtime": "workers-rs",
266                "version": "6.0.0",
267                "features": [
268                    "ldp-containers",
269                    "conditional-requests",
270                    "quota",
271                    "webid",
272                    "acl-crud",
273                    "pod-provisioning",
274                    "wac-allow",
275                    "jsonld-native",
276                    "content-negotiation",
277                    "remote-storage",
278                    "solid-notifications",
279                    "webfinger",
280                    "nip-05",
281                    "payments"
282                ]
283            }),
284            200,
285        );
286    }
287
288    // -------------------------------------------------------------------
289    // .well-known discovery endpoints (federation-ready, Stream 12)
290    // -------------------------------------------------------------------
291
292    // WebFinger: remoteStorage + Solid + ActivityPub discovery
293    if path == "/.well-known/webfinger" {
294        let resource = url
295            .query_pairs()
296            .find(|(k, _)| k == "resource")
297            .map(|(_, v)| v.to_string())
298            .unwrap_or_default();
299        if let Some(pk) = remote_storage::parse_webfinger_resource(&resource) {
300            let host = url.host_str().unwrap_or("example.test");
301            let pod_base = format!("https://{host}");
302            let body = remote_storage::webfinger_response(&pk, host, &pod_base);
303            let json_str =
304                serde_json::to_string(&body).map_err(|e| Error::RustError(e.to_string()))?;
305            let cors = cors_headers(&env);
306            let resp = Response::ok(json_str)?.with_headers(cors);
307            resp.headers()
308                .set("Content-Type", "application/jrd+json")
309                .ok();
310            return Ok(resp);
311        }
312        return json_error(&env, "Invalid resource parameter", 400);
313    }
314
315    // Solid discovery metadata
316    if path == "/.well-known/solid" {
317        let host = url.host_str().unwrap_or("example.test");
318        let body = remote_storage::solid_discovery(&format!("https://{host}"));
319        let json_str = serde_json::to_string(&body).map_err(|e| Error::RustError(e.to_string()))?;
320        let cors = cors_headers(&env);
321        let resp = Response::ok(json_str)?.with_headers(cors);
322        resp.headers().set("Content-Type", "application/json").ok();
323        return Ok(resp);
324    }
325
326    // NIP-05 verification
327    if path == "/.well-known/nostr.json" {
328        // Sprint v10: rate-limit at 60 req/min per IP via POD_META KV. The
329        // endpoint is otherwise unauthenticated and trivially scrape-able,
330        // so without a budget here a single client could enumerate the
331        // entire username table.
332        let kv = env.kv("POD_META")?;
333        let ip = req
334            .headers()
335            .get("CF-Connecting-IP")
336            .ok()
337            .flatten()
338            .unwrap_or_else(|| "unknown".to_string());
339        if !rl_nostr_json(&kv, &ip).await {
340            let cors = cors_headers(&env);
341            let resp = Response::ok(r#"{"error":"Too many requests"}"#)?
342                .with_status(429)
343                .with_headers(cors);
344            resp.headers().set("Content-Type", "application/json").ok();
345            resp.headers().set("Retry-After", "60").ok();
346            return Ok(resp);
347        }
348
349        let name = url
350            .query_pairs()
351            .find(|(k, _)| k == "name")
352            .map(|(_, v)| v.to_string())
353            .unwrap_or_default();
354        if name.is_empty() {
355            return json_error(&env, "Missing name parameter", 400);
356        }
357        // Look up pubkey from KV: nip05:{name} -> pubkey
358        let key = format!("nip05:{name}");
359        let pubkey = kv.get(&key).text().await.ok().flatten();
360        if let Some(pk) = pubkey {
361            let body = remote_storage::nostr_json(&pk, &name);
362            let json_str =
363                serde_json::to_string(&body).map_err(|e| Error::RustError(e.to_string()))?;
364            let cors = cors_headers(&env);
365            let resp = Response::ok(json_str)?.with_headers(cors);
366            resp.headers().set("Content-Type", "application/json").ok();
367            resp.headers().set("Access-Control-Allow-Origin", "*").ok();
368            return Ok(resp);
369        }
370        return json_error(&env, "Name not found", 404);
371    }
372
373    // DID document: GET /.well-known/did/nostr/{pubkey}.json
374    // Returns a did:nostr DID document for any 64-char hex pubkey known to this pod.
375    // Tier1 (public, no auth) — anyone can resolve. Tier3 (extended) not yet gated.
376    if let Some(rest) = path.strip_prefix("/.well-known/did/nostr/") {
377        if let Some(pk) = rest.strip_suffix(".json") {
378            // Validate: must be exactly 64 lowercase hex chars
379            if pk.len() == 64 && pk.bytes().all(|b| b.is_ascii_hexdigit()) {
380                let host = url.host_str().unwrap_or("example.test");
381                let pod_base = format!("https://{host}");
382                let did_doc = build_did_nostr_document(pk, &pod_base);
383                let json_str =
384                    serde_json::to_string(&did_doc).map_err(|e| Error::RustError(e.to_string()))?;
385                let cors = cors_headers(&env);
386                let resp = Response::ok(json_str)?.with_headers(cors);
387                resp.headers()
388                    .set("Content-Type", "application/did+json")
389                    .ok();
390                return Ok(resp);
391            }
392            return json_error(&env, "Invalid pubkey in DID path", 400);
393        }
394    }
395
396    // Web Ledgers discovery
397    if path == "/.well-known/webledgers/webledgers.json" {
398        let host = url.host_str().unwrap_or("example.test");
399        let body = payments::webledgers_discovery(&format!("https://{host}"));
400        return json_ok(&env, &body, 200);
401    }
402
403    // -------------------------------------------------------------------
404    // /pay/ routes (HTTP 402 payment system — Web Ledgers spec)
405    // -------------------------------------------------------------------
406    if path.starts_with("/pay/") {
407        let pay_config = load_pay_config(&env);
408        if pay_config.enabled {
409            let method = req.method();
410            let pay_auth_header = req.headers().get("Authorization").ok().flatten();
411
412            let pay_body: Option<Vec<u8>> = if method == Method::Post {
413                req.bytes().await.ok()
414            } else {
415                None
416            };
417
418            let expected_origin = env
419                .var("EXPECTED_ORIGIN")
420                .map(|v| v.to_string())
421                .unwrap_or_else(|_| "https://example.com".to_string());
422            let request_url = format!("{expected_origin}{path}");
423            let requester_pubkey: Option<String> = if let Some(ref header) = pay_auth_header {
424                let method_name = method_str(&method);
425                let body_ref = pay_body.as_deref();
426                auth::verify_nip98_replay(header, &request_url, method_name, body_ref, &env)
427                    .await
428                    .ok()
429                    .map(|t| t.pubkey)
430            } else {
431                None
432            };
433
434            let pay_db = env
435                .d1("REPLAY_DB")
436                .map_err(|e| Error::RustError(format!("REPLAY_DB D1 binding missing: {e}")))?;
437            if let Some(result) = payments::handle_pay_route(
438                path,
439                &method,
440                requester_pubkey.as_deref(),
441                pay_body.as_deref(),
442                &pay_db,
443                &env,
444                &pay_config,
445            )
446            .await
447            {
448                let resp = result?;
449                resp.headers()
450                    .set("Access-Control-Allow-Origin", &expected_origin)
451                    .ok();
452                return Ok(resp);
453            }
454        }
455        return json_error(&env, "Not found", 404);
456    }
457
458    // Route: /pods/{pubkey}/...
459    let (owner_pubkey, resource_path) = match parse_pod_route(path) {
460        Some(parsed) => parsed,
461        None => return json_error(&env, "Not found", 404),
462    };
463
464    // We need owned copies before we borrow `req` mutably for the body
465    let owner_pubkey = owner_pubkey.to_string();
466    let resource_path = resource_path.to_string();
467    let method = req.method();
468    let req_headers = req.headers().clone();
469    let auth_header = req_headers.get("Authorization").ok().flatten();
470    let slug_header = req_headers.get("Slug").ok().flatten();
471    let accept_header = req_headers.get("Accept").ok().flatten();
472    let content_type = req_headers
473        .get("Content-Type")
474        .ok()
475        .flatten()
476        .unwrap_or_else(|| "application/octet-stream".to_string());
477    let content_length: u64 = req_headers
478        .get("Content-Length")
479        .ok()
480        .flatten()
481        .and_then(|s| s.parse().ok())
482        .unwrap_or(0);
483
484    // Read body early so we can use it for both NIP-98 payload verification and R2 upload
485    let body_bytes: Option<Vec<u8>> = match method {
486        Method::Put | Method::Post | Method::Patch => req.bytes().await.ok(),
487        _ => None,
488    };
489
490    // Authenticate via NIP-98
491    let expected_origin = env
492        .var("EXPECTED_ORIGIN")
493        .map(|v| v.to_string())
494        .unwrap_or_else(|_| "https://example.com".to_string());
495    let request_url = format!("{expected_origin}{path}");
496
497    let requester_pubkey: Option<String> = if let Some(ref header) = auth_header {
498        let method_name = method_str(&method);
499        let body_ref = body_bytes.as_deref();
500        match auth::verify_nip98_replay(header, &request_url, method_name, body_ref, &env).await {
501            Ok(token) => {
502                // If the event carries a `["webid", uri]` tag, verify the URI
503                // is controlled by the signing pubkey. Reject tokens where the
504                // webid tag references a different identity.
505                if let Some(webid_uri) = extract_webid_tag_from_header(header) {
506                    if !did::verify_webid_tag(&webid_uri, &token.pubkey) {
507                        return json_error(&env, "NIP-98 webid tag identity mismatch", 401);
508                    }
509                }
510                Some(token.pubkey)
511            }
512            Err(_) => None,
513        }
514    } else {
515        None
516    };
517
518    let kv = env.kv("POD_META")?;
519    let bucket = env.bucket("PODS")?;
520    let quota_db = env
521        .d1("REPLAY_DB")
522        .map_err(|e| Error::RustError(format!("REPLAY_DB D1 binding missing: {e}")))?;
523
524    let agent_uri = requester_pubkey
525        .as_ref()
526        .map(|pk| format!("did:nostr:{pk}"));
527
528    // -----------------------------------------------------------------------
529    // Provisioning endpoint: POST /pods/{pubkey}/.provision
530    // -----------------------------------------------------------------------
531    if is_provision_path(&resource_path) {
532        if method != Method::Post {
533            return json_error(&env, "Method not allowed; use POST", 405);
534        }
535
536        // Require authentication
537        let req_pk = match requester_pubkey.as_ref() {
538            Some(pk) => pk.clone(),
539            None => return json_error(&env, "Authentication required", 401),
540        };
541
542        // Only the pod owner or an admin can provision
543        let is_owner = req_pk == owner_pubkey;
544        let is_admin = is_admin_user(&env, &req_pk).await;
545        if !is_owner && !is_admin {
546            return json_error(&env, "Only the pod owner or admin can provision", 403);
547        }
548
549        // Check if pod already exists
550        if provision::pod_exists(&bucket, &owner_pubkey).await {
551            return json_error(&env, "Pod already provisioned", 409);
552        }
553
554        // Extract optional display_name from body
555        let display_name: Option<String> = body_bytes
556            .as_deref()
557            .and_then(|b| serde_json::from_slice::<serde_json::Value>(b).ok())
558            .and_then(|v| {
559                v.get("display_name")
560                    .and_then(|n| n.as_str())
561                    .map(String::from)
562            });
563
564        let pod_base = expected_origin.clone();
565        provision::provision_pod(
566            &bucket,
567            &kv,
568            &owner_pubkey,
569            &pod_base,
570            display_name.as_deref(),
571        )
572        .await?;
573
574        let pod_url = format!("{expected_origin}/pods/{owner_pubkey}/");
575        let webid_url = format!("{expected_origin}/pods/{owner_pubkey}/profile/card#me");
576        return json_ok(
577            &env,
578            &serde_json::json!({
579                "status": "provisioned",
580                "podUrl": pod_url,
581                "webId": webid_url,
582                "didNostr": format!("did:nostr:{owner_pubkey}"),
583                "containers": ["profile/", "public/", "private/", "inbox/", "settings/"]
584            }),
585            201,
586        );
587    }
588
589    // -----------------------------------------------------------------------
590    // ACL CRUD: paths ending with .acl
591    // -----------------------------------------------------------------------
592    if is_acl_path(&resource_path) {
593        return handle_acl_request(
594            &env,
595            &bucket,
596            &kv,
597            &owner_pubkey,
598            &resource_path,
599            &method,
600            &req_headers,
601            body_bytes,
602            content_length,
603            requester_pubkey.as_deref(),
604            agent_uri.as_deref(),
605        )
606        .await;
607    }
608
609    // -----------------------------------------------------------------------
610    // Standard resource ACL check
611    // -----------------------------------------------------------------------
612    // For `.acl` sidecars we coerce write-class methods up to acl:Control so
613    // a principal with mere acl:Write cannot escalate by overwriting the
614    // sidecar (audit C3). Non-acl resources retain the standard mapping.
615    let required_mode = coerce_required_mode_for_acl(&resource_path, method_str(&method));
616    let acl_doc = find_effective_acl(&bucket, &kv, &owner_pubkey, &resource_path).await;
617
618    let has_access = evaluate_access(
619        acl_doc.as_ref(),
620        agent_uri.as_deref(),
621        &resource_path,
622        required_mode,
623    );
624
625    if !has_access {
626        return if requester_pubkey.is_some() {
627            json_error(&env, "Forbidden", 403)
628        } else {
629            json_error(&env, "Authentication required", 401)
630        };
631    }
632
633    // Detect container vs resource
634    let is_container_path = container::is_container(&resource_path);
635
636    // R2 operations
637    let r2_key = format!("pods/{owner_pubkey}{resource_path}");
638
639    match method {
640        Method::Get | Method::Head => {
641            // Container listing
642            if is_container_path {
643                let listing =
644                    container::list_container(&bucket, &owner_pubkey, &resource_path).await?;
645                let json_str =
646                    serde_json::to_string(&listing).map_err(|e| Error::RustError(e.to_string()))?;
647                let cors = cors_headers(&env);
648                let resp = Response::ok(json_str)?.with_headers(cors);
649                resp.headers()
650                    .set("Content-Type", "application/ld+json")
651                    .ok();
652                add_ldp_headers(resp.headers(), true, &resource_path);
653                add_wac_allow(
654                    resp.headers(),
655                    acl_doc.as_ref(),
656                    agent_uri.as_deref(),
657                    &resource_path,
658                );
659                add_cache_control(resp.headers(), &resource_path);
660                return Ok(resp);
661            }
662
663            // WebID profile document (special path): serve from R2 if stored,
664            // otherwise generate dynamically.
665            if resource_path == "/profile/card" {
666                let html = match bucket.get(&r2_key).execute().await? {
667                    Some(obj) => {
668                        let body = obj
669                            .body()
670                            .ok_or_else(|| Error::RustError("R2 object has no body".into()))?;
671                        let bytes = body.bytes().await?;
672                        String::from_utf8(bytes).unwrap_or_else(|_| {
673                            webid::generate_webid_html(&owner_pubkey, None, &expected_origin)
674                        })
675                    }
676                    None => webid::generate_webid_html(&owner_pubkey, None, &expected_origin),
677                };
678                let cors = cors_headers(&env);
679                let resp = Response::ok(html)?.with_headers(cors);
680                resp.headers().set("Content-Type", "text/html").ok();
681                add_ldp_headers(resp.headers(), false, &resource_path);
682                add_wac_allow(
683                    resp.headers(),
684                    acl_doc.as_ref(),
685                    agent_uri.as_deref(),
686                    &resource_path,
687                );
688                add_cache_control(resp.headers(), &resource_path);
689                return Ok(resp);
690            }
691
692            // Regular resource GET
693            let object = match bucket.get(&r2_key).execute().await? {
694                Some(obj) => obj,
695                None => return json_error(&env, "Not found", 404),
696            };
697
698            let stored_content_type = object
699                .http_metadata()
700                .content_type
701                .unwrap_or_else(|| "application/octet-stream".to_string());
702            let obj_content_type =
703                content_negotiation::negotiate(accept_header.as_deref(), &stored_content_type);
704            let etag = object.etag();
705            let cors = cors_headers(&env);
706
707            // Conditional request check
708            if let Some(status) = conditional::check_preconditions(&req_headers, &etag) {
709                let resp = Response::empty()?.with_status(status).with_headers(cors);
710                resp.headers().set("ETag", &format!("\"{etag}\"")).ok();
711                add_ldp_headers(resp.headers(), false, &resource_path);
712                add_wac_allow(
713                    resp.headers(),
714                    acl_doc.as_ref(),
715                    agent_uri.as_deref(),
716                    &resource_path,
717                );
718                return Ok(resp);
719            }
720
721            if method == Method::Head {
722                let resp = Response::empty()?.with_headers(cors);
723                resp.headers().set("Content-Type", &obj_content_type).ok();
724                resp.headers().set("ETag", &format!("\"{etag}\"")).ok();
725                resp.headers().set("Vary", "Accept").ok();
726                add_ldp_headers(resp.headers(), false, &resource_path);
727                add_wac_allow(
728                    resp.headers(),
729                    acl_doc.as_ref(),
730                    agent_uri.as_deref(),
731                    &resource_path,
732                );
733                add_cache_control(resp.headers(), &resource_path);
734                return Ok(resp);
735            }
736
737            let body = object
738                .body()
739                .ok_or_else(|| Error::RustError("R2 object has no body".to_string()))?;
740            let bytes = body.bytes().await?;
741
742            // Range request support
743            if let Some((start, end)) = conditional::parse_range(&req_headers, bytes.len() as u64) {
744                let slice = &bytes[start as usize..=end as usize];
745                let resp = Response::from_bytes(slice.to_vec())?
746                    .with_status(206)
747                    .with_headers(cors);
748                resp.headers().set("Content-Type", &obj_content_type).ok();
749                resp.headers().set("ETag", &format!("\"{etag}\"")).ok();
750                resp.headers()
751                    .set(
752                        "Content-Range",
753                        &format!("bytes {start}-{end}/{}", bytes.len()),
754                    )
755                    .ok();
756                add_ldp_headers(resp.headers(), false, &resource_path);
757                add_wac_allow(
758                    resp.headers(),
759                    acl_doc.as_ref(),
760                    agent_uri.as_deref(),
761                    &resource_path,
762                );
763                add_cache_control(resp.headers(), &resource_path);
764                return Ok(resp);
765            }
766
767            let resp = Response::from_bytes(bytes)?.with_headers(cors);
768            resp.headers().set("Content-Type", &obj_content_type).ok();
769            resp.headers().set("ETag", &format!("\"{etag}\"")).ok();
770            resp.headers().set("Vary", "Accept").ok();
771            add_ldp_headers(resp.headers(), false, &resource_path);
772            add_wac_allow(
773                resp.headers(),
774                acl_doc.as_ref(),
775                agent_uri.as_deref(),
776                &resource_path,
777            );
778            add_cache_control(resp.headers(), &resource_path);
779            Ok(resp)
780        }
781
782        Method::Put => {
783            // PUT replaces a resource (not valid on containers)
784            if is_container_path {
785                return json_error(&env, "Cannot PUT to a container; use POST", 405);
786            }
787
788            if content_length > MAX_BODY_SIZE {
789                return json_error(
790                    &env,
791                    &format!("Body exceeds {} byte limit", MAX_BODY_SIZE),
792                    413,
793                );
794            }
795
796            let data = body_bytes.unwrap_or_default();
797            let data_len = data.len() as u64;
798            if data_len > MAX_BODY_SIZE {
799                return json_error(
800                    &env,
801                    &format!("Body exceeds {} byte limit", MAX_BODY_SIZE),
802                    413,
803                );
804            }
805
806            // Conditional check: If-Match for safe overwrites
807            if let Ok(Some(existing)) = bucket.get(&r2_key).execute().await {
808                let etag = existing.etag();
809                if let Some(status) = conditional::check_preconditions(&req_headers, &etag) {
810                    return json_error(
811                        &env,
812                        if status == 412 {
813                            "Precondition failed"
814                        } else {
815                            "Not modified"
816                        },
817                        status,
818                    );
819                }
820            }
821
822            // Atomic quota check + reserve (D1)
823            if let Err(e) = quota::check_and_reserve_d1(&quota_db, &owner_pubkey, data_len).await {
824                return json_error(&env, &e.to_string(), 413);
825            }
826
827            // WebID profile: validate HTML with JSON-LD before storing
828            if resource_path == "/profile/card" {
829                if let Err(msg) = validate_webid_html(&data) {
830                    return json_error(&env, &msg, 422);
831                }
832            }
833
834            bucket
835                .put(&r2_key, data)
836                .http_metadata(HttpMetadata {
837                    content_type: Some(content_type),
838                    ..Default::default()
839                })
840                .execute()
841                .await?;
842
843            // Fire notification webhooks (non-blocking)
844            notifications::notify_change(&kv, &owner_pubkey, &resource_path, "Update").await;
845
846            let resp_body = serde_json::json!({ "status": "ok" });
847            let resp = json_ok(&env, &resp_body, 201)?;
848            add_ldp_headers(resp.headers(), false, &resource_path);
849            add_wac_allow(
850                resp.headers(),
851                acl_doc.as_ref(),
852                agent_uri.as_deref(),
853                &resource_path,
854            );
855            Ok(resp)
856        }
857
858        Method::Post => {
859            // POST to a container creates a child resource
860            if !is_container_path {
861                // POST to a non-container: treat as regular write (backwards compat)
862                if content_length > MAX_BODY_SIZE {
863                    return json_error(
864                        &env,
865                        &format!("Body exceeds {} byte limit", MAX_BODY_SIZE),
866                        413,
867                    );
868                }
869
870                let data = body_bytes.unwrap_or_default();
871                let data_len = data.len() as u64;
872                if data_len > MAX_BODY_SIZE {
873                    return json_error(
874                        &env,
875                        &format!("Body exceeds {} byte limit", MAX_BODY_SIZE),
876                        413,
877                    );
878                }
879
880                if let Err(e) =
881                    quota::check_and_reserve_d1(&quota_db, &owner_pubkey, data_len).await
882                {
883                    return json_error(&env, &e.to_string(), 413);
884                }
885
886                bucket
887                    .put(&r2_key, data)
888                    .http_metadata(HttpMetadata {
889                        content_type: Some(content_type),
890                        ..Default::default()
891                    })
892                    .execute()
893                    .await?;
894
895                // Fire notification webhooks (non-blocking)
896                notifications::notify_change(&kv, &owner_pubkey, &resource_path, "Update").await;
897
898                let resp_body = serde_json::json!({ "status": "ok" });
899                let resp = json_ok(&env, &resp_body, 201)?;
900                add_ldp_headers(resp.headers(), false, &resource_path);
901                add_wac_allow(
902                    resp.headers(),
903                    acl_doc.as_ref(),
904                    agent_uri.as_deref(),
905                    &resource_path,
906                );
907                return Ok(resp);
908            }
909
910            // Container POST: create child resource
911            if content_length > MAX_BODY_SIZE {
912                return json_error(
913                    &env,
914                    &format!("Body exceeds {} byte limit", MAX_BODY_SIZE),
915                    413,
916                );
917            }
918
919            let data = body_bytes.unwrap_or_default();
920            let data_len = data.len() as u64;
921            if data_len > MAX_BODY_SIZE {
922                return json_error(
923                    &env,
924                    &format!("Body exceeds {} byte limit", MAX_BODY_SIZE),
925                    413,
926                );
927            }
928
929            if let Err(e) = quota::check_and_reserve_d1(&quota_db, &owner_pubkey, data_len).await {
930                return json_error(&env, &e.to_string(), 413);
931            }
932
933            let child_path = container::resolve_slug(&resource_path, slug_header.as_deref());
934            let child_r2_key = format!("pods/{owner_pubkey}{child_path}");
935
936            bucket
937                .put(&child_r2_key, data)
938                .http_metadata(HttpMetadata {
939                    content_type: Some(content_type),
940                    ..Default::default()
941                })
942                .execute()
943                .await?;
944
945            // Fire notification webhooks (non-blocking)
946            notifications::notify_change(&kv, &owner_pubkey, &child_path, "Create").await;
947
948            let location = format!("/pods/{owner_pubkey}{child_path}");
949            let resp_body = serde_json::json!({
950                "status": "created",
951                "path": child_path,
952                "location": location,
953            });
954            let resp = json_ok(&env, &resp_body, 201)?;
955            resp.headers().set("Location", &location).ok();
956            add_ldp_headers(resp.headers(), false, &resource_path);
957            add_wac_allow(
958                resp.headers(),
959                acl_doc.as_ref(),
960                agent_uri.as_deref(),
961                &resource_path,
962            );
963            Ok(resp)
964        }
965
966        Method::Patch => {
967            // PATCH applies JSON Patch (RFC 6902) to a resource
968            if is_container_path {
969                return json_error(&env, "Cannot PATCH a container", 405);
970            }
971
972            let patch_data = body_bytes.unwrap_or_default();
973
974            // Parse patch operations
975            let operations: Vec<patch::PatchOperation> = serde_json::from_slice(&patch_data)
976                .map_err(|e| Error::RustError(format!("Invalid JSON Patch: {e}")))?;
977
978            // Read current document
979            let current_bytes = match bucket.get(&r2_key).execute().await? {
980                Some(obj) => {
981                    let body = obj
982                        .body()
983                        .ok_or_else(|| Error::RustError("R2 object has no body".into()))?;
984                    body.bytes().await?
985                }
986                None => return json_error(&env, "Not found", 404),
987            };
988
989            let mut document: serde_json::Value = serde_json::from_slice(&current_bytes)
990                .map_err(|e| Error::RustError(format!("Resource is not JSON: {e}")))?;
991
992            // Apply patches
993            patch::apply_patches(&mut document, &operations)
994                .map_err(|e| Error::RustError(format!("Patch failed: {e}")))?;
995
996            let updated =
997                serde_json::to_vec(&document).map_err(|e| Error::RustError(e.to_string()))?;
998            let updated_len = updated.len() as u64;
999
1000            // Atomic quota check for size increase
1001            let size_delta = updated_len as i64 - current_bytes.len() as i64;
1002            if size_delta > 0 {
1003                if let Err(e) =
1004                    quota::check_and_reserve_d1(&quota_db, &owner_pubkey, size_delta as u64).await
1005                {
1006                    return json_error(&env, &e.to_string(), 413);
1007                }
1008            }
1009
1010            // WebID profile: validate after patching
1011            if resource_path == "/profile/card" {
1012                if let Err(msg) = validate_webid_html(&updated) {
1013                    return json_error(&env, &msg, 422);
1014                }
1015            }
1016
1017            bucket
1018                .put(&r2_key, updated)
1019                .http_metadata(HttpMetadata {
1020                    content_type: Some("application/ld+json".into()),
1021                    ..Default::default()
1022                })
1023                .execute()
1024                .await?;
1025
1026            // Release quota for shrinkage
1027            if size_delta < 0 {
1028                quota::update_usage_d1(&quota_db, &owner_pubkey, size_delta)
1029                    .await
1030                    .ok();
1031            }
1032
1033            // Fire notification webhooks (non-blocking)
1034            notifications::notify_change(&kv, &owner_pubkey, &resource_path, "Update").await;
1035
1036            let resp_body = serde_json::json!({ "status": "ok" });
1037            let resp = json_ok(&env, &resp_body, 200)?;
1038            add_ldp_headers(resp.headers(), false, &resource_path);
1039            add_wac_allow(
1040                resp.headers(),
1041                acl_doc.as_ref(),
1042                agent_uri.as_deref(),
1043                &resource_path,
1044            );
1045            Ok(resp)
1046        }
1047
1048        Method::Delete => {
1049            // Estimate size of deleted resource for quota tracking
1050            let deleted_size: u64 = match bucket.get(&r2_key).execute().await? {
1051                Some(obj) => obj.size(),
1052                None => return json_error(&env, "Not found", 404),
1053            };
1054
1055            bucket.delete(&r2_key).await?;
1056
1057            // Release quota (negative delta, D1 atomic)
1058            quota::update_usage_d1(&quota_db, &owner_pubkey, -(deleted_size as i64))
1059                .await
1060                .ok();
1061
1062            // Fire notification webhooks (non-blocking)
1063            notifications::notify_change(&kv, &owner_pubkey, &resource_path, "Delete").await;
1064
1065            let resp_body = serde_json::json!({ "status": "deleted" });
1066            let resp = json_ok(&env, &resp_body, 200)?;
1067            add_ldp_headers(resp.headers(), false, &resource_path);
1068            add_wac_allow(
1069                resp.headers(),
1070                acl_doc.as_ref(),
1071                agent_uri.as_deref(),
1072                &resource_path,
1073            );
1074            Ok(resp)
1075        }
1076
1077        _ => json_error(&env, "Method not allowed", 405),
1078    }
1079}
1080
1081// ---------------------------------------------------------------------------
1082// ACL request handler
1083// ---------------------------------------------------------------------------
1084
1085/// Handle GET/PUT/DELETE on `.acl` sidecar resources.
1086///
1087/// ACL documents are stored in R2 alongside the resources they protect.
1088/// Writing an ACL requires `acl:Control` on the parent resource.
1089#[allow(clippy::too_many_arguments)]
1090async fn handle_acl_request(
1091    env: &Env,
1092    bucket: &Bucket,
1093    kv: &kv::KvStore,
1094    owner_pubkey: &str,
1095    acl_path: &str,
1096    method: &Method,
1097    req_headers: &Headers,
1098    body_bytes: Option<Vec<u8>>,
1099    content_length: u64,
1100    requester_pubkey: Option<&str>,
1101    agent_uri: Option<&str>,
1102) -> Result<Response> {
1103    let r2_key = format!("pods/{owner_pubkey}{acl_path}");
1104
1105    // Derive the parent resource path: strip `.acl` suffix
1106    let parent_path = acl_path.strip_suffix(".acl").unwrap_or(acl_path);
1107    // Normalize empty parent to "/"
1108    let parent_path = if parent_path.is_empty() {
1109        "/"
1110    } else {
1111        parent_path
1112    };
1113
1114    // Resolve effective ACL for the parent to determine access
1115    let parent_acl = find_effective_acl(bucket, kv, owner_pubkey, parent_path).await;
1116
1117    match *method {
1118        Method::Get | Method::Head => {
1119            // Reading an ACL requires acl:Read on the parent OR acl:Control
1120            let can_read = evaluate_access(
1121                parent_acl.as_ref(),
1122                agent_uri,
1123                parent_path,
1124                AccessMode::Read,
1125            ) || evaluate_access(
1126                parent_acl.as_ref(),
1127                agent_uri,
1128                parent_path,
1129                AccessMode::Control,
1130            );
1131
1132            if !can_read {
1133                return if requester_pubkey.is_some() {
1134                    json_error(env, "Forbidden", 403)
1135                } else {
1136                    json_error(env, "Authentication required", 401)
1137                };
1138            }
1139
1140            let object = match bucket.get(&r2_key).execute().await? {
1141                Some(obj) => obj,
1142                None => return json_error(env, "No ACL document found", 404),
1143            };
1144
1145            let etag = object.etag();
1146            let cors = cors_headers(env);
1147
1148            if let Some(status) = conditional::check_preconditions(req_headers, &etag) {
1149                let resp = Response::empty()?.with_status(status).with_headers(cors);
1150                resp.headers().set("ETag", &format!("\"{etag}\"")).ok();
1151                return Ok(resp);
1152            }
1153
1154            if *method == Method::Head {
1155                let resp = Response::empty()?.with_headers(cors);
1156                resp.headers()
1157                    .set("Content-Type", "application/ld+json")
1158                    .ok();
1159                resp.headers().set("ETag", &format!("\"{etag}\"")).ok();
1160                add_cache_control(resp.headers(), acl_path);
1161                return Ok(resp);
1162            }
1163
1164            let body = object
1165                .body()
1166                .ok_or_else(|| Error::RustError("R2 object has no body".into()))?;
1167            let bytes = body.bytes().await?;
1168            let resp = Response::from_bytes(bytes)?.with_headers(cors);
1169            resp.headers()
1170                .set("Content-Type", "application/ld+json")
1171                .ok();
1172            resp.headers().set("ETag", &format!("\"{etag}\"")).ok();
1173            add_wac_allow(resp.headers(), parent_acl.as_ref(), agent_uri, parent_path);
1174            add_cache_control(resp.headers(), acl_path);
1175            Ok(resp)
1176        }
1177
1178        Method::Put => {
1179            // Writing an ACL requires acl:Control on the parent resource
1180            let has_control = evaluate_access(
1181                parent_acl.as_ref(),
1182                agent_uri,
1183                parent_path,
1184                AccessMode::Control,
1185            );
1186
1187            if !has_control {
1188                return if requester_pubkey.is_some() {
1189                    json_error(env, "acl:Control required to modify ACL", 403)
1190                } else {
1191                    json_error(env, "Authentication required", 401)
1192                };
1193            }
1194
1195            if content_length > MAX_BODY_SIZE {
1196                return json_error(
1197                    env,
1198                    &format!("Body exceeds {} byte limit", MAX_BODY_SIZE),
1199                    413,
1200                );
1201            }
1202
1203            let data = body_bytes.unwrap_or_default();
1204
1205            // Validate that the body is a valid ACL document (parseable JSON-LD)
1206            if serde_json::from_slice::<acl::AclDocument>(&data).is_err() {
1207                return json_error(
1208                    env,
1209                    "Invalid ACL document: must be valid JSON-LD with @graph",
1210                    422,
1211                );
1212            }
1213
1214            bucket
1215                .put(&r2_key, data)
1216                .http_metadata(HttpMetadata {
1217                    content_type: Some("application/ld+json".into()),
1218                    ..Default::default()
1219                })
1220                .execute()
1221                .await?;
1222
1223            let resp_body = serde_json::json!({ "status": "ok" });
1224            json_ok(env, &resp_body, 201)
1225        }
1226
1227        Method::Delete => {
1228            // Deleting an ACL requires acl:Control on the parent resource
1229            let has_control = evaluate_access(
1230                parent_acl.as_ref(),
1231                agent_uri,
1232                parent_path,
1233                AccessMode::Control,
1234            );
1235
1236            if !has_control {
1237                return if requester_pubkey.is_some() {
1238                    json_error(env, "acl:Control required to delete ACL", 403)
1239                } else {
1240                    json_error(env, "Authentication required", 401)
1241                };
1242            }
1243
1244            // Check it exists
1245            if bucket.get(&r2_key).execute().await?.is_none() {
1246                return json_error(env, "ACL document not found", 404);
1247            }
1248
1249            bucket.delete(&r2_key).await?;
1250
1251            let resp_body = serde_json::json!({ "status": "deleted" });
1252            json_ok(env, &resp_body, 200)
1253        }
1254
1255        _ => json_error(env, "Method not allowed on ACL resource", 405),
1256    }
1257}
1258
1259// ---------------------------------------------------------------------------
1260// WebID validation
1261// ---------------------------------------------------------------------------
1262
1263/// Validate that a byte slice is a valid WebID profile document.
1264///
1265/// Checks that the content is valid UTF-8 and contains embedded JSON-LD
1266/// (a `<script type="application/ld+json">` block).
1267fn validate_webid_html(data: &[u8]) -> Result<(), String> {
1268    let text =
1269        std::str::from_utf8(data).map_err(|_| "WebID profile must be valid UTF-8".to_string())?;
1270
1271    if !text.contains("application/ld+json") {
1272        return Err(
1273            "WebID profile must contain a <script type=\"application/ld+json\"> block".to_string(),
1274        );
1275    }
1276
1277    // Extract the JSON-LD content and verify it parses
1278    if let Some(start) = text.find("application/ld+json") {
1279        // Find the closing > of the script tag
1280        if let Some(tag_end) = text[start..].find('>') {
1281            let json_start = start + tag_end + 1;
1282            if let Some(script_end) = text[json_start..].find("</script>") {
1283                let json_str = text[json_start..json_start + script_end].trim();
1284                serde_json::from_str::<serde_json::Value>(json_str)
1285                    .map_err(|e| format!("Invalid JSON-LD in WebID profile: {e}"))?;
1286            }
1287        }
1288    }
1289
1290    Ok(())
1291}
1292
1293// ---------------------------------------------------------------------------
1294// NIP-98 webid tag extractor
1295// ---------------------------------------------------------------------------
1296
1297/// Extract the value of the `["webid", uri]` tag from a raw NIP-98
1298/// `Authorization: Nostr <base64>` header, if present.
1299///
1300/// The NIP-98 spec allows extension tags. When a client sends a `webid`
1301/// tag, we verify that the URI refers to the same identity as the signing
1302/// pubkey (via `did::verify_webid_tag`).
1303///
1304/// Returns `None` if the header is malformed, the event has no webid tag,
1305/// or base64 decoding fails — non-fatal; auth proceeds without webid check.
1306fn extract_webid_tag_from_header(auth_header: &str) -> Option<String> {
1307    let b64 = auth_header.strip_prefix("Nostr ")?;
1308    let bytes = base64::engine::general_purpose::STANDARD
1309        .decode(b64.trim())
1310        .ok()?;
1311    let event: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
1312    let tags = event.get("tags")?.as_array()?;
1313    for tag in tags {
1314        let arr = tag.as_array()?;
1315        if arr.first()?.as_str() == Some("webid") {
1316            if let Some(uri) = arr.get(1).and_then(|v| v.as_str()) {
1317                return Some(uri.to_string());
1318            }
1319        }
1320    }
1321    None
1322}
1323
1324// ---------------------------------------------------------------------------
1325// Payment config loader
1326// ---------------------------------------------------------------------------
1327
1328fn load_pay_config(env: &Env) -> payments::PayConfig {
1329    let enabled = env
1330        .var("PAY_ENABLED")
1331        .map(|v| {
1332            let s = v.to_string();
1333            s == "true" || s == "1"
1334        })
1335        .unwrap_or(false);
1336    let cost_sats = env
1337        .var("PAY_COST_SATS")
1338        .ok()
1339        .and_then(|v| v.to_string().parse().ok())
1340        .unwrap_or(1);
1341
1342    let token = env.var("PAY_TOKEN_TICKER").ok().map(|ticker_var| {
1343        let ticker = ticker_var.to_string();
1344        let rate = env
1345            .var("PAY_TOKEN_RATE")
1346            .ok()
1347            .and_then(|v| v.to_string().parse().ok())
1348            .unwrap_or(10);
1349        let supply = env
1350            .var("PAY_TOKEN_SUPPLY")
1351            .ok()
1352            .and_then(|v| v.to_string().parse().ok())
1353            .unwrap_or(1_000_000);
1354        let issuer = env
1355            .var("PAY_TOKEN_ISSUER")
1356            .ok()
1357            .map(|v| v.to_string())
1358            .unwrap_or_default();
1359        payments::TokenConfig {
1360            ticker,
1361            rate,
1362            supply,
1363            issuer,
1364        }
1365    });
1366
1367    payments::PayConfig {
1368        enabled,
1369        cost_sats,
1370        token,
1371        chains: vec![
1372            payments::ChainConfig::bitcoin_mainnet(),
1373            payments::ChainConfig::bitcoin_testnet4(),
1374            payments::ChainConfig::bitcoin_signet(),
1375        ],
1376    }
1377}
1378
1379// ---------------------------------------------------------------------------
1380// Admin check helper
1381// ---------------------------------------------------------------------------
1382
1383/// Check if a pubkey is an admin user via the shared D1 database.
1384///
1385/// Queries `members.is_admin` then falls back to `whitelist.is_admin`,
1386/// matching the auth-worker's `admin::is_admin` logic. Uses the `REPLAY_DB`
1387/// binding which points at the same D1 database as the auth-worker's `DB`.
1388async fn is_admin_user(env: &Env, pubkey: &str) -> bool {
1389    #[derive(serde::Deserialize)]
1390    struct IsAdminRow {
1391        is_admin: i32,
1392    }
1393
1394    let db = match env.d1("REPLAY_DB") {
1395        Ok(db) => db,
1396        Err(_) => return false,
1397    };
1398
1399    if let Ok(stmt) = db
1400        .prepare("SELECT is_admin FROM members WHERE pubkey = ?1")
1401        .bind(&[wasm_bindgen::JsValue::from_str(pubkey)])
1402    {
1403        if let Ok(Some(row)) = stmt.first::<IsAdminRow>(None).await {
1404            if row.is_admin == 1 {
1405                return true;
1406            }
1407        }
1408    }
1409
1410    if let Ok(stmt) = db
1411        .prepare("SELECT is_admin FROM whitelist WHERE pubkey = ?1")
1412        .bind(&[wasm_bindgen::JsValue::from_str(pubkey)])
1413    {
1414        if let Ok(Some(row)) = stmt.first::<IsAdminRow>(None).await {
1415            return row.is_admin == 1;
1416        }
1417    }
1418
1419    false
1420}
1421
1422// ---------------------------------------------------------------------------
1423// Unit tests (route parsing only -- full integration requires wasm-bindgen)
1424// ---------------------------------------------------------------------------
1425
1426#[cfg(test)]
1427mod tests {
1428    use super::*;
1429
1430    #[test]
1431    fn parse_pod_route_valid() {
1432        let pubkey = "a".repeat(64);
1433        let path = format!("/pods/{pubkey}/profile/card");
1434        let (pk, rp) = parse_pod_route(&path).unwrap();
1435        assert_eq!(pk, pubkey);
1436        assert_eq!(rp, "/profile/card");
1437    }
1438
1439    #[test]
1440    fn parse_pod_route_root() {
1441        let pubkey = "b".repeat(64);
1442        let path = format!("/pods/{pubkey}");
1443        let (pk, rp) = parse_pod_route(&path).unwrap();
1444        assert_eq!(pk, pubkey);
1445        assert_eq!(rp, "/");
1446    }
1447
1448    #[test]
1449    fn parse_pod_route_with_trailing_slash() {
1450        let pubkey = "c".repeat(64);
1451        let path = format!("/pods/{pubkey}/");
1452        let (pk, rp) = parse_pod_route(&path).unwrap();
1453        assert_eq!(pk, pubkey);
1454        assert_eq!(rp, "/");
1455    }
1456
1457    #[test]
1458    fn parse_pod_route_invalid_hex() {
1459        let path = format!("/pods/{}/file", "x".repeat(64));
1460        assert!(parse_pod_route(&path).is_none());
1461    }
1462
1463    #[test]
1464    fn parse_pod_route_short_pubkey() {
1465        assert!(parse_pod_route("/pods/abc/file").is_none());
1466    }
1467
1468    #[test]
1469    fn parse_pod_route_wrong_prefix() {
1470        assert!(parse_pod_route("/api/something").is_none());
1471    }
1472
1473    #[test]
1474    fn parse_pod_route_no_slash_after_pubkey() {
1475        let pubkey = "d".repeat(64);
1476        let path = format!("/pods/{pubkey}extra");
1477        assert!(parse_pod_route(&path).is_none());
1478    }
1479
1480    #[test]
1481    fn parse_pod_route_container_path() {
1482        let pubkey = "e".repeat(64);
1483        let path = format!("/pods/{pubkey}/media/");
1484        let (pk, rp) = parse_pod_route(&path).unwrap();
1485        assert_eq!(pk, pubkey);
1486        assert_eq!(rp, "/media/");
1487    }
1488
1489    #[test]
1490    fn is_acl_path_detects_acl_suffix() {
1491        assert!(is_acl_path("/public/.acl"));
1492        assert!(is_acl_path("/.acl"));
1493        assert!(is_acl_path("/profile/card.acl"));
1494        assert!(!is_acl_path("/public/"));
1495        assert!(!is_acl_path("/profile/card"));
1496        assert!(!is_acl_path("/acl/resource"));
1497    }
1498
1499    #[test]
1500    fn is_provision_path_detects_endpoint() {
1501        assert!(is_provision_path("/.provision"));
1502        assert!(!is_provision_path("/provision"));
1503        assert!(!is_provision_path("/.provision/extra"));
1504        assert!(!is_provision_path("/public/.provision"));
1505    }
1506
1507    #[test]
1508    fn validate_webid_html_accepts_valid() {
1509        let html = r##"<!DOCTYPE html>
1510<html>
1511<head>
1512  <script type="application/ld+json">
1513  {"@context": {"foaf": "http://xmlns.com/foaf/0.1/"}, "@id": "#me", "@type": "foaf:Person"}
1514  </script>
1515</head>
1516<body></body>
1517</html>"##;
1518        assert!(validate_webid_html(html.as_bytes()).is_ok());
1519    }
1520
1521    #[test]
1522    fn validate_webid_html_rejects_no_jsonld() {
1523        let html = "<!DOCTYPE html><html><body>No JSON-LD here</body></html>";
1524        assert!(validate_webid_html(html.as_bytes()).is_err());
1525    }
1526
1527    #[test]
1528    fn validate_webid_html_rejects_invalid_utf8() {
1529        let bad_bytes: &[u8] = &[0xff, 0xfe, 0xfd];
1530        assert!(validate_webid_html(bad_bytes).is_err());
1531    }
1532
1533    #[test]
1534    fn validate_webid_html_rejects_invalid_jsonld() {
1535        let html = r##"<!DOCTYPE html>
1536<html>
1537<head>
1538  <script type="application/ld+json">
1539  {not valid json}
1540  </script>
1541</head>
1542<body></body>
1543</html>"##;
1544        assert!(validate_webid_html(html.as_bytes()).is_err());
1545    }
1546}