Compare commits

..

2 Commits

10 changed files with 44 additions and 112 deletions
+1 -17
View File
@@ -60,13 +60,7 @@ See [spec](spec) for more details
## API Routes
Most API routes are NIP-98 protected.
Public exceptions:
- `GET /plans`
- `GET /plans/:id`
- `POST /stripe/webhook` (validated with Stripe signatures instead)
All routes are NIP-98 protected.
- `GET /identity` — get auth identity (`pubkey`, `is_admin`)
- `GET /tenants` — list tenants (admin)
@@ -79,13 +73,3 @@ Public exceptions:
- `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.
+3 -5
View File
@@ -184,11 +184,9 @@ Notes:
## `extract_auth_pubkey(&self, headers: &HeaderMap) -> Result<String>`
- Parses `Authorization` header
- 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
- 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
- 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.
+1 -1
View File
@@ -19,7 +19,7 @@ Members:
## `async fn handle_activity(&self, activity: &Activity)`
- For `create_relay`, `update_relay`, `activate_relay`, or `deactivate_relay` activity, calls `sync_and_report`.
- For `create_relay`, `update_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)`
-3
View File
@@ -179,9 +179,6 @@ 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"
+3
View File
@@ -901,7 +901,10 @@ mod tests {
&unknown_status_paid
));
}
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::SqlitePool;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
+2 -1
View File
@@ -209,7 +209,8 @@ impl Command {
.execute(&mut *tx)
.await?;
let activity = Self::insert_activity(&mut tx, activity_type, "relay", relay_id).await?;
let activity =
Self::insert_activity(&mut tx, "deactivate_relay", "relay", &relay_id).await?;
tx.commit().await?;
self.emit(activity);
+7 -23
View File
@@ -56,7 +56,10 @@ impl Infra {
}
async fn handle_activity(&self, activity: &Activity) -> Result<()> {
let needs_sync = should_sync_relay_activity(activity.activity_type.as_str());
let needs_sync = matches!(
activity.activity_type.as_str(),
"create_relay" | "update_relay" | "deactivate_relay"
);
if needs_sync {
let Some(relay) = self.query.get_relay(&activity.resource_id).await? else {
@@ -90,9 +93,7 @@ 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)
}
@@ -149,21 +150,11 @@ 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() {
@@ -174,10 +165,3 @@ impl Infra {
Ok(())
}
}
fn should_sync_relay_activity(activity_type: &str) -> bool {
matches!(
activity_type,
"create_relay" | "update_relay" | "activate_relay" | "deactivate_relay"
)
}
+2 -5
View File
@@ -51,11 +51,8 @@ npm run preview
## Authentication
- 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.
- 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
## Routes
+25 -55
View File
@@ -6,7 +6,6 @@ import { getInvoice, getInvoiceBolt11 } from "@/lib/api"
import { tenantNeedsPaymentSetup } from "@/lib/hooks"
type PayStatus = "idle" | "loading" | "success" | "error"
type Bolt11Status = "idle" | "loading" | "ready" | "error"
type PaymentInvoice = {
id: string
@@ -22,34 +21,20 @@ type PaymentDialogProps = {
export default function PaymentDialog(props: PaymentDialogProps) {
const [bolt11, setBolt11] = createSignal("")
const [qrDataUrl, setQrDataUrl] = createSignal("")
const [bolt11Status, setBolt11Status] = createSignal<Bolt11Status>("idle")
const [bolt11Error, setBolt11Error] = createSignal("")
const [payStatus, setPayStatus] = createSignal<PayStatus>("idle")
const [payError, setPayError] = createSignal("")
const [showSetup, setShowSetup] = createSignal(false)
const [showPaymentSetup, setShowPaymentSetup] = createSignal(false)
async function loadBolt11() {
if (!props.invoice.id) return
setBolt11Status("loading")
setBolt11Error("")
setBolt11("")
setQrDataUrl("")
createEffect(async () => {
if (!props.open || !props.invoice.id) return
try {
const { bolt11: invoice } = await getInvoiceBolt11(props.invoice.id)
setBolt11(invoice)
setQrDataUrl(await QRCode.toDataURL(invoice, { width: 256, margin: 2 }))
setBolt11Status("ready")
} catch (e) {
setBolt11Status("error")
setBolt11Error(e instanceof Error ? e.message : "Failed to generate Lightning invoice")
} catch {
// bolt11 generation may fail
}
}
createEffect(() => {
if (!props.open || !props.invoice.id) return
void loadBolt11()
})
function copyBolt11() {
@@ -77,8 +62,6 @@ export default function PaymentDialog(props: PaymentDialogProps) {
function handleClose() {
setPayStatus("idle")
setPayError("")
setBolt11Status("idle")
setBolt11Error("")
setBolt11("")
setQrDataUrl("")
setShowSetup(false)
@@ -121,46 +104,33 @@ export default function PaymentDialog(props: PaymentDialogProps) {
when={payStatus() === "success"}
fallback={
<div class="w-full space-y-3">
<Show when={bolt11Status() === "idle" || bolt11Status() === "loading"}>
<div class="flex items-center justify-center py-12 text-sm text-gray-400">Generating invoice...</div>
<Show
when={qrDataUrl()}
fallback={<div class="flex items-center justify-center py-12 text-sm text-gray-400">Generating invoice...</div>}
>
<img src={qrDataUrl()} alt="Lightning invoice QR code" class="mx-auto rounded-lg" />
</Show>
<Show when={bolt11Status() === "error"}>
<div class="rounded-lg border border-red-200 bg-red-50 p-4">
<p class="text-sm font-medium text-red-700">Unable to generate invoice</p>
<p class="mt-1 text-xs text-red-600 wrap-break-word">{bolt11Error()}</p>
<Show when={bolt11()}>
<div class="flex rounded-lg border border-gray-300">
<input
type="text"
readOnly
value={bolt11()}
class="min-w-0 flex-1 rounded-l-lg border-0 px-3 py-2 text-xs text-gray-500 bg-transparent focus:outline-none"
/>
<button
type="button"
onClick={() => void loadBolt11()}
class="mt-3 inline-flex items-center rounded-lg bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700"
class="flex items-center px-3 text-gray-400 hover:text-gray-700"
onClick={copyBolt11}
title="Copy invoice"
>
Retry
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
</button>
</div>
</Show>
<Show when={bolt11Status() === "ready"}>
<img src={qrDataUrl()} alt="Lightning invoice QR code" class="mx-auto rounded-lg" />
<Show when={bolt11()}>
<div class="flex rounded-lg border border-gray-300">
<input
type="text"
readOnly
value={bolt11()}
class="min-w-0 flex-1 rounded-l-lg border-0 px-3 py-2 text-xs text-gray-500 bg-transparent focus:outline-none"
/>
<button
type="button"
class="flex items-center px-3 text-gray-400 hover:text-gray-700"
onClick={copyBolt11}
title="Copy invoice"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
</svg>
</button>
</div>
</Show>
</Show>
</div>
}
>
@@ -218,7 +188,7 @@ export default function PaymentDialog(props: PaymentDialogProps) {
<button
type="button"
onClick={checkPayment}
disabled={payStatus() === "loading" || bolt11Status() !== "ready"}
disabled={payStatus() === "loading"}
class="py-2 px-4 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{payStatus() === "loading" ? "Checking..." : "Complete Payment"}
-2
View File
@@ -145,8 +145,6 @@ 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]],
})