From fed9387617a7e245a021b7bc82f94b7199f75631 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 1 Jun 2026 13:53:15 -0700 Subject: [PATCH] Fix login/tenant create race --- frontend/src/lib/state.ts | 45 ++++++++++++++++++++++++++++++++---- frontend/src/views/Login.tsx | 5 ++-- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/state.ts b/frontend/src/lib/state.ts index 22ce970..c6514a7 100644 --- a/frontend/src/lib/state.ts +++ b/frontend/src/lib/state.ts @@ -6,7 +6,7 @@ import { EventStore } from "applesauce-core" import { createEventLoaderForStore } from "applesauce-loaders/loaders" import { RelayPool } from "applesauce-relay" import { NostrConnectSigner } from "applesauce-signers" -import { getIdentity, getTenant, listPlans, listTenantInvoices, listTenantRelays, registerAccountGetter, type Plan } from "@/lib/api" +import { createTenant, getIdentity, getTenant, listPlans, listTenantInvoices, listTenantRelays, registerAccountGetter, type Plan } from "@/lib/api" export type UnsignedEvent = { kind: number @@ -56,10 +56,15 @@ export const [identity, { refetch: refetchIdentity, mutate: setIdentity }] = cre ) // Shared billing reads, fetched once per session and consumed by the dashboard -// shell, the billing page, and the billing-prompt surface. Keyed on the active -// pubkey so they refetch on account switch; refetchBilling() refreshes them all -// after a mutation (payment, method update, plan change). -const billingKey = () => account()?.pubkey +// shell, the billing page, and the billing-prompt surface. They're gated on +// billingPubkey rather than the active account directly: the tenant row is +// provisioned lazily on first login, so getTenant/listTenantInvoices 404 until +// ensureSessionTenant() has created it. billingPubkey is reset on activation and +// re-set once the tenant is ensured, so the reads still refetch on account switch +// without racing ahead of provisioning. refetchBilling() refreshes them all after +// a mutation (payment, method update, plan change). +const [billingPubkey, setBillingPubkey] = createSignal() +const billingKey = () => billingPubkey() export const [billingTenant, { refetch: refetchBillingTenant }] = createResource(billingKey, getTenant) export const [billingInvoices, { refetch: refetchBillingInvoices }] = createResource(billingKey, listTenantInvoices) @@ -71,6 +76,31 @@ export function refetchBilling() { void refetchBillingRelays() } +// Ensure the active pubkey's tenant row exists, then unlock billing reads. The +// tenant is created lazily on first login, so this must run before any +// tenant-scoped read. The in-flight promise is shared so the login flow (which +// awaits it to roll back on failure) and the activation subscriber below don't +// double-provision; createTenant is itself idempotent. +let tenantEnsure: { pubkey: string; promise: Promise } | undefined + +export function ensureSessionTenant(): Promise { + const pubkey = account()?.pubkey + if (!pubkey) return Promise.resolve() + if (tenantEnsure?.pubkey === pubkey) return tenantEnsure.promise + + const promise = (async () => { + try { + await createTenant() + if (account()?.pubkey === pubkey) setBillingPubkey(pubkey) + } finally { + if (tenantEnsure?.pubkey === pubkey) tenantEnsure = undefined + } + })() + + tenantEnsure = { pubkey, promise } + return promise +} + // Deferred to avoid circular-import TDZ errors (api.ts <-> state.ts) queueMicrotask(() => { try { @@ -97,5 +127,10 @@ queueMicrotask(() => { } refetchIdentity() + + // Lock billing reads until the new account's tenant is ensured, so they never + // fire against a not-yet-provisioned tenant during signup. + setBillingPubkey(undefined) + if (account) void ensureSessionTenant().catch(() => {}) }) }) diff --git a/frontend/src/views/Login.tsx b/frontend/src/views/Login.tsx index 23bc9d3..5827941 100644 --- a/frontend/src/views/Login.tsx +++ b/frontend/src/views/Login.tsx @@ -4,8 +4,7 @@ import { ExtensionAccount, NostrConnectAccount, PasswordAccount, PrivateKeyAccou import { PasswordSigner } from "applesauce-signers" import QrScanner from "qr-scanner" import QRCode from "qrcode" -import { accountManager, identity, PLATFORM_NAME } from "@/lib/state" -import { createTenant } from "@/lib/api" +import { accountManager, ensureSessionTenant, identity, PLATFORM_NAME } from "@/lib/state" import useMinLoading from "@/components/useMinLoading" const NIP46_RELAYS = ['wss://bucket.coracle.social', 'wss://ephemeral.snowflare.cc'] @@ -71,7 +70,7 @@ export default function Login(props: LoginPageProps = {}) { accountManager.addAccount(account) accountManager.setActive(account) try { - await createTenant() + await ensureSessionTenant() } catch (e) { accountManager.removeAccount(account) throw e