Compare commits

...

10 Commits

Author SHA1 Message Date
userAdityaa 41602e21c2 feat: display relay provisioning errors in UI 2026-04-20 18:08:38 +00:00
hodlbod c47727b909 Merge pull request 'Add tenant create endpoint' (#27) from create-tenant into master 2026-04-20 15:56:03 +00:00
Jon Staab 0705da8b09 Add tenant create endpoint 2026-04-20 15:55:56 +00:00
userAdityaa ca26d41eef fix: relay secret rotation on infra sync updates (#26)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-18 13:24:08 +00:00
userAdityaa 44f9928070 fix: make stripe webhooks explicitly toggleable with mandatory secret validation (#23)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-17 22:57:37 +00:00
Jon Staab 87dcf53d74 Change default backend port 2026-04-17 13:23:26 -07:00
userAdityaa bcbce5c058 chore: replace placeholder letter badges with actual SVG logos (#24)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-17 18:36:35 +00:00
userAdityaa 90e488d87e feat: add Nostrord to recommended apps (#22)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-17 15:23:20 +00:00
userAdityaa 334f05783f chore: harden relay plan validation to prevent billing bypass and plan-state drift (#20)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-16 21:35:43 +00:00
userAdityaa 145b511f9d docs(auth): document intentional session-style NIP-98 model (#16)
Co-authored-by: userAdityaa <aditya.chaudhary1558@gmail.com>
Co-committed-by: userAdityaa <aditya.chaudhary1558@gmail.com>
2026-04-16 15:40:50 +00:00
27 changed files with 472 additions and 173 deletions
+2 -2
View File
@@ -53,7 +53,7 @@ The rest of the defaults work as-is. `ROBOT_*`, `LIVEKIT_*`, billing, and Stripe
cp frontend/.env.template frontend/.env cp frontend/.env.template frontend/.env
``` ```
The defaults (`VITE_API_URL=http://127.0.0.1:3000`) point at the backend and work out of the box. The defaults (`VITE_API_URL=http://127.0.0.1:2892`) point at the backend and work out of the box.
### 4. Install dependencies and run ### 4. Install dependencies and run
@@ -62,7 +62,7 @@ cd frontend && bun install && cd ..
just dev just dev
``` ```
This starts the backend (with auto-reload on file changes) at `http://127.0.0.1:3000` and the frontend at `http://127.0.0.1:5173`. This starts the backend (with auto-reload on file changes) at `http://127.0.0.1:2892` and the frontend at `http://127.0.0.1:5173`.
## Project docs ## Project docs
+3 -3
View File
@@ -1,6 +1,6 @@
# Server # Server
HOST=127.0.0.1 HOST=127.0.0.1
PORT=3000 PORT=2892
ALLOW_ORIGINS= # Optional comma-separated allowed CORS origins; empty = permissive ALLOW_ORIGINS= # Optional comma-separated allowed CORS origins; empty = permissive
# Auth # Auth
@@ -28,5 +28,5 @@ LIVEKIT_API_SECRET=
# Billing # Billing
NWC_URL= # Nostr Wallet Connect URL for generating Lightning invoices NWC_URL= # Nostr Wallet Connect URL for generating Lightning invoices
STRIPE_SECRET_KEY= # Stripe API secret key (sk_...) STRIPE_SECRET_KEY= # Required Stripe API secret key (sk_...)
STRIPE_WEBHOOK_SECRET= # Secret for verifying Stripe webhook signatures (whsec_...) STRIPE_WEBHOOK_SECRET=whsec_test_00000000000000000000000000 # Webhook signing secret (use real value in production)
+1 -1
View File
@@ -26,6 +26,6 @@ WORKDIR /app
COPY --from=build /app/target/release/backend /app/backend COPY --from=build /app/target/release/backend /app/backend
EXPOSE 3000 EXPOSE 2892
CMD ["/app/backend"] CMD ["/app/backend"]
+42 -24
View File
@@ -30,27 +30,29 @@ backend/
Environment variables: Environment variables:
| Variable | Description | Default | | Variable | Description | Default |
|---|---|---| | ------------------------ | ----------------------------------------------------------------------- | ------------------------------------ |
| `DATABASE_URL` | SQLite URL. Relative paths are resolved under `backend/`. | `sqlite://<backend>/data/caravel.db` | | `DATABASE_URL` | SQLite URL. Relative paths are resolved under `backend/`. | `sqlite://<backend>/data/caravel.db` |
| `HOST` | API bind host (also used for NIP-98 `u` host check) | `127.0.0.1` | | `HOST` | API bind host (also used for NIP-98 `u` host check) | `127.0.0.1` |
| `PORT` | API bind port | `3000` | | `PORT` | API bind port | `2892` |
| `ADMINS` | Comma-separated admin pubkeys (hex) | _optional_ | | `ADMINS` | Comma-separated admin pubkeys (hex) | _optional_ |
| `ALLOW_ORIGINS` | Comma-separated CORS origins. If empty, CORS is permissive. | _optional_ | | `ALLOW_ORIGINS` | Comma-separated CORS origins. If empty, CORS is permissive. | _optional_ |
| `ZOOID_API_URL` | Zooid API base URL used by infra worker | _required for infra sync_ | | `ZOOID_API_URL` | Zooid API base URL used by infra worker | _required for infra sync_ |
| `ZOOID_API_SECRET` | Nostr secret key used for authentication of requests to the zooid API | _required_ | | `ZOOID_API_SECRET` | Nostr secret key used for authentication of requests to the zooid API | _required_ |
| `RELAY_DOMAIN` | Base domain appended to relay subdomains | empty | | `RELAY_DOMAIN` | Base domain appended to relay subdomains | empty |
| `LIVEKIT_URL` | LiveKit URL sent to zooid when relay livekit is enabled | _optional_ | | `LIVEKIT_URL` | LiveKit URL sent to zooid when relay livekit is enabled | _optional_ |
| `LIVEKIT_API_KEY` | LiveKit API key sent to zooid | _optional_ | | `LIVEKIT_API_KEY` | LiveKit API key sent to zooid | _optional_ |
| `LIVEKIT_API_SECRET` | LiveKit API secret sent to zooid | _optional_ | | `LIVEKIT_API_SECRET` | LiveKit API secret sent to zooid | _optional_ |
| `NWC_URL` | Platform NWC URL used to generate BOLT11 invoices | _required for invoice generation_ | | `NWC_URL` | Platform NWC URL used to generate BOLT11 invoices | _required for invoice generation_ |
| `ROBOT_SECRET` | Robot Nostr secret key | _required_ | | `STRIPE_SECRET_KEY` | Stripe API secret key used for billing API operations | _required_ |
| `ROBOT_NAME` | Robot display name (kind `0`) | _optional_ | | `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret used to verify `Stripe-Signature` headers | _required_ |
| `ROBOT_DESCRIPTION` | Robot description (kind `0`) | _optional_ | | `ROBOT_SECRET` | Robot Nostr secret key | _required_ |
| `ROBOT_PICTURE` | Robot picture URL (kind `0`) | _optional_ | | `ROBOT_NAME` | Robot display name (kind `0`) | _optional_ |
| `ROBOT_OUTBOX_RELAYS` | Comma-separated relays published as kind `10002` | _required_ | | `ROBOT_DESCRIPTION` | Robot description (kind `0`) | _optional_ |
| `ROBOT_INDEXER_RELAYS` | Comma-separated relays used for recipient relay discovery | _required_ | | `ROBOT_PICTURE` | Robot picture URL (kind `0`) | _optional_ |
| `ROBOT_MESSAGING_RELAYS` | Comma-separated relays published as kind `10050` | _required_ | | `ROBOT_OUTBOX_RELAYS` | Comma-separated relays published as kind `10002` | _required_ |
| `ROBOT_INDEXER_RELAYS` | Comma-separated relays used for recipient relay discovery | _required_ |
| `ROBOT_MESSAGING_RELAYS` | Comma-separated relays published as kind `10050` | _required_ |
Relay list env vars are comma-separated and trimmed. If a relay has no `ws://` or `wss://` scheme, `wss://` is prepended. Relay list env vars are comma-separated and trimmed. If a relay has no `ws://` or `wss://` scheme, `wss://` is prepended.
@@ -60,11 +62,17 @@ See [spec](spec) for more details
## API Routes ## API Routes
All routes are NIP-98 protected. Most API routes are NIP-98 protected.
- `GET /identity` — get auth identity (`pubkey`, `is_admin`) Public exceptions:
- `GET /plans`
- `GET /plans/:id`
- `POST /stripe/webhook` (validated with Stripe signatures)
- `GET /identity` — get auth identity (`pubkey`, `is_admin`); side-effect-free
- `GET /tenants` — list tenants (admin) - `GET /tenants` — list tenants (admin)
- `POST /tenants`create current auth pubkey as tenant - `POST /tenants`idempotently ensure a tenant row exists for the current auth pubkey (creates Stripe customer + tenant on first call, returns existing tenant otherwise)
- `GET /tenants/:pubkey` — get tenant (admin or same tenant) - `GET /tenants/:pubkey` — get tenant (admin or same tenant)
- `PUT /tenants/:pubkey/billing` — update tenant `nwc_url` (admin or same tenant) - `PUT /tenants/:pubkey/billing` — update tenant `nwc_url` (admin or same tenant)
- `GET /relays` — list relays (`?tenant=<pubkey>` allowed for admin only) - `GET /relays` — list relays (`?tenant=<pubkey>` allowed for admin only)
@@ -73,3 +81,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.
+23 -8
View File
@@ -46,9 +46,8 @@ Notes:
- Serves `GET /identity` - Serves `GET /identity`
- Authorizes anyone, but must be authorized - Authorizes anyone, but must be authorized
- If a tenant for the identity doesn't exist: - Side-effect-free: returns `{ pubkey, is_admin }` only
- Call the Stripe API to create a new customer - Clients must call `POST /tenants` before any tenant-scoped write
- Create a new tenant using `command.create_tenant` with payload and `stripe_customer_id`. No subscription is created yet — that happens when the first relay is added.
- Return `data` is an `Identity` struct - Return `data` is an `Identity` struct
--- Tenant routes --- Tenant routes
@@ -59,6 +58,18 @@ Notes:
- Authorizes admin only - Authorizes admin only
- Return `data` is a list of tenant structs from `query.list_tenants` - Return `data` is a list of tenant structs from `query.list_tenants`
## `async fn create_tenant(...) -> Response`
- Serves `POST /tenants`
- Authorizes anyone, but must be authorized
- No request body; target pubkey is derived from NIP-98 auth
- Idempotent: if a tenant already exists for the auth pubkey, return it without calling Stripe or writing to the DB
- Otherwise, call the Stripe API to create a new customer and create a new tenant using `command.create_tenant` with the resulting `stripe_customer_id`. No subscription is created yet — that happens when the first relay is added.
- On unique-constraint race (`pubkey-exists`), re-fetch and return the existing tenant
- If Stripe customer creation fails, return `code=stripe-customer-create-failed`
- Always returns `200` (create-or-get is uniform)
- Return `data` is a single `Tenant` struct
## `async fn get_tenant(...) -> Response` ## `async fn get_tenant(...) -> Response`
- Serves `GET /tenants/:pubkey` - Serves `GET /tenants/:pubkey`
@@ -178,15 +189,18 @@ Notes:
- Reads raw request body and `Stripe-Signature` header - Reads raw request body and `Stripe-Signature` header
- Calls `billing.handle_webhook(payload, signature)` - Calls `billing.handle_webhook(payload, signature)`
- Returns `200` on success, `400` on signature verification failure - Returns `200` on success, `400` on signature verification failure
- Startup requires non-empty `STRIPE_WEBHOOK_SECRET`
--- Utilities --- Utilities
## `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.
@@ -202,7 +216,8 @@ Refer to https://github.com/nostr-protocol/nips/blob/master/98.md for details. U
## `prepare_relay(&self, relay: Relay) -> anyhow::Result<Relay>` ## `prepare_relay(&self, relay: Relay) -> anyhow::Result<Relay>`
- Validate `subdomain` - Validate `subdomain`
- If `plan` is free and `blossom` is enabled, return `premium-feature` - Validate that `plan` matches a known plan id from `Query::list_plans`
- If `plan` is free and `livekit` is enabled, return `premium-feature` - If selected `plan` does not include `blossom` and `blossom` is enabled, return `premium-feature`
- If selected `plan` does not include `livekit` and `livekit` is enabled, return `premium-feature`
- Populate `schema` if not already set - Populate `schema` if not already set
- Populate missing fields using reasonable defaults - Populate missing fields using reasonable defaults
+3 -1
View File
@@ -5,6 +5,7 @@ Billing encapsulates logic related to synchronizing state with Stripe, processin
Members: Members:
- `nwc_url: String` - a nostr wallet connect URL used to **create** bolt11 invoices (i.e. receive payments), from `NWC_URL` - `nwc_url: String` - a nostr wallet connect URL used to **create** bolt11 invoices (i.e. receive payments), from `NWC_URL`
- `stripe_secret_key: String` - Stripe API key used for billing API operations, from `STRIPE_SECRET_KEY`
- `stripe_webhook_secret: String` - secret for verifying Stripe webhook signatures, from `STRIPE_WEBHOOK_SECRET` - `stripe_webhook_secret: String` - secret for verifying Stripe webhook signatures, from `STRIPE_WEBHOOK_SECRET`
- `query: Query` - `query: Query`
- `command: Command` - `command: Command`
@@ -13,6 +14,8 @@ Members:
## `pub fn new(query: Query, command: Command, robot: Robot) -> Self` ## `pub fn new(query: Query, command: Command, robot: Robot) -> Self`
- Reads environment and populates members - Reads environment and populates members
- Panics if `STRIPE_SECRET_KEY` is missing/empty
- Panics if `STRIPE_WEBHOOK_SECRET` is missing/empty
## `pub fn start(&self)` ## `pub fn start(&self)`
@@ -109,4 +112,3 @@ Skip invoices with `amount_due` of 0.
- Look up tenant by `stripe_customer_id` - Look up tenant by `stripe_customer_id`
- Clear `stripe_subscription_id` via `command.clear_tenant_subscription` - Clear `stripe_subscription_id` via `command.clear_tenant_subscription`
+3 -2
View File
@@ -30,5 +30,6 @@ Members:
## `async fn sync_relay(&self, relay: &Relay, is_new: bool)` ## `async fn sync_relay(&self, relay: &Relay, is_new: bool)`
- If `is_new`, sends `POST /relay/:id` to create the relay in zooid. - If `is_new`, sends `POST /relay/:id` to create the relay in zooid.
- Otherwise, sends `PUT /relay/:id` to update it. - Otherwise, sends `PATCH /relay/:id` to update it.
- Passes full relay configuration in the body including host, schema, secret, inactive flag, info, policy, groups, management, blossom, livekit, push, and roles. - Includes `secret` only for relay creation (`POST`) so updates do not rotate relay identity.
- Passes relay configuration in the body including host, schema, inactive flag, info, policy, groups, management, blossom, livekit, push, and roles.
+1 -2
View File
@@ -85,9 +85,8 @@ A relay is a nostr relay owned by a `tenant` and hosted by the attached zooid in
Some attributes persisted to zooid via API have special handling: Some attributes persisted to zooid via API have special handling:
- The relay's `secret` is generated once and persisted to the zooid configuration but isn't stored in the database. - The relay's `secret` is generated once on relay creation, persisted to the zooid configuration, and isn't stored in the database. Relay updates do not resend `secret`.
- The relay's `host` is calculated based on `subdomain` + `RELAY_DOMAIN` - The relay's `host` is calculated based on `subdomain` + `RELAY_DOMAIN`
- The value of `inactive` is calculated based on `status` - The value of `inactive` is calculated based on `status`
- The relay's `livekit_*` configuration is inferred based on environment variables and `livekit_enabled`. - The relay's `livekit_*` configuration is inferred based on environment variables and `livekit_enabled`.
- The relay's `roles` are hard-coded for now. - The relay's `roles` are hard-coded for now.
+66 -35
View File
@@ -139,11 +139,11 @@ impl Api {
api: Arc::new(self), api: Arc::new(self),
}; };
Router::new() let router = Router::new()
.route("/identity", get(get_identity)) .route("/identity", get(get_identity))
.route("/plans", get(list_plans)) .route("/plans", get(list_plans))
.route("/plans/:id", get(get_plan)) .route("/plans/:id", get(get_plan))
.route("/tenants", get(list_tenants)) .route("/tenants", get(list_tenants).post(create_tenant))
.route("/tenants/:pubkey", get(get_tenant).put(update_tenant)) .route("/tenants/:pubkey", get(get_tenant).put(update_tenant))
.route("/tenants/:pubkey/relays", get(list_tenant_relays)) .route("/tenants/:pubkey/relays", get(list_tenant_relays))
.route("/relays", get(list_relays).post(create_relay)) .route("/relays", get(list_relays).post(create_relay))
@@ -158,8 +158,9 @@ impl Api {
"/tenants/:pubkey/stripe/session", "/tenants/:pubkey/stripe/session",
get(create_stripe_session), get(create_stripe_session),
) )
.route("/stripe/webhook", post(stripe_webhook)) .route("/stripe/webhook", post(stripe_webhook));
.with_state(state)
router.with_state(state)
} }
fn extract_auth_pubkey(&self, headers: &HeaderMap) -> std::result::Result<String, ApiError> { fn extract_auth_pubkey(&self, headers: &HeaderMap) -> std::result::Result<String, ApiError> {
@@ -209,6 +210,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"
@@ -256,10 +260,12 @@ impl Api {
return Err(anyhow!("invalid-subdomain")); return Err(anyhow!("invalid-subdomain"));
} }
if relay.plan == "free" && relay.blossom_enabled == 1 { let plan = Query::get_plan(&relay.plan).ok_or_else(|| anyhow!("invalid-plan"))?;
if !plan.blossom && relay.blossom_enabled == 1 {
return Err(anyhow!("premium-feature")); return Err(anyhow!("premium-feature"));
} }
if relay.plan == "free" && relay.livekit_enabled == 1 { if !plan.livekit && relay.livekit_enabled == 1 {
return Err(anyhow!("premium-feature")); return Err(anyhow!("premium-feature"));
} }
@@ -273,14 +279,10 @@ impl Api {
relay.policy_strip_signatures = parse_bool_default(relay.policy_strip_signatures, 0); relay.policy_strip_signatures = parse_bool_default(relay.policy_strip_signatures, 0);
relay.groups_enabled = parse_bool_default(relay.groups_enabled, 1); relay.groups_enabled = parse_bool_default(relay.groups_enabled, 1);
relay.management_enabled = parse_bool_default(relay.management_enabled, 1); relay.management_enabled = parse_bool_default(relay.management_enabled, 1);
relay.blossom_enabled = parse_bool_default( relay.blossom_enabled =
relay.blossom_enabled, parse_bool_default(relay.blossom_enabled, if plan.blossom { 1 } else { 0 });
if relay.plan == "free" { 0 } else { 1 }, relay.livekit_enabled =
); parse_bool_default(relay.livekit_enabled, if plan.livekit { 1 } else { 0 });
relay.livekit_enabled = parse_bool_default(
relay.livekit_enabled,
if relay.plan == "free" { 0 } else { 1 },
);
relay.push_enabled = parse_bool_default(relay.push_enabled, 1); relay.push_enabled = parse_bool_default(relay.push_enabled, 1);
Ok(relay) Ok(relay)
@@ -399,10 +401,17 @@ async fn get_identity(
) -> std::result::Result<Response, ApiError> { ) -> std::result::Result<Response, ApiError> {
let pubkey = state.api.extract_auth_pubkey(&headers)?; let pubkey = state.api.extract_auth_pubkey(&headers)?;
let is_admin = state.api.admins.iter().any(|a| a == &pubkey); let is_admin = state.api.admins.iter().any(|a| a == &pubkey);
Ok(ok(StatusCode::OK, IdentityResponse { pubkey, is_admin }))
}
async fn create_tenant(
State(state): State<AppState>,
headers: HeaderMap,
) -> std::result::Result<Response, ApiError> {
let pubkey = state.api.extract_auth_pubkey(&headers)?;
// Ensure tenant exists.
match state.api.query.get_tenant(&pubkey).await { match state.api.query.get_tenant(&pubkey).await {
Ok(Some(_)) => {} Ok(Some(t)) => Ok(ok(StatusCode::OK, t)),
Ok(None) => { Ok(None) => {
let stripe_customer_id = match state.api.billing.stripe_create_customer(&pubkey).await { let stripe_customer_id = match state.api.billing.stripe_create_customer(&pubkey).await {
Ok(id) => id, Ok(id) => id,
@@ -426,31 +435,39 @@ async fn get_identity(
}; };
match state.api.command.create_tenant(&tenant).await { match state.api.command.create_tenant(&tenant).await {
Ok(()) => {} Ok(()) => Ok(ok(StatusCode::OK, tenant)),
Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => {} Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => {
Err(e) => { match state.api.query.get_tenant(&pubkey).await {
return Ok(err( Ok(Some(t)) => Ok(ok(StatusCode::OK, t)),
StatusCode::INTERNAL_SERVER_ERROR, Ok(None) => Ok(err(
"internal", StatusCode::INTERNAL_SERVER_ERROR,
&e.to_string(), "internal",
)); "tenant row missing after unique-constraint race",
)),
Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
)),
}
} }
}; Err(e) => Ok(err(
} StatusCode::INTERNAL_SERVER_ERROR,
Err(e) => { "internal",
return Ok(err( &e.to_string(),
StatusCode::INTERNAL_SERVER_ERROR, )),
"internal", }
&e.to_string(),
));
} }
Err(e) => Ok(err(
StatusCode::INTERNAL_SERVER_ERROR,
"internal",
&e.to_string(),
)),
} }
Ok(ok(StatusCode::OK, IdentityResponse { pubkey, is_admin }))
} }
async fn get_plan(Path(id): Path<String>) -> Response { async fn get_plan(Path(id): Path<String>) -> Response {
match Query::list_plans().into_iter().find(|p| p.id == id) { match Query::get_plan(&id) {
Some(plan) => ok(StatusCode::OK, plan), Some(plan) => ok(StatusCode::OK, plan),
None => err(StatusCode::NOT_FOUND, "not-found", "plan not found"), None => err(StatusCode::NOT_FOUND, "not-found", "plan not found"),
} }
@@ -591,6 +608,13 @@ async fn create_relay(
}; };
relay = match state.api.prepare_relay(relay) { relay = match state.api.prepare_relay(relay) {
Err(e) if e.to_string() == "invalid-plan" => {
return Ok(err(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid-plan",
"plan not found",
));
}
Ok(r) => r, Ok(r) => r,
Err(e) if e.to_string() == "premium-feature" => { Err(e) if e.to_string() == "premium-feature" => {
return Ok(err( return Ok(err(
@@ -688,6 +712,13 @@ async fn update_relay(
} }
relay = match state.api.prepare_relay(relay) { relay = match state.api.prepare_relay(relay) {
Err(e) if e.to_string() == "invalid-plan" => {
return Ok(err(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid-plan",
"plan not found",
));
}
Ok(r) => r, Ok(r) => r,
Err(e) if e.to_string() == "premium-feature" => { Err(e) if e.to_string() == "premium-feature" => {
return Ok(err( return Ok(err(
+112 -16
View File
@@ -95,6 +95,9 @@ impl Billing {
panic!("missing STRIPE_SECRET_KEY environment variable"); panic!("missing STRIPE_SECRET_KEY environment variable");
} }
let stripe_webhook_secret = std::env::var("STRIPE_WEBHOOK_SECRET").unwrap_or_default(); let stripe_webhook_secret = std::env::var("STRIPE_WEBHOOK_SECRET").unwrap_or_default();
if stripe_webhook_secret.trim().is_empty() {
panic!("missing STRIPE_WEBHOOK_SECRET environment variable");
}
let btc_quote_api_base = let btc_quote_api_base =
std::env::var("BTC_PRICE_API_BASE").unwrap_or_else(|_| COINBASE_SPOT_API.to_string()); std::env::var("BTC_PRICE_API_BASE").unwrap_or_else(|_| COINBASE_SPOT_API.to_string());
Self { Self {
@@ -154,8 +157,11 @@ impl Billing {
return Ok(()); return Ok(());
}; };
let plan = Query::get_plan(&relay.plan)
.ok_or_else(|| anyhow!("unknown relay plan id: {}", relay.plan))?;
// Free plan: remove subscription item if exists, then clean up // Free plan: remove subscription item if exists, then clean up
if relay.plan == "free" { if plan.id == "free" {
if let Some(ref item_id) = relay.stripe_subscription_item_id { if let Some(ref item_id) = relay.stripe_subscription_item_id {
self.stripe_delete_subscription_item(item_id).await?; self.stripe_delete_subscription_item(item_id).await?;
self.command self.command
@@ -179,12 +185,6 @@ impl Billing {
} }
// Active relay on a paid plan // Active relay on a paid plan
let plan = Query::list_plans().into_iter().find(|p| p.id == relay.plan);
let Some(plan) = plan else {
return Ok(());
};
let Some(ref stripe_price_id) = plan.stripe_price_id else { let Some(ref stripe_price_id) = plan.stripe_price_id else {
return Ok(()); return Ok(());
}; };
@@ -442,7 +442,7 @@ impl Billing {
let relays = self.query.list_relays_for_tenant(&tenant.pubkey).await?; let relays = self.query.list_relays_for_tenant(&tenant.pubkey).await?;
for relay in relays { for relay in relays {
if relay.status == RELAY_STATUS_ACTIVE && relay.plan != "free" { if relay.status == RELAY_STATUS_ACTIVE && Query::is_paid_plan(&relay.plan) {
self.command.mark_relay_delinquent(&relay).await?; self.command.mark_relay_delinquent(&relay).await?;
} }
} }
@@ -477,7 +477,7 @@ impl Billing {
let relays = self.query.list_relays_for_tenant(&tenant.pubkey).await?; let relays = self.query.list_relays_for_tenant(&tenant.pubkey).await?;
for relay in relays { for relay in relays {
if relay.status == RELAY_STATUS_ACTIVE && relay.plan != "free" { if relay.status == RELAY_STATUS_ACTIVE && Query::is_paid_plan(&relay.plan) {
self.command.mark_relay_delinquent(&relay).await?; self.command.mark_relay_delinquent(&relay).await?;
} }
} }
@@ -801,7 +801,7 @@ impl Billing {
} }
fn should_reactivate_after_payment(relay: &Relay) -> bool { fn should_reactivate_after_payment(relay: &Relay) -> bool {
relay.status == RELAY_STATUS_DELINQUENT && relay.plan != "free" relay.status == RELAY_STATUS_DELINQUENT && Query::is_paid_plan(&relay.plan)
} }
async fn fetch_btc_spot_price(&self, currency: &str) -> Result<f64> { async fn fetch_btc_spot_price(&self, currency: &str) -> Result<f64> {
@@ -952,7 +952,8 @@ mod tests {
use sqlx::SqlitePool; use sqlx::SqlitePool;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use std::str::FromStr; use std::str::FromStr;
use std::sync::{Mutex, OnceLock}; use std::sync::OnceLock;
use tokio::sync::Mutex;
fn env_lock() -> &'static Mutex<()> { fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
@@ -967,6 +968,14 @@ mod tests {
} }
} }
#[allow(unused_unsafe)]
fn set_stripe_webhook_secret(value: Option<&str>) {
match value {
Some(v) => unsafe { std::env::set_var("STRIPE_WEBHOOK_SECRET", v) },
None => unsafe { std::env::remove_var("STRIPE_WEBHOOK_SECRET") },
}
}
struct StripeSecretKeyGuard { struct StripeSecretKeyGuard {
previous: Option<String>, previous: Option<String>,
} }
@@ -985,6 +994,24 @@ mod tests {
} }
} }
struct StripeWebhookSecretGuard {
previous: Option<String>,
}
impl StripeWebhookSecretGuard {
fn set(value: Option<&str>) -> Self {
let previous = std::env::var("STRIPE_WEBHOOK_SECRET").ok();
set_stripe_webhook_secret(value);
Self { previous }
}
}
impl Drop for StripeWebhookSecretGuard {
fn drop(&mut self) {
set_stripe_webhook_secret(self.previous.as_deref());
}
}
async fn test_pool() -> SqlitePool { async fn test_pool() -> SqlitePool {
let connect_options = SqliteConnectOptions::from_str("sqlite::memory:") let connect_options = SqliteConnectOptions::from_str("sqlite::memory:")
.expect("valid sqlite memory url") .expect("valid sqlite memory url")
@@ -1006,8 +1033,9 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn billing_new_panics_without_stripe_secret_key() { async fn billing_new_panics_without_stripe_secret_key() {
let _lock = env_lock().lock().expect("acquire env lock"); let _lock = env_lock().lock().await;
let _env = StripeSecretKeyGuard::set(None); let _secret_env = StripeSecretKeyGuard::set(None);
let _webhook_env = StripeWebhookSecretGuard::set(Some("whsec_test_dummy"));
let pool = test_pool().await; let pool = test_pool().await;
let query = Query::new(pool.clone()); let query = Query::new(pool.clone());
@@ -1037,9 +1065,76 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn billing_new_accepts_non_empty_stripe_secret_key() { async fn billing_new_panics_without_stripe_webhook_secret() {
let _lock = env_lock().lock().expect("acquire env lock"); let _lock = env_lock().lock().await;
let _env = StripeSecretKeyGuard::set(Some("sk_test_dummy")); let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy"));
let _webhook_env = StripeWebhookSecretGuard::set(None);
let pool = test_pool().await;
let query = Query::new(pool.clone());
let command = Command::new(pool);
let robot = Robot::test_stub();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
Billing::new(query, command, robot)
}));
let panic_payload = match result {
Ok(_) => panic!("constructor should panic when STRIPE_WEBHOOK_SECRET is missing"),
Err(payload) => payload,
};
let panic_msg = if let Some(msg) = panic_payload.downcast_ref::<&str>() {
(*msg).to_string()
} else if let Some(msg) = panic_payload.downcast_ref::<String>() {
msg.clone()
} else {
String::new()
};
assert!(
panic_msg.contains("missing STRIPE_WEBHOOK_SECRET environment variable"),
"unexpected panic: {panic_msg}"
);
}
#[tokio::test]
async fn billing_new_panics_with_blank_stripe_webhook_secret() {
let _lock = env_lock().lock().await;
let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy"));
let _webhook_env = StripeWebhookSecretGuard::set(Some(" "));
let pool = test_pool().await;
let query = Query::new(pool.clone());
let command = Command::new(pool);
let robot = Robot::test_stub();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
Billing::new(query, command, robot)
}));
let panic_payload = match result {
Ok(_) => panic!("constructor should panic when STRIPE_WEBHOOK_SECRET is blank"),
Err(payload) => payload,
};
let panic_msg = if let Some(msg) = panic_payload.downcast_ref::<&str>() {
(*msg).to_string()
} else if let Some(msg) = panic_payload.downcast_ref::<String>() {
msg.clone()
} else {
String::new()
};
assert!(
panic_msg.contains("missing STRIPE_WEBHOOK_SECRET environment variable"),
"unexpected panic: {panic_msg}"
);
}
#[tokio::test]
async fn billing_new_accepts_non_empty_stripe_secrets() {
let _lock = env_lock().lock().await;
let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy"));
let _webhook_env = StripeWebhookSecretGuard::set(Some("whsec_test_dummy"));
let pool = test_pool().await; let pool = test_pool().await;
let billing = Billing::new( let billing = Billing::new(
@@ -1049,5 +1144,6 @@ mod tests {
); );
assert_eq!(billing.stripe_secret_key, "sk_test_dummy"); assert_eq!(billing.stripe_secret_key, "sk_test_dummy");
assert_eq!(billing.stripe_webhook_secret, "whsec_test_dummy");
} }
} }
+1 -1
View File
@@ -32,7 +32,7 @@ impl Command {
.fetch_one(&mut **tx) .fetch_one(&mut **tx)
.await? .await?
} }
_ => anyhow::bail!("unknown resource_type: {}", resource_type), _ => anyhow::bail!("unknown resource_type: {resource_type}"),
}; };
let id = uuid::Uuid::new_v4().to_string(); let id = uuid::Uuid::new_v4().to_string();
+69 -49
View File
@@ -2,7 +2,7 @@ use anyhow::Result;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use crate::command::Command; use crate::command::Command;
use crate::models::{Activity, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE}; use crate::models::{Activity, Relay, RELAY_STATUS_DELINQUENT, RELAY_STATUS_INACTIVE};
use crate::query::Query; use crate::query::Query;
#[derive(Clone)] #[derive(Clone)]
@@ -70,7 +70,7 @@ impl Infra {
Ok(()) Ok(())
} }
async fn sync_and_report(&self, relay: &crate::models::Relay, is_new: bool) { async fn sync_and_report(&self, relay: &Relay, is_new: bool) {
match self.sync_relay(relay, is_new).await { match self.sync_relay(relay, is_new).await {
Ok(()) => { Ok(()) => {
tracing::info!(relay = %relay.id, "relay sync succeeded"); tracing::info!(relay = %relay.id, "relay sync succeeded");
@@ -96,7 +96,7 @@ impl Infra {
Ok(auth) Ok(auth)
} }
async fn sync_relay(&self, relay: &crate::models::Relay, is_new: bool) -> Result<()> { async fn sync_relay(&self, relay: &Relay, is_new: bool) -> Result<()> {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let base = self.api_url.trim_end_matches('/'); let base = self.api_url.trim_end_matches('/');
@@ -106,8 +106,6 @@ impl Infra {
format!("{}.{}", relay.subdomain, self.relay_domain) format!("{}.{}", relay.subdomain, self.relay_domain)
}; };
let secret = Keys::generate().secret_key().to_secret_hex();
let livekit = if relay.livekit_enabled == 1 { let livekit = if relay.livekit_enabled == 1 {
serde_json::json!({ serde_json::json!({
"enabled": true, "enabled": true,
@@ -119,62 +117,84 @@ impl Infra {
serde_json::json!({ "enabled": false }) serde_json::json!({ "enabled": false })
}; };
let body = serde_json::json!({ let body = relay_sync_body(
"host": host, relay,
"schema": relay.schema, host,
"secret": secret, livekit,
"inactive": relay.status == RELAY_STATUS_INACTIVE is_new.then(|| Keys::generate().secret_key().to_secret_hex()),
|| relay.status == RELAY_STATUS_DELINQUENT, );
"info": {
"name": relay.info_name,
"icon": relay.info_icon,
"description": relay.info_description,
"pubkey": relay.tenant,
},
"policy": {
"public_join": relay.policy_public_join == 1,
"strip_signatures": relay.policy_strip_signatures == 1,
},
"groups": { "enabled": relay.groups_enabled == 1 },
"management": { "enabled": relay.management_enabled == 1 },
"blossom": { "enabled": relay.blossom_enabled == 1 },
"livekit": livekit,
"push": { "enabled": relay.push_enabled == 1 },
"roles": {
"admin": { "can_manage": true, "can_invite": true },
"member": { "can_invite": true },
},
});
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, zooid_sync_http_method(is_new)).await?;
let auth = self.nip98_auth(&url, HttpMethod::POST).await?;
client let request = if is_new {
.post(&url) client.post(&url)
.header("Authorization", auth)
.json(&body)
.send()
.await?
} else { } else {
let url = format!("{}/relay/{}", base, relay.id); client.patch(&url)
let auth = self.nip98_auth(&url, HttpMethod::PUT).await?;
client
.put(&url)
.header("Authorization", auth)
.json(&body)
.send()
.await?
}; };
let response = request
.header("Authorization", auth)
.json(&body)
.send()
.await?;
if !response.status().is_success() { if !response.status().is_success() {
let status = response.status(); let status = response.status();
let body = response.text().await.unwrap_or_default(); let body = response.text().await.unwrap_or_default();
anyhow::bail!("zooid sync returned {}: {}", status, body) anyhow::bail!("zooid sync returned {status}: {body}")
} }
Ok(()) Ok(())
} }
} }
fn zooid_sync_http_method(is_new: bool) -> HttpMethod {
if is_new {
HttpMethod::POST
} else {
HttpMethod::PATCH
}
}
fn relay_sync_body(
relay: &Relay,
host: String,
livekit: serde_json::Value,
secret: Option<String>,
) -> serde_json::Value {
let mut body = serde_json::json!({
"host": host,
"schema": relay.schema,
"inactive": relay.status == RELAY_STATUS_INACTIVE
|| relay.status == RELAY_STATUS_DELINQUENT,
"info": {
"name": relay.info_name,
"icon": relay.info_icon,
"description": relay.info_description,
"pubkey": relay.tenant,
},
"policy": {
"public_join": relay.policy_public_join == 1,
"strip_signatures": relay.policy_strip_signatures == 1,
},
"groups": { "enabled": relay.groups_enabled == 1 },
"management": { "enabled": relay.management_enabled == 1 },
"blossom": { "enabled": relay.blossom_enabled == 1 },
"livekit": livekit,
"push": { "enabled": relay.push_enabled == 1 },
"roles": {
"admin": { "can_manage": true, "can_invite": true },
"member": { "can_invite": true },
},
});
if let (Some(secret), Some(body_obj)) = (secret, body.as_object_mut()) {
body_obj.insert("secret".to_string(), serde_json::Value::String(secret));
}
body
}
fn should_sync_relay_activity(activity_type: &str) -> bool { fn should_sync_relay_activity(activity_type: &str) -> bool {
matches!( matches!(
activity_type, activity_type,
+2 -2
View File
@@ -3,8 +3,8 @@ mod billing;
mod command; mod command;
mod infra; mod infra;
mod models; mod models;
mod query;
mod pool; mod pool;
mod query;
mod robot; mod robot;
use anyhow::Result; use anyhow::Result;
@@ -40,7 +40,7 @@ async fn main() -> Result<()> {
let port: u16 = std::env::var("PORT") let port: u16 = std::env::var("PORT")
.ok() .ok()
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())
.unwrap_or(3000); .unwrap_or(2892);
let origins: Vec<String> = std::env::var("ALLOW_ORIGINS") let origins: Vec<String> = std::env::var("ALLOW_ORIGINS")
.unwrap_or_default() .unwrap_or_default()
.split(',') .split(',')
+1 -2
View File
@@ -21,8 +21,7 @@ pub async fn create_pool() -> Result<SqlitePool> {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
} }
let connect_options = let connect_options = SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true);
SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true);
let pool = SqlitePoolOptions::new() let pool = SqlitePoolOptions::new()
.max_connections(5) .max_connections(5)
+15 -4
View File
@@ -68,6 +68,16 @@ impl Query {
] ]
} }
pub fn get_plan(plan_id: &str) -> Option<Plan> {
Self::list_plans().into_iter().find(|p| p.id == plan_id)
}
pub fn is_paid_plan(plan_id: &str) -> bool {
Self::get_plan(plan_id)
.map(|p| p.id != "free")
.unwrap_or(false)
}
pub async fn list_relays(&self) -> Result<Vec<Relay>> { pub async fn list_relays(&self) -> Result<Vec<Relay>> {
let rows = sqlx::query_as::<_, Relay>( let rows = sqlx::query_as::<_, Relay>(
"SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id, "SELECT id, tenant, schema, subdomain, plan, stripe_subscription_item_id,
@@ -135,13 +145,14 @@ impl Query {
} }
pub async fn has_active_paid_relays(&self, tenant_id: &str) -> Result<bool> { pub async fn has_active_paid_relays(&self, tenant_id: &str) -> Result<bool> {
let count = sqlx::query_scalar::<_, i64>( let plans = sqlx::query_scalar::<_, String>(
"SELECT COUNT(*) FROM relay WHERE tenant = ? AND status = 'active' AND plan != 'free'", "SELECT plan FROM relay WHERE tenant = ? AND status = 'active'",
) )
.bind(tenant_id) .bind(tenant_id)
.fetch_one(&self.pool) .fetch_all(&self.pool)
.await?; .await?;
Ok(count > 0)
Ok(plans.into_iter().any(|plan| Self::is_paid_plan(&plan)))
} }
pub async fn list_activity_for_relay(&self, relay_id: &str) -> Result<Vec<Activity>> { pub async fn list_activity_for_relay(&self, relay_id: &str) -> Result<Vec<Activity>> {
+2 -2
View File
@@ -25,7 +25,7 @@ async fn quote_endpoint_can_be_stubbed_deterministically() {
assert_eq!(btc_price, 50_000.0); assert_eq!(btc_price, 50_000.0);
let msats = fiat_minor_to_msats_from_quote(100, "USD", btc_price) let msats =
.expect("convert quoted fiat amount"); fiat_minor_to_msats_from_quote(100, "USD", btc_price).expect("convert quoted fiat amount");
assert_eq!(msats, 2_000_000); assert_eq!(msats, 2_000_000);
} }
+1 -1
View File
@@ -1,5 +1,5 @@
# Backend API base URL # Backend API base URL
VITE_API_URL=http://127.0.0.1:3000 VITE_API_URL=http://127.0.0.1:2892
# Platform display name shown in UI # Platform display name shown in UI
VITE_PLATFORM_NAME=Caravel VITE_PLATFORM_NAME=Caravel
+6 -3
View File
@@ -32,7 +32,7 @@ Environment variables (see `.env.template`):
| Variable | Description | Default | | Variable | Description | Default |
|---|---|---| |---|---|---|
| `VITE_API_URL` | Backend API base URL | `http://127.0.0.1:3000` | | `VITE_API_URL` | Backend API base URL | `http://127.0.0.1:2892` |
## Running ## Running
@@ -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
+1
View File
@@ -0,0 +1 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" width="220" height="220" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M0 32C0 14.3269 14.3269 0 32 0V0C49.6731 0 64 14.3269 64 32V32C64 49.6731 49.6731 64 32 64H8C3.58172 64 0 60.4183 0 56V32Z" fill="#6D29D9"></path><path fill-rule="evenodd" clip-rule="evenodd" d="M21 22C19.3431 22 18 23.3431 18 25C18 26.1046 17.1046 27 16 27C14.8954 27 14 26.1046 14 25C14 21.134 17.134 18 21 18C24.866 18 28 21.134 28 25C28 26.1046 27.1046 27 26 27C24.8954 27 24 26.1046 24 25C24 23.3431 22.6569 22 21 22ZM43 22C41.3431 22 40 23.3431 40 25C40 26.1046 39.1046 27 38 27C36.8954 27 36 26.1046 36 25C36 21.134 39.134 18 43 18C46.866 18 50 21.134 50 25C50 26.1046 49.1046 27 48 27C46.8954 27 46 26.1046 46 25C46 23.3431 44.6569 22 43 22Z" fill="#FFFFFF"></path><path d="M32 47C38.6985 47 44.2982 42.2956 45.6755 36.0106C45.9829 34.608 44.5 33.5552 43.1016 33.8813C40.0379 34.5957 35.7213 35.1538 32 35.1538C28.2787 35.1538 23.9621 34.5957 20.8984 33.8813C19.5 33.5552 18.0171 34.608 18.3245 36.0106C19.7018 42.2956 25.3015 47 32 47Z" fill="#FFFFFF"></path></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

+21
View File
@@ -0,0 +1,21 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" width="66.145837" height="66.145837" viewBox="0 0 66.145837 66.145837" version="1.1" id="svg1" inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)" sodipodi:docname="nostrord.svg">
<sodipodi:namedview id="namedview1" pagecolor="#ffffff" bordercolor="#000000" borderopacity="0.25" inkscape:showpageshadow="2" inkscape:pageopacity="0.0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#d1d1d1" inkscape:document-units="mm" inkscape:zoom="2.1971895" inkscape:cx="172.94822" inkscape:cy="184.78151" inkscape:window-width="2048" inkscape:window-height="1083" inkscape:window-x="0" inkscape:window-y="0" inkscape:window-maximized="1" inkscape:current-layer="layer1"/>
<defs id="defs1"/>
<g inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1" transform="translate(-80.278336,-100.91589)">
<rect x="80.278336" y="100.91589" width="66.145836" height="66.145836" rx="15.434029" fill="#0a0a0a" id="rect1-6-9-7-7-9-8-6" style="stroke-width:1.10243"/>
<g id="g32" transform="matrix(0.56473291,0,0,0.56473291,-434.56644,229.88942)">
<path d="m 941.12408,-145.50974 c 0,7.28807 10.93211,10.93211 29.15233,10.93211 18.2201,0 29.1523,-3.64404 29.1523,-10.93211 v -32.79632 c 0,-14.57615 -10.9322,-25.50826 -29.1523,-25.50826 -18.22022,0 -29.15233,10.93211 -29.15233,25.50826 z" fill="#fafafa" id="path3-6-2-5-9-7-4-1" style="stroke-width:1.82202"/>
<path d="m 948.41215,-171.01799 c 0,-7.28807 10.93211,-10.93211 21.86426,-10.93211 10.9321,0 21.8642,3.64404 21.8642,10.93211 v 7.28807 c 0,7.28807 -10.9321,10.9321 -21.8642,10.9321 -10.93215,0 -21.86426,-3.64403 -21.86426,-10.9321 z" fill="#0a0a0a" id="path4-1-3-6-2-3-5-5" style="stroke-width:1.82202"/>
<ellipse cx="959.34424" cy="-167.37396" rx="5.4660549" ry="7.2880735" fill="#fafafa" id="ellipse4-5-7-2-0-6-0-5" style="stroke-width:1.82202"/>
<ellipse cx="981.2085" cy="-167.37396" rx="5.4660549" ry="7.2880735" fill="#fafafa" id="ellipse5-5-5-9-2-1-3-4" style="stroke-width:1.82202"/>
<g id="g12-2-3-2-6-7" transform="matrix(1.3286857,0,0,1.3286857,15.786725,-131.79559)">
<path d="m 740.53601,-40.469391 14,-14 q 3,-2 1,2 l -11,14 q -2,2 -4,1 z" fill="#fafafa" id="path3-9-7-7-9-1-6"/>
<path d="m 738.53601,-34.469391 10,-10 q 2,-2 1,1 l -8,11 q -2,2 -3,1 z" fill="#fafafa" id="path4-73-0-5-3-0-5"/>
</g>
<g id="g12-2-2-9-1-6-6" transform="matrix(-1.3286857,0,0,1.3286857,1924.6621,-131.79559)">
<path d="m 740.53601,-40.469391 14,-14 q 3,-2 1,2 l -11,14 q -2,2 -4,1 z" fill="#fafafa" id="path3-9-7-6-2-9-3-9"/>
<path d="m 738.53601,-34.469391 10,-10 q 2,-2 1,1 l -8,11 q -2,2 -3,1 z" fill="#fafafa" id="path4-73-0-1-2-4-2-3"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -203,6 +203,13 @@ export default function RelayDetailCard(props: RelayDetailCardProps) {
</Show> </Show>
</div> </div>
<Show when={r().sync_error}>
<div class="rounded-lg border border-red-200 bg-red-50 px-4 py-3">
<p class="text-sm font-semibold text-red-800">Provisioning error</p>
<p class="mt-1 text-sm text-red-700 font-mono break-all">{r().sync_error}</p>
</div>
</Show>
<hr class="border-gray-200" /> <hr class="border-gray-200" />
<DetailSection title="Policy"> <DetailSection title="Policy">
+12 -1
View File
@@ -1,4 +1,5 @@
import { A } from "@solidjs/router" import { A } from "@solidjs/router"
import { Show } from "solid-js"
import type { Relay } from "@/lib/api" import type { Relay } from "@/lib/api"
type RelayListItemProps = { type RelayListItemProps = {
@@ -19,7 +20,17 @@ export default function RelayListItem(props: RelayListItemProps) {
<p class="text-xs text-gray-500 break-all mt-1">Tenant: {props.relay.tenant}</p> <p class="text-xs text-gray-500 break-all mt-1">Tenant: {props.relay.tenant}</p>
)} )}
</div> </div>
<p class="text-xs uppercase tracking-wide text-gray-500">{props.relay.status}</p> <Show
when={props.relay.sync_error}
fallback={<p class="text-xs uppercase tracking-wide text-gray-500">{props.relay.status}</p>}
>
<span
class="inline-flex items-center rounded-full border border-red-200 bg-red-50 px-2.5 py-0.5 text-xs font-medium text-red-700 max-w-56 truncate"
title={props.relay.sync_error}
>
{props.relay.sync_error}
</span>
</Show>
</div> </div>
</A> </A>
</li> </li>
+6
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]],
}) })
@@ -203,6 +205,10 @@ export function getIdentity() {
return callApi<undefined, Identity>("GET", "/identity") return callApi<undefined, Identity>("GET", "/identity")
} }
export function createTenant() {
return callApi<undefined, Tenant>("POST", "/tenants")
}
export function getPlan(id: string) { export function getPlan(id: string) {
return callApi<undefined, Plan>("GET", `/plans/${id}`) return callApi<undefined, Plan>("GET", `/plans/${id}`)
} }
+46 -7
View File
@@ -8,6 +8,9 @@ import Modal from "@/components/Modal"
import Login from "@/views/Login" import Login from "@/views/Login"
import { createRelayForActiveTenant } from "@/lib/hooks" import { createRelayForActiveTenant } from "@/lib/hooks"
import { account } from "@/lib/state" import { account } from "@/lib/state"
import FlotillaLogo from "@/assets/flotilla-logo.svg"
import ChachiLogo from "@/assets/chachi-logo.svg"
import NostordLogo from "@/assets/nostord-logo.svg"
export default function Home() { export default function Home() {
const navigate = useNavigate() const navigate = useNavigate()
@@ -213,7 +216,7 @@ export default function Home() {
</p> </p>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Flotilla */} {/* Flotilla */}
<a <a
href="https://flotilla.social" href="https://flotilla.social"
@@ -223,9 +226,7 @@ export default function Home() {
> >
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-2xl bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center text-white font-bold text-xl shadow-md shadow-blue-200"> <img src={FlotillaLogo} alt="Flotilla" class="w-12 h-12 rounded-2xl shadow-md shadow-blue-200" />
F
</div>
<div> <div>
<h3 class="text-lg font-bold text-gray-900 group-hover:text-blue-600 transition-colors">Flotilla</h3> <h3 class="text-lg font-bold text-gray-900 group-hover:text-blue-600 transition-colors">Flotilla</h3>
<p class="text-xs text-gray-400">flotilla.social</p> <p class="text-xs text-gray-400">flotilla.social</p>
@@ -263,9 +264,7 @@ export default function Home() {
> >
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-2xl bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-white font-bold text-xl shadow-md shadow-purple-200"> <img src={ChachiLogo} alt="Chachi" class="w-12 h-12 rounded-2xl shadow-md shadow-purple-200" />
C
</div>
<div> <div>
<h3 class="text-lg font-bold text-gray-900 group-hover:text-purple-600 transition-colors">Chachi</h3> <h3 class="text-lg font-bold text-gray-900 group-hover:text-purple-600 transition-colors">Chachi</h3>
<p class="text-xs text-gray-400">chachi.chat</p> <p class="text-xs text-gray-400">chachi.chat</p>
@@ -293,6 +292,45 @@ export default function Home() {
</span> </span>
</div> </div>
</a> </a>
{/* Nostrord */}
<a
href="https://nostrord.com/"
target="_blank"
rel="noopener noreferrer"
class="group flex flex-col gap-5 rounded-2xl border border-gray-200 bg-white p-8 hover:border-amber-300 hover:shadow-md transition-all"
>
<div class="flex items-start justify-between gap-4">
<div class="flex items-center gap-3">
<img src={NostordLogo} alt="Nostrord" class="w-12 h-12 rounded-2xl shadow-md shadow-amber-200" />
<div>
<h3 class="text-lg font-bold text-gray-900 group-hover:text-amber-600 transition-colors">Nostrord</h3>
<p class="text-xs text-gray-400">nostrord.com</p>
</div>
</div>
<span class="text-gray-300 group-hover:text-amber-400 transition-colors mt-1">
<ExternalLinkIcon />
</span>
</div>
<p class="text-sm text-gray-600 leading-relaxed">
A NIP-29 client built for decentralized group chat on Nostr. Create
censorship-resistant communities with admin roles, moderation, and access
controlall powered by your relay.
</p>
<div class="space-y-2">
{["Decentralized group chat with NIP-29", "Censorship-resistant communities", "Admin roles & moderation"].map(f => (
<div class="flex items-start gap-2 text-sm text-gray-600">
<CheckIcon />
{f}
</div>
))}
</div>
<div class="mt-auto pt-2">
<span class="inline-flex items-center gap-1.5 text-sm font-semibold text-amber-600">
Visit nostrord.com <ExternalLinkIcon />
</span>
</div>
</a>
</div> </div>
</section> </section>
@@ -338,6 +376,7 @@ export default function Home() {
<div class="flex gap-4"> <div class="flex gap-4">
<a href="https://flotilla.social" target="_blank" rel="noopener noreferrer" class="hover:text-gray-600 transition-colors">Flotilla</a> <a href="https://flotilla.social" target="_blank" rel="noopener noreferrer" class="hover:text-gray-600 transition-colors">Flotilla</a>
<a href="https://chachi.chat" target="_blank" rel="noopener noreferrer" class="hover:text-gray-600 transition-colors">Chachi</a> <a href="https://chachi.chat" target="_blank" rel="noopener noreferrer" class="hover:text-gray-600 transition-colors">Chachi</a>
<a href="https://nostrord.com/" target="_blank" rel="noopener noreferrer" class="hover:text-gray-600 transition-colors">Nostrord</a>
</div> </div>
</div> </div>
</footer> </footer>
+7
View File
@@ -5,6 +5,7 @@ import { PasswordSigner } from "applesauce-signers"
import QrScanner from "qr-scanner" import QrScanner from "qr-scanner"
import QRCode from "qrcode" import QRCode from "qrcode"
import { accountManager, identity, PLATFORM_NAME } from "@/lib/state" import { accountManager, identity, PLATFORM_NAME } from "@/lib/state"
import { createTenant } from "@/lib/api"
import useMinLoading from "@/components/useMinLoading" import useMinLoading from "@/components/useMinLoading"
const NIP46_RELAYS = ['wss://bucket.coracle.social', 'wss://ephemeral.snowflare.cc'] const NIP46_RELAYS = ['wss://bucket.coracle.social', 'wss://ephemeral.snowflare.cc']
@@ -69,6 +70,12 @@ export default function Login(props: LoginPageProps = {}) {
async function completeLogin(account: ExtensionAccount | NostrConnectAccount | PrivateKeyAccount | PasswordAccount) { async function completeLogin(account: ExtensionAccount | NostrConnectAccount | PrivateKeyAccount | PasswordAccount) {
accountManager.addAccount(account) accountManager.addAccount(account)
accountManager.setActive(account) accountManager.setActive(account)
try {
await createTenant()
} catch (e) {
accountManager.removeAccount(account)
throw e
}
await props.onAuthenticated?.() await props.onAuthenticated?.()
} }
+14 -7
View File
@@ -1,13 +1,20 @@
import { defineConfig } from 'vite' import { defineConfig, loadEnv } from 'vite'
import solid from 'vite-plugin-solid' import solid from 'vite-plugin-solid'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import { fileURLToPath, URL } from 'node:url' import { fileURLToPath, URL } from 'node:url'
export default defineConfig({ export default defineConfig(({mode}) => {
plugins: [tailwindcss(), solid()], const env = loadEnv(mode, process.cwd(), '')
resolve: {
alias: { return {
'@': fileURLToPath(new URL('./src', import.meta.url)), plugins: [tailwindcss(), solid()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
}, },
}, server: {
port: Number(env.VITE_PORT) || 5173,
},
}
}) })