Compare commits

...

2 Commits

9 changed files with 57 additions and 19 deletions
+17 -1
View File
@@ -60,7 +60,13 @@ See [spec](spec) for more details
## API Routes ## API Routes
All routes are NIP-98 protected. Most API routes are NIP-98 protected.
Public exceptions:
- `GET /plans`
- `GET /plans/:id`
- `POST /stripe/webhook` (validated with Stripe signatures instead)
- `GET /identity` — get auth identity (`pubkey`, `is_admin`) - `GET /identity` — get auth identity (`pubkey`, `is_admin`)
- `GET /tenants` — list tenants (admin) - `GET /tenants` — list tenants (admin)
@@ -73,3 +79,13 @@ All routes are NIP-98 protected.
- `PUT /relays/:id` — update relay (admin or relay tenant) - `PUT /relays/:id` — update relay (admin or relay tenant)
- `POST /relays/:id/deactivate` — deactivate relay (admin or relay tenant) - `POST /relays/:id/deactivate` — deactivate relay (admin or relay tenant)
- `GET /invoices` — list invoices (`?tenant=<pubkey>` allowed for admin only) - `GET /invoices` — list invoices (`?tenant=<pubkey>` allowed for admin only)
## API Auth Model
Caravel intentionally uses a session-style variant of NIP-98 for client-to-backend API auth.
- Frontend signs one kind `27235` event with `u = VITE_API_URL` and caches that header for about 10 minutes.
- Backend verifies event kind, signature, and that `u` contains configured `HOST`.
- Backend intentionally does not bind auth to exact request URL/method/query, and does not enforce payload hash, timestamp freshness window, or replay cache.
- Goal: reduce repeated wallet signing prompts and avoid cookie-based sessions.
- Tradeoff: this is weaker request-intent binding than strict NIP-98 semantics.
+5 -3
View File
@@ -184,9 +184,11 @@ Notes:
## `extract_auth_pubkey(&self, headers: &HeaderMap) -> Result<String>` ## `extract_auth_pubkey(&self, headers: &HeaderMap) -> Result<String>`
- Parses `Authorization` header - Parses `Authorization` header
- Validates event kind and signature using `nostr_sdk` - Validates event kind (`27235`) and signature using `nostr_sdk`
- Validates event `u` against `HOST` (not the request path. Non-standard, but correct) - Validates event `u` contains configured `HOST`
- Does not validate `method` tag - Intentionally does **not** enforce exact request URL/method/query matching
- Intentionally does **not** validate `payload` tag/hash, `created_at` freshness window, or replay nonce/cache
- This is a deliberate session-style tradeoff to reduce repeated signer prompts in the client
- Returns pubkey if header all checks pass - Returns pubkey if header all checks pass
Refer to https://github.com/nostr-protocol/nips/blob/master/98.md for details. Use `nostr_sdk` functionality where possible. Refer to https://github.com/nostr-protocol/nips/blob/master/98.md for details. Use `nostr_sdk` functionality where possible.
+1 -1
View File
@@ -19,7 +19,7 @@ Members:
## `async fn handle_activity(&self, activity: &Activity)` ## `async fn handle_activity(&self, activity: &Activity)`
- For `create_relay`, `update_relay`, or `deactivate_relay` activity, calls `sync_and_report`. - For `create_relay`, `update_relay`, `activate_relay`, or `deactivate_relay` activity, calls `sync_and_report`.
- All other activity types are ignored (e.g. `fail_relay_sync`, `complete_relay_sync`). - All other activity types are ignored (e.g. `fail_relay_sync`, `complete_relay_sync`).
## `async fn sync_and_report(&self, relay: &Relay, is_new: bool)` ## `async fn sync_and_report(&self, relay: &Relay, is_new: bool)`
+3
View File
@@ -179,6 +179,9 @@ impl Api {
return Err(ApiError::Unauthorized(anyhow!("missing u tag"))); return Err(ApiError::Unauthorized(anyhow!("missing u tag")));
}; };
// Intentional session-style variant of NIP-98 for Caravel API auth.
// We validate signer identity plus host affinity, and do not bind to exact
// request URL/method or maintain replay state here.
if !self.host.is_empty() && !got_u.contains(&self.host) { if !self.host.is_empty() && !got_u.contains(&self.host) {
return Err(ApiError::Unauthorized(anyhow!( return Err(ApiError::Unauthorized(anyhow!(
"authorization host mismatch" "authorization host mismatch"
-3
View File
@@ -901,10 +901,7 @@ mod tests {
&unknown_status_paid &unknown_status_paid
)); ));
} }
}
#[cfg(test)]
mod tests {
use super::*; use super::*;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
+1 -2
View File
@@ -209,8 +209,7 @@ impl Command {
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
let activity = let activity = Self::insert_activity(&mut tx, activity_type, "relay", relay_id).await?;
Self::insert_activity(&mut tx, "deactivate_relay", "relay", &relay_id).await?;
tx.commit().await?; tx.commit().await?;
self.emit(activity); self.emit(activity);
+23 -7
View File
@@ -56,10 +56,7 @@ impl Infra {
} }
async fn handle_activity(&self, activity: &Activity) -> Result<()> { async fn handle_activity(&self, activity: &Activity) -> Result<()> {
let needs_sync = matches!( let needs_sync = should_sync_relay_activity(activity.activity_type.as_str());
activity.activity_type.as_str(),
"create_relay" | "update_relay" | "deactivate_relay"
);
if needs_sync { if needs_sync {
let Some(relay) = self.query.get_relay(&activity.resource_id).await? else { let Some(relay) = self.query.get_relay(&activity.resource_id).await? else {
@@ -93,7 +90,9 @@ impl Infra {
async fn nip98_auth(&self, url: &str, method: HttpMethod) -> Result<String> { async fn nip98_auth(&self, url: &str, method: HttpMethod) -> Result<String> {
let keys = Keys::parse(&self.api_secret)?; let keys = Keys::parse(&self.api_secret)?;
let server_url = Url::parse(url)?; let server_url = Url::parse(url)?;
let auth = HttpData::new(server_url, method).to_authorization(&keys).await?; let auth = HttpData::new(server_url, method)
.to_authorization(&keys)
.await?;
Ok(auth) Ok(auth)
} }
@@ -150,11 +149,21 @@ impl Infra {
let response = if is_new { let response = if is_new {
let url = format!("{}/relay/{}", base, relay.id); let url = format!("{}/relay/{}", base, relay.id);
let auth = self.nip98_auth(&url, HttpMethod::POST).await?; let auth = self.nip98_auth(&url, HttpMethod::POST).await?;
client.post(&url).header("Authorization", auth).json(&body).send().await? client
.post(&url)
.header("Authorization", auth)
.json(&body)
.send()
.await?
} else { } else {
let url = format!("{}/relay/{}", base, relay.id); let url = format!("{}/relay/{}", base, relay.id);
let auth = self.nip98_auth(&url, HttpMethod::PUT).await?; let auth = self.nip98_auth(&url, HttpMethod::PUT).await?;
client.put(&url).header("Authorization", auth).json(&body).send().await? client
.put(&url)
.header("Authorization", auth)
.json(&body)
.send()
.await?
}; };
if !response.status().is_success() { if !response.status().is_success() {
@@ -165,3 +174,10 @@ impl Infra {
Ok(()) Ok(())
} }
} }
fn should_sync_relay_activity(activity_type: &str) -> bool {
matches!(
activity_type,
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay"
)
}
+5 -2
View File
@@ -51,8 +51,11 @@ npm run preview
## Authentication ## Authentication
- Tenant requests use NIP-98 tokens derived from the logged-in user - Tenant requests use an intentional session-style variant of NIP-98:
- Admin routes require a pubkey listed in `PLATFORM_ADMIN_PUBKEYS` on the backend - The client signs one kind `27235` event with `u = VITE_API_URL`.
- The resulting `Authorization` header is cached for about 10 minutes to avoid repeated signer prompts.
- The backend validates signer identity + host affinity rather than exact URL/method binding per request.
- Admin routes require a pubkey listed in `ADMINS` on the backend.
## Routes ## Routes
+2
View File
@@ -145,6 +145,8 @@ export async function makeAuth(): Promise<string | undefined> {
kind: 27235, kind: 27235,
content: "", content: "",
created_at: Math.floor(now / 1000), created_at: Math.floor(now / 1000),
// Intentional session-style auth: sign the API base URL once, then reuse
// the header briefly to avoid prompting the signer on every request.
tags: [["u", API_URL]], tags: [["u", API_URL]],
}) })