diff --git a/app/layout.tsx b/app/layout.tsx index 6f539e24b..711851578 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -9,8 +9,20 @@ import { Toaster } from "sonner"; import { CreditsProvider } from "@/lib/providers/CreditsProvider"; import { PostHogProvider } from "@/lib/providers/PostHogProvider"; import PrivyProvider from "@/lib/providers/PrivyProvider"; +import { StewardAuthProvider } from "@/lib/providers/StewardProvider"; import { getRobotsMetadata } from "@/lib/seo"; +const stewardAuthEnabled = process.env.NEXT_PUBLIC_STEWARD_AUTH_ENABLED === "true"; + +/** + * Conditionally wraps children in StewardAuthProvider when enabled. + * Both Privy and Steward providers can coexist, managing separate auth state. + */ +function MaybeStewardProvider({ children }: { children: React.ReactNode }) { + if (!stewardAuthEnabled) return <>{children}; + return {children}; +} + // DM Mono for landing page const dmMono = DM_Mono({ subsets: ["latin"], @@ -140,34 +152,36 @@ export default function RootLayout({ className={`${sfPro.variable} ${dmMono.variable} ${inter.variable} antialiased selection:bg-[#FF5800] selection:text-white`} > - - - - - {children} - - - - + + + + + + {children} + + + + + {shouldEnableVercelAnalytics ? : null} diff --git a/app/login/page.tsx b/app/login/page.tsx index c571a5f67..613ecc83e 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -2,12 +2,18 @@ import { BrandButton, Input } from "@elizaos/cloud-ui"; import { useLogin, useLoginWithEmail, useLoginWithOAuth, usePrivy } from "@privy-io/react-auth"; +import { StewardLogin } from "@stwd/react"; +import { StewardProvider } from "@stwd/react"; import { ArrowLeft, Chrome, Github, Loader2, Mail, Wallet } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { Suspense, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import LandingHeader from "@/packages/ui/src/components/layout/landing-header"; +const STEWARD_AUTH_ENABLED = process.env.NEXT_PUBLIC_STEWARD_AUTH_ENABLED === "true"; +const STEWARD_AUTH_BASE_URL = process.env.NEXT_PUBLIC_STEWARD_AUTH_BASE_URL || "https://api.steward.fi"; +const STEWARD_TENANT_ID = process.env.NEXT_PUBLIC_STEWARD_TENANT_ID || undefined; + // Discord SVG Icon Component const DiscordIcon = ({ className }: { className?: string }) => ( + {/* Steward Auth Section (feature-flagged) */} + {STEWARD_AUTH_ENABLED && !showCodeInput && ( +
+ + { + toast.success("Signed in with Steward!"); + const redirectUrl = getSafeReturnTo(searchParams); + router.replace(redirectUrl); + }} + onError={(err) => { + toast.error(err?.message || "Steward login failed"); + }} + /> + + + {/* Divider between Steward and Privy options */} +
+
+
+
+
+ or +
+
+
+ )} + {/* Email/Code Login Section */} {!showCodeInput ? ( // Email Input diff --git a/bun.lock b/bun.lock index 275336cf4..84c57b8b0 100644 --- a/bun.lock +++ b/bun.lock @@ -61,6 +61,7 @@ "@sendgrid/client": "^8.1.6", "@sendgrid/helpers": "^8.0.0", "@sendgrid/mail": "^8.1.6", + "@simplewebauthn/browser": "^13.3.0", "@smithy/smithy-client": "^4.12.3", "@solana-program/memo": "^0.10.0", "@solana-program/system": "^0.10.0", @@ -72,7 +73,8 @@ "@solana/web3.js": "^1.98.4", "@stripe/react-stripe-js": "^5.4.1", "@stripe/stripe-js": "^8.6.4", - "@stwd/sdk": "^0.3.0", + "@stwd/react": "0.6.4", + "@stwd/sdk": "0.7.2", "@tabler/icons-react": "^3.36.1", "@tailwindcss/postcss": "^4.1.18", "@tailwindcss/typography": "^0.5.19", @@ -1543,7 +1545,9 @@ "@stripe/stripe-js": ["@stripe/stripe-js@8.11.0", "", {}, "sha512-3fVF4z3efsgwgyj64nFK+6F4/vMw0mUXD2TBbOfftJtKVNx4JNv3CSfe1fY4DCtCk0JFp8/YPNcRkzgV0HJ8cg=="], - "@stwd/sdk": ["@stwd/sdk@0.3.0", "", {}, "sha512-GUx+6lskxFA9PTlPtfVeZbKiIb0mZelBNjvodr8fiayyJIyJVbAzmr2gMLhbM88AGSqqp+BH6ur2GUhBosFl+Q=="], + "@stwd/react": ["@stwd/react@0.6.4", "", { "dependencies": { "@stwd/sdk": "^0.7.0" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-DWDpDVDTwFLzOqbTRgigjGHUfpeUvAesMa6yG7c5Uc0dPQciUqmKhzxcEw9id8aMYTiMnn6A0zIv4hpQYZlFSg=="], + + "@stwd/sdk": ["@stwd/sdk@0.7.2", "", {}, "sha512-qu1gyULCZ7YKAWGYQ8wkVDrvsxDJsn/ffVYjhR6HNCoBEoK29LW4Gwekxr2PprBMeAXsEtxhkDkyTj/5ly/W/Q=="], "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], diff --git a/package.json b/package.json index 4995299a6..bd8ffaf6c 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,6 @@ "@fal-ai/server-proxy": "^1.1.2", "@modelcontextprotocol/sdk": "^1.27.1", "@monaco-editor/react": "^4.7.0", - "monaco-editor": "^0.52.2", "@neondatabase/serverless": "^1.0.2", "@octokit/rest": "^22.0.1", "@privy-io/react-auth": "^3.11.0", @@ -134,6 +133,7 @@ "@sendgrid/client": "^8.1.6", "@sendgrid/helpers": "^8.0.0", "@sendgrid/mail": "^8.1.6", + "@simplewebauthn/browser": "^13.3.0", "@smithy/smithy-client": "^4.12.3", "@solana-program/memo": "^0.10.0", "@solana-program/system": "^0.10.0", @@ -145,7 +145,8 @@ "@solana/web3.js": "^1.98.4", "@stripe/react-stripe-js": "^5.4.1", "@stripe/stripe-js": "^8.6.4", - "@stwd/sdk": "^0.3.0", + "@stwd/react": "0.6.4", + "@stwd/sdk": "0.7.2", "@tabler/icons-react": "^3.36.1", "@tailwindcss/postcss": "^4.1.18", "@tailwindcss/typography": "^0.5.19", @@ -192,6 +193,7 @@ "libphonenumber-js": "^1.12.35", "lucide-react": "^0.562.0", "mcp-handler": "^1.0.7", + "monaco-editor": "^0.52.2", "motion": "^12.29.0", "nanoid": "^5.1.6", "next": "^16.1.6", diff --git a/packages/lib/auth.ts b/packages/lib/auth.ts index 118d65e02..e0116c79f 100644 --- a/packages/lib/auth.ts +++ b/packages/lib/auth.ts @@ -24,7 +24,13 @@ import { invalidatePrivyTokenCache, verifyAuthTokenCached, } from "./auth/privy-client"; +import { + invalidateStewardTokenCache, + verifyStewardTokenCached, +} from "./auth/steward-client"; import { syncUserFromPrivy } from "./privy-sync"; +// TODO: Import syncUserFromSteward once steward-sync module is created +// import { syncUserFromSteward } from "./steward-sync"; // Re-export Organization type for convenience export type { Organization }; @@ -53,8 +59,11 @@ export async function invalidateUserSessionCache(sessionToken: string): Promise< * @param sessionToken - The Privy auth token to invalidate */ export async function invalidateSessionCaches(sessionToken: string): Promise { - await invalidatePrivyTokenCache(sessionToken); - logger.debug("[AUTH] Invalidated all session caches (Privy + user)"); + await Promise.all([ + invalidatePrivyTokenCache(sessionToken), + invalidateStewardTokenCache(sessionToken), + ]); + logger.debug("[AUTH] Invalidated all session caches (Privy + Steward + user)"); } /** @@ -507,8 +516,10 @@ export async function requireAuthOrApiKey(request: NextRequest): Promise( + 200, + 30_000, +); + +/** + * Extract StewardTokenClaims from a raw jose JWTPayload. + */ +function extractClaims(payload: JWTPayload): StewardTokenClaims { + return { + userId: (payload.sub ?? payload.userId ?? "") as string, + email: payload.email as string | undefined, + address: payload.address as string | undefined, + tenantId: (payload.tenantId ?? payload.tenant_id) as string | undefined, + expiration: payload.exp ?? 0, + issuedAt: payload.iat ?? 0, + }; +} + +/** + * Verify a Steward JWT with caching. + * + * Cache layers (fastest to slowest): + * 1. In-memory LRU: ~0ms (same serverless instance, 30s TTL) + * 2. Redis: ~5ms (cross-instance, 5min TTL) + * 3. Local jose verify: ~1-5ms (no third-party API call) + * + * @param token - The Steward JWT from Authorization header + * @returns Verified claims or null if invalid/expired/missing secret + */ +export async function verifyStewardTokenCached( + token: string, +): Promise { + const secret = getJwtSecret(); + if (!secret) return null; + + const tokenHash = hashToken(token); + const cacheKey = CacheKeys.session.steward(tokenHash); + const now = Math.floor(Date.now() / 1000); + const startTime = Date.now(); + + try { + // 0. Check in-memory cache first + const inMemoryCached = IN_MEMORY_STEWARD_CACHE.get(tokenHash); + if (inMemoryCached && inMemoryCached.expiration > now) { + logger.debug("[StewardClient] ✓ In-memory cache hit", { + tokenHash: tokenHash.substring(0, 8), + durationMs: Date.now() - startTime, + }); + return inMemoryCached; + } + + // 1. Check Redis cache + const cached = await cache.get(cacheKey); + if (cached && cached.expiration > now) { + logger.debug("[StewardClient] ✓ Redis cache hit", { + tokenHash: tokenHash.substring(0, 8), + userId: cached.userId.substring(0, 20), + durationMs: Date.now() - startTime, + }); + + const claims: StewardTokenClaims = { + userId: cached.userId, + email: cached.email, + address: cached.address, + tenantId: cached.tenantId, + expiration: cached.expiration, + issuedAt: cached.issuedAt, + }; + + // Populate in-memory cache from Redis hit + IN_MEMORY_STEWARD_CACHE.set(tokenHash, claims); + return claims; + } + + if (cached) { + // Expired entry, clean up + await cache.del(cacheKey); + } + + // 2. Cache miss: verify JWT with jose + logger.debug("[StewardClient] Cache miss, verifying JWT locally", { + tokenHash: tokenHash.substring(0, 8), + }); + + const { payload } = await jwtVerify(token, secret, { + // Accept HS256 (symmetric) and RS256/ES256 if needed in future + algorithms: ["HS256"], + }); + + const claims = extractClaims(payload); + + if (!claims.userId) { + logger.warn("[StewardClient] JWT valid but missing userId/sub claim"); + return null; + } + + // 3. Cache the result + const tokenRemainingSeconds = claims.expiration - now; + const effectiveTtl = Math.min(CacheTTL.session.steward, tokenRemainingSeconds); + + if (effectiveTtl > 0) { + const cachedClaims: CachedStewardClaims = { + ...claims, + cachedAt: Date.now(), + }; + + await cache.set(cacheKey, cachedClaims, effectiveTtl); + + logger.debug("[StewardClient] ✓ Cached verification result", { + tokenHash: tokenHash.substring(0, 8), + userId: claims.userId.substring(0, 20), + ttlSeconds: effectiveTtl, + durationMs: Date.now() - startTime, + }); + } + + // Also cache in-memory + IN_MEMORY_STEWARD_CACHE.set(tokenHash, claims); + + return claims; + } catch (error) { + const isExpectedFailure = + error instanceof Error && + (error.message.includes("JWSInvalid") || + error.message.includes("JWTExpired") || + error.message.includes("JWTClaimValidationFailed") || + error.message.includes("Invalid Compact JWS") || + error.message.includes("signature verification failed") || + ("code" in error && + (error.code === "ERR_JWS_INVALID" || + error.code === "ERR_JWT_EXPIRED" || + error.code === "ERR_JWT_CLAIM_VALIDATION_FAILED" || + error.code === "ERR_JWS_SIGNATURE_VERIFICATION_FAILED"))); + + if (isExpectedFailure) { + logger.debug( + "[StewardClient] Token verification failed (invalid/expired):", + error instanceof Error ? error.message : "Unknown error", + ); + return null; + } + + logger.error( + "[StewardClient] ✗ Unexpected verification error:", + error instanceof Error ? error.message : "Unknown error", + ); + return null; + } +} + +/** + * Invalidate the cache for a specific Steward token. + * Call on logout to ensure immediate token invalidation. + */ +export async function invalidateStewardTokenCache( + token: string, +): Promise { + const tokenHash = hashToken(token); + + IN_MEMORY_STEWARD_CACHE.delete(tokenHash); + + await Promise.all([ + cache.del(CacheKeys.session.steward(tokenHash)), + cache.del(CacheKeys.session.user(tokenHash)), + ]); + + logger.debug( + "[StewardClient] ✓ Invalidated token cache (in-memory + Redis)", + { + tokenHash: tokenHash.substring(0, 8), + }, + ); +} diff --git a/packages/lib/cache/keys.ts b/packages/lib/cache/keys.ts index d03ad84bc..44aa70fca 100644 --- a/packages/lib/cache/keys.ts +++ b/packages/lib/cache/keys.ts @@ -47,6 +47,8 @@ export const CacheKeys = { session: { /** Cache session token validation results */ privy: (tokenHash: string) => `session:privy:${tokenHash}:v1`, + /** Cache Steward JWT verification results */ + steward: (tokenHash: string) => `session:steward:${tokenHash}:v1`, /** Cache user data by session token */ user: (tokenHash: string) => `session:user:${tokenHash}:v1`, pattern: () => `session:*`, @@ -240,6 +242,7 @@ export const CacheTTL = { }, session: { privy: 300, // 5 minutes - Privy token validation + steward: 300, // 5 minutes - Steward JWT validation (mirrors Privy) user: 300, // 5 minutes - User data by session }, user: { diff --git a/packages/lib/providers/StewardProvider.tsx b/packages/lib/providers/StewardProvider.tsx new file mode 100644 index 000000000..52eb4f9e1 --- /dev/null +++ b/packages/lib/providers/StewardProvider.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { + StewardProvider, + useAuth as useStewardAuth, +} from "@stwd/react"; +import { StewardClient } from "@stwd/sdk"; +import { useEffect, useMemo, useRef } from "react"; + +/** + * Steward auth provider for Eliza Cloud. + * + * Mirrors the PrivyProvider pattern: wraps children in an auth context, + * syncs JWT tokens to a global API client, and validates env config on mount. + * + * Requires NEXT_PUBLIC_STEWARD_API_URL to be set. + * Optional: NEXT_PUBLIC_STEWARD_TENANT_ID for multi-tenant setups. + */ + +function isPlaceholderValue(value: string | undefined): boolean { + if (!value) return true; + const normalized = value.trim().toLowerCase(); + return ( + normalized.length === 0 || + normalized.includes("your_steward_") || + normalized.includes("your-steward-") || + normalized.includes("replace_with") || + normalized.includes("placeholder") + ); +} + +/** + * Inner wrapper that syncs the Steward JWT to a global API client + * so authenticated requests outside React components work correctly. + */ +function AuthTokenSync({ children }: { children: React.ReactNode }) { + const { isAuthenticated, getToken, user } = useStewardAuth(); + const lastSyncedToken = useRef(null); + + useEffect(() => { + if (!isAuthenticated) { + lastSyncedToken.current = null; + return; + } + + const token = getToken(); + if (token && token !== lastSyncedToken.current) { + lastSyncedToken.current = token; + + // Dispatch a custom event so non-React code (fetch wrappers, etc.) + // can pick up the fresh JWT without coupling to React context. + window.dispatchEvent( + new CustomEvent("steward-token-sync", { + detail: { token, userId: user?.id }, + }), + ); + } + }, [isAuthenticated, getToken, user]); + + return children; +} + +export function StewardAuthProvider({ + children, +}: { + children: React.ReactNode; +}) { + const hasLoggedConfigError = useRef(false); + + const apiUrl = process.env.NEXT_PUBLIC_STEWARD_API_URL ?? "http://localhost:3200"; + const tenantId = process.env.NEXT_PUBLIC_STEWARD_TENANT_ID; + const hasValidUrl = !isPlaceholderValue(apiUrl); + + // Create a StewardClient instance once (no API key needed for user-facing auth flows) + const client = useMemo( + () => + new StewardClient({ + baseUrl: apiUrl, + ...(tenantId && !isPlaceholderValue(tenantId) ? { tenantId } : {}), + }), + [apiUrl, tenantId], + ); + + useEffect(() => { + if (typeof window === "undefined" || hasValidUrl || hasLoggedConfigError.current) return; + hasLoggedConfigError.current = true; + console.error( + "NEXT_PUBLIC_STEWARD_API_URL is missing or invalid! Steward auth will not function.", + ); + }, [hasValidUrl]); + + if (!hasValidUrl) { + // Steward is optional, so we just render children without the provider + // rather than showing an error screen (unlike Privy which is required). + return <>{children}; + } + + return ( + + {children} + + ); +} diff --git a/packages/lib/steward-sync.ts b/packages/lib/steward-sync.ts new file mode 100644 index 000000000..d0b34d812 --- /dev/null +++ b/packages/lib/steward-sync.ts @@ -0,0 +1,645 @@ +/** + * Steward User Synchronization + * + * Resolves a Steward JWT to an eliza-cloud user. Mirrors privy-sync.ts pattern + * for backward compatibility but is significantly simpler because: + * + * 1. Steward JWTs contain email/userId/walletAddress directly (no third-party API call) + * 2. No anonymous user upgrade path (Steward doesn't have anonymous users) + * 3. Uses steward_user_id column instead of privy_user_id + */ + +import { organizationInvitesRepository } from "@/db/repositories/organization-invites"; +import { usersRepository } from "@/db/repositories/users"; +import { apiKeysService } from "@/lib/services/api-keys"; +import { charactersService } from "@/lib/services/characters/characters"; +import { creditsService } from "@/lib/services/credits"; +import { discordService } from "@/lib/services/discord"; +import { emailService } from "@/lib/services/email"; +import { invitesService } from "@/lib/services/invites"; +import { organizationsService } from "@/lib/services/organizations"; +import { usersService } from "@/lib/services/users"; +import type { UserWithOrganization } from "@/lib/types"; +import { getDefaultElizaCharacterData } from "@/lib/utils/default-eliza-character"; +import { getRandomUserAvatar } from "@/lib/utils/default-user-avatar"; +import { logger } from "@/lib/utils/logger"; + +const DEFAULT_INITIAL_CREDITS = 5.0; +const getInitialCredits = (): number => { + const envValue = process.env.INITIAL_FREE_CREDITS; + if (envValue) { + const parsed = parseFloat(envValue); + if (!isNaN(parsed) && parsed >= 0) { + return parsed; + } + } + return DEFAULT_INITIAL_CREDITS; +}; + +const STEWARD_IDENTITY_UNIQUE_CONSTRAINT = "user_identities_steward_user_id_unique"; + +function extractErrorMetadata(candidate: unknown): { + code?: string; + constraint?: string; + detail?: string; + message: string; +} { + if (!candidate || typeof candidate !== "object") { + return { message: String(candidate ?? "") }; + } + + const typedCandidate = candidate as { + code?: unknown; + constraint?: unknown; + detail?: unknown; + message?: unknown; + }; + + return { + code: typeof typedCandidate.code === "string" ? typedCandidate.code : undefined, + constraint: + typeof typedCandidate.constraint === "string" ? typedCandidate.constraint : undefined, + detail: typeof typedCandidate.detail === "string" ? typedCandidate.detail : undefined, + message: + typeof typedCandidate.message === "string" ? typedCandidate.message : String(candidate), + }; +} + +function isRecoverableStewardProjectionConflict(error: unknown): boolean { + if (!error || typeof error !== "object") { + return false; + } + + const errorMetadata = extractErrorMetadata(error); + const causeMetadata = "cause" in error ? extractErrorMetadata(error.cause) : { message: "" }; + const isUniqueViolation = errorMetadata.code === "23505" || causeMetadata.code === "23505"; + const hasExactStewardConstraint = + errorMetadata.constraint === STEWARD_IDENTITY_UNIQUE_CONSTRAINT || + causeMetadata.constraint === STEWARD_IDENTITY_UNIQUE_CONSTRAINT; + + return isUniqueViolation && hasExactStewardConstraint; +} + +async function recoverCanonicalStewardUser( + expectedUserId: string, + stewardUserId: string, + context: "invite" | "signup", + error: unknown, +): Promise { + if (!isRecoverableStewardProjectionConflict(error)) { + return false; + } + + const projection = await usersService.getStewardIdentityForWrite(stewardUserId); + if (!projection || projection.user_id !== expectedUserId) { + return false; + } + + const user = await usersService.getByStewardIdForWrite(stewardUserId); + if (!user || user.id !== expectedUserId) { + return false; + } + + logger.warn("[StewardSync] Recovered from stale Steward identity projection conflict", { + context, + expectedUserId, + stewardUserId, + error: error instanceof Error ? error.message : String(error), + }); + + return true; +} + +async function rollbackCreatedUserSafely( + userId: string, + context: "invite" | "signup", + originalError: unknown, +): Promise { + try { + await usersRepository.delete(userId); + } catch (rollbackError) { + logger.error("[StewardSync] Failed to roll back newly created user", { + context, + userId, + originalError: originalError instanceof Error ? originalError.message : String(originalError), + rollbackError: rollbackError instanceof Error ? rollbackError.message : String(rollbackError), + }); + } +} + +async function restorePreviousStewardUserIdSafely( + userId: string, + previousStewardUserId: string | null | undefined, + originalError: unknown, +): Promise { + try { + await usersService.update(userId, { + steward_user_id: previousStewardUserId, + updated_at: new Date(), + }); + } catch (rollbackError) { + logger.error("[StewardSync] Failed to restore previous Steward user ID", { + userId, + previousStewardUserId, + originalError: originalError instanceof Error ? originalError.message : String(originalError), + rollbackError: rollbackError instanceof Error ? rollbackError.message : String(rollbackError), + }); + } +} + +/** + * Generates a unique organization slug from an email address. + */ +function generateSlugFromEmail(email: string): string { + const username = email.split("@")[0]; + const sanitized = username.toLowerCase().replace(/[^a-z0-9]/g, "-"); + const random = Math.random().toString(36).substring(2, 8); + const timestamp = Date.now().toString(36).slice(-4); + return `${sanitized}-${timestamp}${random}`; +} + +/** + * Generates a unique organization slug from a wallet address. + */ +function generateSlugFromWallet(walletAddress: string): string { + const shortAddress = walletAddress.substring(0, 8); + const sanitized = shortAddress.toLowerCase().replace(/[^a-z0-9]/g, "-"); + const random = Math.random().toString(36).substring(2, 8); + const timestamp = Date.now().toString(36).slice(-4); + return `wallet-${sanitized}-${timestamp}${random}`; +} + +export interface StewardSyncParams { + stewardUserId: string; + email?: string; + walletAddress?: string; + walletChainType?: "ethereum" | "solana"; + name?: string; +} + +/** + * Sync a Steward user to the local database. + * Creates user and organization if they don't exist. + * Updates user data if it has changed. + * + * Flow: + * 1. Check if user exists by steward_user_id -> return existing (update if needed) + * 2. Check for pending invite by email -> accept invite, create user in that org + * 3. Check if email already taken -> link steward_user_id to existing account + * 4. Create new user + organization + */ +export async function syncUserFromSteward( + params: StewardSyncParams, +): Promise { + const { stewardUserId, walletChainType } = params; + const email = params.email?.toLowerCase().trim(); + const walletAddress = params.walletAddress?.toLowerCase(); + + // Resolve display name with fallbacks + let name = params.name; + if (!name && email) { + name = email.split("@")[0]; + } else if (!name && walletAddress) { + name = `${walletAddress.substring(0, 6)}...${walletAddress.substring(walletAddress.length - 4)}`; + } else if (!name) { + name = `user-${stewardUserId.substring(0, 8)}`; + } + + // ── 1. Existing user by steward_user_id ────────────────────────────── + let user = await usersService.getByStewardId(stewardUserId); + + if (user) { + // Ensure identity projection is current + try { + await usersService.upsertStewardIdentity(user.id, stewardUserId); + } catch (error) { + logger.warn("[StewardSync] Failed to repair Steward identity projection for existing user", { + userId: user.id, + stewardUserId, + error: error instanceof Error ? error.message : String(error), + }); + } + + // Update user fields if anything changed + const shouldUpdate = + user.name !== name || + user.email !== email || + user.wallet_address !== walletAddress || + (email && !user.email_verified); + + if (shouldUpdate) { + await usersService.update(user.id, { + name, + email: email || user.email, + email_verified: !!email || user.email_verified, + wallet_address: walletAddress || user.wallet_address, + wallet_chain_type: walletChainType || user.wallet_chain_type, + updated_at: new Date(), + }); + + // Re-read from primary to avoid replica lag + user = (await usersService.getByStewardIdForWrite(stewardUserId))!; + } + + return user; + } + + // ── 2. Pending invite by email ─────────────────────────────────────── + if (email) { + const pendingInvite = await invitesService.findPendingInviteByEmail(email); + + if (pendingInvite) { + let newUser: Awaited> | undefined; + + try { + newUser = await usersService.create({ + steward_user_id: stewardUserId, + email: email || null, + email_verified: !!email, + wallet_address: walletAddress || null, + wallet_chain_type: walletChainType || null, + wallet_verified: false, + name, + avatar: getRandomUserAvatar(), + organization_id: pendingInvite.organization_id, + role: pendingInvite.invited_role, + is_active: true, + }); + await usersService.upsertStewardIdentity(newUser.id, stewardUserId); + } catch (error) { + const recovered = + newUser && + (await recoverCanonicalStewardUser(newUser.id, stewardUserId, "invite", error)); + + if (newUser && !recovered) { + await rollbackCreatedUserSafely(newUser.id, "invite", error); + } + if (!recovered) { + throw error; + } + } + + const userWithOrg = await usersService.getByStewardIdForWrite(stewardUserId); + + if (!userWithOrg) { + throw new Error( + `Failed to fetch newly created user (steward: ${stewardUserId}) after accepting invite`, + ); + } + + await organizationInvitesRepository.markAsAccepted(pendingInvite.id, userWithOrg.id); + + // Log to Discord (fire-and-forget) + discordService + .logUserSignup({ + userId: userWithOrg.id, + privyUserId: userWithOrg.privy_user_id || "", + email: userWithOrg.email || null, + name: userWithOrg.name || null, + walletAddress: userWithOrg.wallet_address || null, + organizationId: userWithOrg.organization?.id || "", + organizationName: userWithOrg.organization?.name || "", + role: userWithOrg.role, + isNewOrganization: false, + }) + .catch((error) => { + logger.error("[StewardSync] Discord log failed:", { error }); + }); + + return userWithOrg; + } + } + + // ── 3. Email already taken (account linking) ───────────────────────── + if (email) { + const existingByEmail = await usersService.getByEmailWithOrganization(email); + + if (existingByEmail && existingByEmail.steward_user_id !== stewardUserId) { + logger.info( + `[StewardSync] Linking Steward account for ${email}: ${existingByEmail.steward_user_id} → ${stewardUserId}`, + ); + const previousStewardUserId = existingByEmail.steward_user_id; + + await usersService.update(existingByEmail.id, { + steward_user_id: stewardUserId, + updated_at: new Date(), + }); + + try { + await usersService.upsertStewardIdentity(existingByEmail.id, stewardUserId); + } catch (error) { + await restorePreviousStewardUserIdSafely( + existingByEmail.id, + previousStewardUserId, + error, + ); + throw error; + } + + const linkedUser = await usersService.getByStewardIdForWrite(stewardUserId); + if (!linkedUser) { + throw new Error(`Failed to fetch user after Steward account linking for ${email}`); + } + return linkedUser; + } + } + + // ── 4. Create new user + organization ──────────────────────────────── + + // Generate organization slug + let orgSlug: string; + if (email) { + orgSlug = generateSlugFromEmail(email); + } else if (walletAddress) { + orgSlug = generateSlugFromWallet(walletAddress); + } else if (name) { + const sanitized = name.toLowerCase().replace(/[^a-z0-9]/g, "-"); + const random = Math.random().toString(36).substring(2, 8); + const timestamp = Date.now().toString(36).slice(-4); + orgSlug = `${sanitized}-${timestamp}${random}`; + } else { + throw new Error(`Cannot generate organization slug for Steward user ${stewardUserId}`); + } + + // Ensure slug uniqueness + let attempts = 0; + while (await organizationsService.getBySlug(orgSlug)) { + attempts++; + if (attempts > 10) { + throw new Error( + `Failed to generate unique organization slug for Steward user ${stewardUserId}`, + ); + } + orgSlug = email ? generateSlugFromEmail(email) : generateSlugFromWallet(walletAddress!); + } + + // Create organization with zero balance initially + const organization = await organizationsService.create({ + name: `${name}'s Organization`, + slug: orgSlug, + credit_balance: "0.00", + }); + + // Add initial free credits + const initialCredits = getInitialCredits(); + + if (initialCredits > 0) { + try { + await creditsService.addCredits({ + organizationId: organization.id, + amount: initialCredits, + description: "Initial free credits - Welcome bonus", + metadata: { + type: "initial_free_credits", + source: "signup", + }, + }); + } catch (_error) { + await organizationsService.update(organization.id, { + credit_balance: String(initialCredits), + }); + } + } + + // Create user, handle race conditions + let createdUser: Awaited> | undefined; + + try { + createdUser = await usersService.create({ + steward_user_id: stewardUserId, + email: email || null, + email_verified: !!email, + wallet_address: walletAddress || null, + wallet_chain_type: walletChainType || null, + wallet_verified: false, + name, + avatar: getRandomUserAvatar(), + organization_id: organization.id, + role: "owner", + is_active: true, + }); + } catch (error) { + const isDuplicateError = + error && + typeof error === "object" && + (("code" in error && error.code === "23505") || + ("cause" in error && + error.cause && + typeof error.cause === "object" && + "code" in error.cause && + error.cause.code === "23505")); + + if (isDuplicateError) { + let existingUser: UserWithOrganization | undefined; + const maxRetries = 3; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + if (attempt > 0) { + await new Promise((resolve) => setTimeout(resolve, 50 * 2 ** (attempt - 1))); + } + + existingUser = await usersService.getByStewardIdForWrite(stewardUserId); + if (existingUser) break; + + if (email) { + existingUser = await usersService.getByEmailWithOrganization(email); + } + + if (existingUser) { + if (existingUser.steward_user_id !== stewardUserId) { + logger.info( + `[StewardSync] Linking Steward account for ${email}: ${existingUser.steward_user_id} → ${stewardUserId}`, + ); + const previousStewardUserId = existingUser.steward_user_id; + await usersService.update(existingUser.id, { + steward_user_id: stewardUserId, + updated_at: new Date(), + }); + try { + await usersService.upsertStewardIdentity(existingUser.id, stewardUserId); + } catch (upsertError) { + await usersService.update(existingUser.id, { + steward_user_id: previousStewardUserId, + updated_at: new Date(), + }); + throw upsertError; + } + await organizationsService.delete(organization.id); + const linkedUser = await usersService.getByStewardIdForWrite(stewardUserId); + if (!linkedUser) { + throw new Error( + `Failed to fetch user after Steward account linking for ${email}`, + ); + } + return linkedUser; + } + break; + } + } + + if (existingUser) { + await organizationsService.delete(organization.id); + return existingUser; + } + + logger.error( + `[StewardSync] Duplicate key error but user (steward: ${stewardUserId}) not found after ${maxRetries} retries`, + ); + await organizationsService.delete(organization.id); + } + + logger.error("[StewardSync] Failed to create user", { + stewardUserId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + + if (!createdUser) { + throw new Error(`Failed to create user for Steward user ${stewardUserId}`); + } + + // Upsert identity projection + try { + await usersService.upsertStewardIdentity(createdUser.id, stewardUserId); + } catch (error) { + const recovered = await recoverCanonicalStewardUser( + createdUser.id, + stewardUserId, + "signup", + error, + ); + + if (!recovered) { + await rollbackCreatedUserSafely(createdUser.id, "signup", error); + await organizationsService.delete(organization.id); + throw error; + } + } + + // Fetch final user with organization + const userWithOrg = await usersService.getByStewardIdForWrite(stewardUserId); + + if (!userWithOrg) { + throw new Error(`Failed to fetch newly created Steward user ${stewardUserId}`); + } + + // Send welcome email (fire-and-forget) + const recipientEmail = email || userWithOrg.organization?.billing_email; + if (recipientEmail) { + queueWelcomeEmail({ + email: recipientEmail, + userName: name || "there", + organizationName: userWithOrg.organization?.name || "", + creditBalance: initialCredits, + }).catch((error) => { + logger.error("[StewardSync] Failed to send welcome email:", { error }); + }); + } else { + logger.warn("[StewardSync] No email available for welcome email", { + userId: userWithOrg.id, + stewardUserId, + walletAddress, + }); + } + + // Log to Discord (fire-and-forget) + discordService + .logUserSignup({ + userId: userWithOrg.id, + privyUserId: userWithOrg.privy_user_id || "", + email: userWithOrg.email || null, + name: userWithOrg.name || null, + walletAddress: userWithOrg.wallet_address || null, + organizationId: userWithOrg.organization?.id || "", + organizationName: userWithOrg.organization?.name || "", + role: userWithOrg.role, + isNewOrganization: true, + }) + .catch((error) => { + logger.error("[StewardSync] Discord signup log failed:", { error }); + }); + + // Auto-generate default API key (fire-and-forget) + void ensureUserHasApiKey(userWithOrg.id, userWithOrg.organization?.id || ""); + + // Auto-create default Eliza character (fire-and-forget) + void ensureDefaultCharacter(userWithOrg.id, userWithOrg.organization?.id || ""); + + return userWithOrg; +} + +/** + * Ensures a user has a default API key for programmatic access. + */ +async function ensureUserHasApiKey(userId: string, organizationId: string): Promise { + if (!userId?.trim() || !organizationId?.trim()) { + logger.warn("[StewardSync] Invalid userId or organizationId, skipping API key creation"); + return; + } + + try { + const existingKeys = await apiKeysService.listByOrganization(organizationId); + if (existingKeys.some((key) => key.user_id === userId)) { + return; + } + + await apiKeysService.create({ + user_id: userId, + organization_id: organizationId, + name: "Default API Key", + is_active: true, + }); + } catch (error) { + logger.error("[StewardSync] Error creating API key", { + userId, + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** + * Ensures a new account starts with a default Eliza character. + */ +async function ensureDefaultCharacter(userId: string, organizationId: string): Promise { + if (!userId?.trim() || !organizationId?.trim()) { + logger.warn("[StewardSync] Invalid userId or organizationId, skipping default character"); + return; + } + + try { + const existing = await charactersService.listByOrganization(organizationId); + if (existing.length > 0) { + return; + } + + const defaultData = getDefaultElizaCharacterData(); + await charactersService.create({ + ...defaultData, + user_id: userId, + organization_id: organizationId, + }); + + logger.info(`[StewardSync] Created default Eliza character for user ${userId}`); + } catch (error) { + logger.error("[StewardSync] Error creating default character", { + userId, + error: error instanceof Error ? error.message : String(error), + }); + } +} + +/** + * Queues a welcome email for a new Steward user. + */ +async function queueWelcomeEmail(data: { + email: string; + userName: string; + organizationName: string; + creditBalance: number; +}): Promise { + await emailService.sendWelcomeEmail({ + ...data, + dashboardUrl: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`, + }); +} diff --git a/packages/scripts/provision-waifu-tenant.ts b/packages/scripts/provision-waifu-tenant.ts new file mode 100644 index 000000000..ddbfa0082 --- /dev/null +++ b/packages/scripts/provision-waifu-tenant.ts @@ -0,0 +1,46 @@ +/** + * Provision a steward tenant for waifu.fun + * + * Usage: + * STEWARD_API_URL=http://steward:3200 \ + * STEWARD_PLATFORM_KEYS=stw_plat... \ + * bun run packages/scripts/provision-waifu-tenant.ts + */ + +const STEWARD_URL = process.env.STEWARD_API_URL || "http://localhost:3200"; +const PLATFORM_KEY = (process.env.STEWARD_PLATFORM_KEYS ?? "").split(",")[0].trim(); + +if (!PLATFORM_KEY) { + console.error("STEWARD_PLATFORM_KEYS is required"); + process.exit(1); +} + +console.log(`Provisioning waifu tenant on ${STEWARD_URL}...`); + +const res = await fetch(`${STEWARD_URL}/platform/tenants`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Steward-Platform-Key": PLATFORM_KEY, + }, + body: JSON.stringify({ + id: "waifu", + name: "waifu.fun", + }), +}); + +const data = await res.json(); + +if (res.ok) { + console.log("✅ Tenant created:"); + console.log(` ID: waifu`); + console.log(` API Key: ${data.data?.apiKey ?? "(check response)"}`); + console.log("\nAdd to waifu.fun .env:"); + console.log(` STEWARD_TENANT_ID=waifu`); + console.log(` STEWARD_TENANT_API_KEY=${data.data?.apiKey ?? "..."}`); +} else if (res.status === 409) { + console.log("ℹ️ Tenant 'waifu' already exists"); +} else { + console.error("❌ Failed:", data); + process.exit(1); +}