forked from coracle/caravel
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 57e21cf51d | |||
| 85d37f53ce |
+17
-1
@@ -60,7 +60,13 @@ See [spec](spec) for more details
|
||||
|
||||
## 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 /tenants` — list tenants (admin)
|
||||
@@ -73,3 +79,13 @@ All routes are NIP-98 protected.
|
||||
- `PUT /relays/:id` — update 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)
|
||||
|
||||
## 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
@@ -184,9 +184,11 @@ Notes:
|
||||
## `extract_auth_pubkey(&self, headers: &HeaderMap) -> Result<String>`
|
||||
|
||||
- Parses `Authorization` header
|
||||
- Validates event kind and signature using `nostr_sdk`
|
||||
- Validates event `u` against `HOST` (not the request path. Non-standard, but correct)
|
||||
- Does not validate `method` tag
|
||||
- Validates event kind (`27235`) and signature using `nostr_sdk`
|
||||
- Validates event `u` contains configured `HOST`
|
||||
- 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
|
||||
|
||||
Refer to https://github.com/nostr-protocol/nips/blob/master/98.md for details. Use `nostr_sdk` functionality where possible.
|
||||
|
||||
@@ -19,7 +19,7 @@ Members:
|
||||
|
||||
## `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`).
|
||||
|
||||
## `async fn sync_and_report(&self, relay: &Relay, is_new: bool)`
|
||||
|
||||
@@ -179,6 +179,9 @@ impl Api {
|
||||
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) {
|
||||
return Err(ApiError::Unauthorized(anyhow!(
|
||||
"authorization host mismatch"
|
||||
|
||||
@@ -901,10 +901,7 @@ mod tests {
|
||||
&unknown_status_paid
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use sqlx::SqlitePool;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
|
||||
@@ -209,8 +209,7 @@ impl Command {
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let activity =
|
||||
Self::insert_activity(&mut tx, "deactivate_relay", "relay", &relay_id).await?;
|
||||
let activity = Self::insert_activity(&mut tx, activity_type, "relay", relay_id).await?;
|
||||
|
||||
tx.commit().await?;
|
||||
self.emit(activity);
|
||||
|
||||
+23
-7
@@ -56,10 +56,7 @@ impl Infra {
|
||||
}
|
||||
|
||||
async fn handle_activity(&self, activity: &Activity) -> Result<()> {
|
||||
let needs_sync = matches!(
|
||||
activity.activity_type.as_str(),
|
||||
"create_relay" | "update_relay" | "deactivate_relay"
|
||||
);
|
||||
let needs_sync = should_sync_relay_activity(activity.activity_type.as_str());
|
||||
|
||||
if needs_sync {
|
||||
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> {
|
||||
let keys = Keys::parse(&self.api_secret)?;
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -150,11 +149,21 @@ impl Infra {
|
||||
let response = if is_new {
|
||||
let url = format!("{}/relay/{}", base, relay.id);
|
||||
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 {
|
||||
let url = format!("{}/relay/{}", base, relay.id);
|
||||
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() {
|
||||
@@ -165,3 +174,10 @@ impl Infra {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn should_sync_relay_activity(activity_type: &str) -> bool {
|
||||
matches!(
|
||||
activity_type,
|
||||
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay"
|
||||
)
|
||||
}
|
||||
|
||||
+5
-2
@@ -51,8 +51,11 @@ npm run preview
|
||||
|
||||
## Authentication
|
||||
|
||||
- Tenant requests use NIP-98 tokens derived from the logged-in user
|
||||
- Admin routes require a pubkey listed in `PLATFORM_ADMIN_PUBKEYS` on the backend
|
||||
- Tenant requests use an intentional session-style variant of NIP-98:
|
||||
- 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
|
||||
|
||||
|
||||
@@ -145,6 +145,8 @@ export async function makeAuth(): Promise<string | undefined> {
|
||||
kind: 27235,
|
||||
content: "",
|
||||
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]],
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user