Implement outbox for profile lookup

This commit is contained in:
Jon Staab
2026-02-26 16:27:07 -08:00
parent a2be0b9a79
commit a15372d402
11 changed files with 163 additions and 41 deletions
+2
View File
@@ -0,0 +1,2 @@
--ignore-dir=target
--ignore-dir=ref
+22 -1
View File
@@ -35,6 +35,7 @@ pub fn router(state: AppState) -> Router {
.route("/tenant/billing", put(update_tenant_billing));
let admin_routes = Router::new()
.route("/admin/check", get(admin_check))
.route("/admin/tenants", get(admin_list_tenants))
.route(
"/admin/tenants/:pubkey",
@@ -108,7 +109,7 @@ fn extract_auth_pubkey(headers: &HeaderMap, method: &Method, uri: &Uri) -> Resul
let path = uri.path_and_query().map(|v| v.as_str()).unwrap_or(uri.path());
let url = format!("{}://{}{}", scheme, host, path);
let pubkey = verify_nip98(auth_header, &url, method.as_str())?;
Ok(pubkey.to_string())
Ok(pubkey.to_hex())
}
async fn get_tenant(
@@ -396,6 +397,26 @@ async fn admin_list_tenants(
}
}
#[derive(Debug, Serialize)]
struct AdminCheckResponse {
is_admin: bool,
}
async fn admin_check(
State(state): State<AppState>,
headers: HeaderMap,
method: Method,
uri: Uri,
) -> Response {
let pubkey = match extract_auth_pubkey(&headers, &method, &uri) {
Ok(pubkey) => pubkey,
Err(_) => return unauthorized(),
};
let is_admin = state.admin_pubkeys.contains(&pubkey);
(StatusCode::OK, Json(AdminCheckResponse { is_admin })).into_response()
}
async fn admin_get_tenant(
State(state): State<AppState>,
headers: HeaderMap,
+16 -1
View File
@@ -1,5 +1,8 @@
use std::env;
use std::path::Path;
use std::str::FromStr;
use nostr_sdk::nostr::key::PublicKey;
#[derive(Debug, Clone)]
pub struct Config {
@@ -31,7 +34,7 @@ impl Config {
let admin_pubkeys = env::var("PLATFORM_ADMIN_PUBKEYS")
.unwrap_or_default()
.split(',')
.map(|v| v.trim().to_string())
.filter_map(normalize_pubkey)
.filter(|v| !v.is_empty())
.collect::<Vec<_>>();
let zooid_api_url =
@@ -74,6 +77,18 @@ impl Config {
}
}
fn normalize_pubkey(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
match PublicKey::from_str(trimmed) {
Ok(pubkey) => Some(pubkey.to_hex()),
Err(_) => Some(trimmed.to_lowercase()),
}
}
fn resolve_database_url(database_url: String) -> String {
const PREFIX: &str = "sqlite://";
if !database_url.starts_with(PREFIX) {
+3
View File
@@ -13,6 +13,7 @@
"applesauce-accounts": "^5.1.0",
"applesauce-common": "^5.1.0",
"applesauce-core": "^5.1.0",
"applesauce-loaders": "^5.1.0",
"applesauce-relay": "^5.1.0",
"applesauce-signers": "^5.1.0",
"applesauce-wallet-connect": "^5.0.1",
@@ -281,6 +282,8 @@
"applesauce-core": ["applesauce-core@5.1.0", "", { "dependencies": { "debug": "^4.4.0", "fast-deep-equal": "^3.1.3", "hash-sum": "^2.0.0", "nanoid": "^5.0.9", "nostr-tools": "~2.19", "rxjs": "^7.8.1" } }, "sha512-kk4nHndK4zjS8Sa6mC8LGtQ0LDSP4hlCGPJ9lpyIln7MkZaNFWD9eFd+fsEhfE9kyrne9IyYuVfJNp+EqY1b9w=="],
"applesauce-loaders": ["applesauce-loaders@5.1.0", "", { "dependencies": { "applesauce-core": "^5.1.0", "nanoid": "^5.0.9", "rxjs": "^7.8.1" } }, "sha512-xllWYl7KxG0oaJqKVdZNzxN8OZQoDMaMmaTAO9Ao1Son+mmJyR9Q4UVicSwSlzzHarf59WCfvJeSdrSHIitkHg=="],
"applesauce-relay": ["applesauce-relay@5.1.0", "", { "dependencies": { "@noble/hashes": "^1.7.1", "applesauce-core": "^5.1.0", "nanoid": "^5.0.9", "nostr-tools": "~2.19", "rxjs": "^7.8.1" } }, "sha512-d0LTJmQmr5gsYFm9A6efPEo2Bx/ewoL7LNsIdieMx34QohZBpPb137RvU9KQ1lFIXTm0tudd8VYfAPncqti2OQ=="],
"applesauce-signers": ["applesauce-signers@5.1.0", "", { "dependencies": { "@noble/secp256k1": "^1.7.1", "applesauce-core": "^5.1.0", "debug": "^4.4.0", "nanoid": "^5.0.9", "rxjs": "^7.8.2" } }, "sha512-sdQe6J1txYV1GVX8/zSGZDyyXuuZomePHSfUDozZmNAnXhCXE0wqVfhLK0yegVMnomSgoeDUCsGmJiTE2BHqoQ=="],
+1
View File
@@ -17,6 +17,7 @@
"applesauce-accounts": "^5.1.0",
"applesauce-common": "^5.1.0",
"applesauce-core": "^5.1.0",
"applesauce-loaders": "^5.1.0",
"applesauce-relay": "^5.1.0",
"applesauce-signers": "^5.1.0",
"applesauce-wallet-connect": "^5.0.1",
+3 -6
View File
@@ -15,7 +15,7 @@ import AdminRelays from "./pages/admin/AdminRelays"
import AdminRelayDetail from "./pages/admin/AdminRelayDetail"
import AdminRelayEdit from "./pages/admin/AdminRelayEdit"
import { useActiveAccount } from "./lib/nostr"
import { adminListTenants } from "./lib/api"
import { adminCheck as fetchAdminCheck } from "./lib/api"
function Layout(props: { children?: any }) {
const location = useLocation()
@@ -54,10 +54,7 @@ export default function App() {
const account = useActiveAccount()
const [adminCheck] = createResource(
() => account()?.id,
async () => {
await adminListTenants()
return true
},
() => fetchAdminCheck(),
)
createEffect(() => {
@@ -68,7 +65,7 @@ export default function App() {
<Show when={account()}>
<Show when={!adminCheck.loading} fallback={<div class="max-w-4xl mx-auto px-4 py-8 text-gray-500">Checking admin access...</div>}>
<Show
when={!adminCheck.error}
when={!adminCheck.error && !!adminCheck()?.is_admin}
fallback={<div class="max-w-4xl mx-auto px-4 py-8 text-red-600">You do not have admin access.</div>}
>
<Page />
+1 -4
View File
@@ -9,14 +9,11 @@ export default function Navbar() {
return (
<nav class="bg-white border-b border-gray-200">
<div class="max-w-4xl mx-auto px-4 h-14 flex items-center justify-between">
<A href="/" class="flex items-center gap-2">
<A href={account() ? "/relays" : "/"} class="flex items-center gap-2">
<img src="/caravel.png" alt={PLATFORM_NAME} class="h-8 w-8 rounded-full object-cover" />
<span class="font-bold text-gray-900 text-lg">{PLATFORM_NAME}</span>
</A>
<div class="flex items-center gap-4">
<A href="/relays" class="text-sm text-gray-600 hover:text-gray-900">
Dashboard
</A>
<Show
when={account()}
fallback={
+8
View File
@@ -124,6 +124,10 @@ export type UpdateRelayInput = {
plan: string
}
export type AdminCheck = {
is_admin: boolean
}
export function listTenantRelays() {
return request<Relay[]>("/tenant/relays")
}
@@ -179,6 +183,10 @@ export function adminListTenants() {
return request<Tenant[]>("/admin/tenants")
}
export function adminCheck() {
return request<AdminCheck>("/admin/check")
}
export function adminGetTenant(pubkey: string) {
return request<TenantDetail>(`/admin/tenants/${pubkey}`)
}
+42 -6
View File
@@ -1,21 +1,28 @@
import { createEffect, createSignal, onCleanup } from "solid-js"
import { EventStore } from "applesauce-core"
import { getProfilePicture } from "applesauce-core/helpers/profile"
import { createOutboxMap, selectOptimalRelays, setFallbackRelays } from "applesauce-core/helpers/relay-selection"
import { includeMailboxes } from "applesauce-core/observable"
import { RelayPool } from "applesauce-relay"
import { AccountManager } from "applesauce-accounts"
import type { IAccount, SerializedAccount } from "applesauce-accounts"
import { registerCommonAccountTypes } from "applesauce-accounts/accounts"
import { createEventLoaderForStore } from "applesauce-loaders/loaders"
import { NostrConnectSigner } from "applesauce-signers"
import { map, of } from "rxjs"
export const API_URL = import.meta.env.VITE_API_URL
export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME || "Caravel"
const PROFILE_RELAYS = ["wss://purplepag.es", "wss://relay.damus.io", "wss://nos.lol"]
export const eventStore = new EventStore()
export const pool = new RelayPool()
export const accounts = new AccountManager()
createEventLoaderForStore(eventStore, pool, {
lookupRelays: ["wss://purplepag.es/", "wss://relay.damus.io/", "wss://indexer.coracle.social/"],
extraRelays: ["wss://relay.damus.io/", "wss://nos.lol/", "wss://relay.primal.net/"],
})
const ACCOUNTS_STORAGE_KEY = "caravel.accounts"
const ACTIVE_ACCOUNT_STORAGE_KEY = "caravel.activeAccount"
@@ -79,10 +86,7 @@ export function useProfilePicture(pubkey: () => string | undefined) {
setPicture(getProfilePicture(profile))
})
// Fetch the kind 0 from relays and add to the event store
const reqSub = pool.request(PROFILE_RELAYS, { kinds: [0], authors: [pk] }).subscribe(event => {
eventStore.add(event)
})
const reqSub = primeProfiles([pk])
onCleanup(() => {
profileSub.unsubscribe()
@@ -92,3 +96,35 @@ export function useProfilePicture(pubkey: () => string | undefined) {
return picture
}
export function primeProfiles(pubkeys: string[]) {
const uniquePubkeys = Array.from(new Set(pubkeys.filter(Boolean)))
if (uniquePubkeys.length === 0) {
return { unsubscribe() {} }
}
const seedRelays = Array.from(pool.relays.keys())
const mailboxSeedSub = seedRelays.length
? pool.request(seedRelays, { kinds: [10002], authors: uniquePubkeys }).subscribe((event) => {
eventStore.add(event)
})
: undefined
const outboxMap$ = of(uniquePubkeys.map((pubkey) => ({ pubkey }))).pipe(
includeMailboxes(eventStore),
map((pointers) => (seedRelays.length > 0 ? setFallbackRelays(pointers, seedRelays) : pointers)),
map((pointers) => selectOptimalRelays(pointers, { maxConnections: 8, maxRelaysPerUser: 3 })),
map(createOutboxMap),
)
const profileSub = pool.outboxSubscription(outboxMap$, { kinds: [0] }).subscribe((message) => {
if (message !== "EOSE") eventStore.add(message)
})
return {
unsubscribe() {
profileSub.unsubscribe()
mailboxSeedSub?.unsubscribe()
},
}
}
+11 -18
View File
@@ -26,24 +26,23 @@ export default function Account() {
return (
<div class="max-w-4xl mx-auto px-4 py-8">
<h1 class="text-2xl font-bold text-gray-900 mb-6">Account</h1>
<h1 class="text-2xl font-bold text-gray-900 mb-6">My Account</h1>
<div class="space-y-6">
<section class="bg-white border border-gray-200 rounded-xl p-6">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Account Status</h2>
<div class="flex items-center justify-between gap-3">
<h2 class="text-lg font-semibold text-gray-900">Account Status</h2>
<Show when={tenant()}>
{(t) => (
<span class="rounded-full border border-gray-300 bg-gray-100 px-2.5 py-1 text-xs font-medium uppercase tracking-wide text-gray-700">
{t().status}
</span>
)}
</Show>
</div>
<Show when={tenant.loading}>
<p class="text-gray-500">Loading account...</p>
</Show>
<Show when={tenant()}>
{(t) => (
<div class="text-sm text-gray-700 space-y-2">
<p>
Status: <span class="font-medium capitalize">{t().status}</span>
</p>
<p class="break-all">Tenant pubkey: {t().pubkey}</p>
</div>
)}
</Show>
</section>
<section class="bg-white border border-gray-200 rounded-xl p-6">
@@ -51,12 +50,6 @@ export default function Account() {
<p class="text-sm text-gray-600 mb-4">
Enable automatic payments by providing your Nostr Wallet Connect URL.
</p>
<p class="text-sm mb-3">
Current setting:{" "}
<span class={recurringEnabled() ? "text-green-700 font-medium" : "text-gray-600"}>
{recurringEnabled() ? "Enabled" : "Disabled"}
</span>
</p>
<div class="flex gap-2">
<input
type="text"
+54 -5
View File
@@ -1,10 +1,18 @@
import { A } from "@solidjs/router"
import { createMemo, createResource, createSignal, For, Show } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, Show } from "solid-js"
import { getProfilePicture } from "applesauce-core/helpers/profile"
import { adminListTenants } from "../../lib/api"
import { eventStore, primeProfiles } from "../../lib/nostr"
function shortenPubkey(pubkey: string) {
if (pubkey.length <= 16) return pubkey
return `${pubkey.slice(0, 8)}...${pubkey.slice(-8)}`
}
export default function AdminTenants() {
const [query, setQuery] = createSignal("")
const [tenants] = createResource(adminListTenants)
const [profiles, setProfiles] = createSignal<Record<string, { name?: string, about?: string, picture?: string }>>({})
const filtered = createMemo(() => {
const q = query().trim().toLowerCase()
@@ -14,6 +22,31 @@ export default function AdminTenants() {
})
})
createEffect(() => {
const list = tenants() ?? []
if (!list.length) return
const pubkeys = list.map(t => t.pubkey)
const reqSub = primeProfiles(pubkeys)
const profileSubs = pubkeys.map((pubkey) =>
eventStore.profile(pubkey).subscribe((profile) => {
setProfiles(prev => ({
...prev,
[pubkey]: {
name: profile?.name || profile?.display_name,
about: profile?.about,
picture: getProfilePicture(profile),
},
}))
}),
)
onCleanup(() => {
reqSub.unsubscribe()
for (const sub of profileSubs) sub.unsubscribe()
})
})
return (
<div class="max-w-4xl mx-auto px-4 py-8">
<h1 class="text-2xl font-bold text-gray-900 mb-6">Tenants</h1>
@@ -34,16 +67,32 @@ export default function AdminTenants() {
<Show when={(filtered().length ?? 0) > 0} fallback={<p class="text-gray-500">No tenants found.</p>}>
<ul class="space-y-3">
<For each={filtered()}>
{(tenant) => (
{(tenant) => {
const profile = () => profiles()[tenant.pubkey]
return (
<li>
<A href={`/admin/tenants/${tenant.pubkey}`} class="block border border-gray-200 rounded-lg p-4 bg-white hover:border-gray-300">
<div class="flex items-center justify-between gap-3">
<p class="text-sm break-all text-gray-700">{tenant.pubkey}</p>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex items-start gap-3">
<Show
when={profile()?.picture}
fallback={<div class="h-10 w-10 rounded-full bg-gray-200 flex items-center justify-center text-gray-500 text-xs">{(profile()?.name || tenant.pubkey).slice(0, 1).toUpperCase()}</div>}
>
<img src={profile()?.picture} alt="Profile" class="h-10 w-10 rounded-full object-cover" />
</Show>
<div class="min-w-0">
<p class="font-medium text-gray-900 truncate">{profile()?.name || shortenPubkey(tenant.pubkey)}</p>
<p class="mt-1 text-sm text-gray-600 line-clamp-2">{profile()?.about || "No profile bio"}</p>
<p class="mt-1 text-xs text-gray-500 break-all">{tenant.pubkey}</p>
</div>
</div>
<p class="text-xs uppercase tracking-wide text-gray-500">{tenant.status}</p>
</div>
</A>
</li>
)}
)
}}
</For>
</ul>
</Show>