From b4be8ca9d83f28fba6612d74e6990305d2986fd8 Mon Sep 17 00:00:00 2001 From: Odilitime Date: Mon, 30 Mar 2026 04:15:16 +0000 Subject: [PATCH 01/22] feat(referrals): invite links via GET /api/v1/referrals and dashboard UX Add authenticated GET endpoint that idempotently ensures a referral code and returns flat JSON for clients. Surface copy-to-clipboard invite links in the dashboard header and on the Affiliates page, with distinct styling and copy from affiliates. Return 403 for ForbiddenError, block sharing inactive codes, and hide the header Invite control during auth grace. Extend e2e coverage for the new GET route. Document flows in docs/referrals.md and docs/affiliate-referral-comparison.md, update README, ROADMAP, and changelog, and add WHY-oriented code comments. Made-with: Cursor --- README.md | 3 +- app/api/v1/referrals/route.ts | 90 ++++++++++++ docs/ROADMAP.md | 11 ++ docs/affiliate-referral-comparison.md | 14 ++ docs/referrals.md | 115 +++++++++++++++ packages/content/changelog.mdx | 9 ++ packages/lib/services/referrals.ts | 5 + packages/lib/types/referral-me.ts | 28 ++++ packages/tests/e2e/v1/affiliates.test.ts | 24 +++ .../affiliates/affiliates-page-client.tsx | 137 +++++++++++++++++- .../affiliates/affiliates-page-wrapper.tsx | 4 +- .../layout/header-invite-button.tsx | 113 +++++++++++++++ packages/ui/src/components/layout/header.tsx | 3 + 13 files changed, 550 insertions(+), 6 deletions(-) create mode 100644 app/api/v1/referrals/route.ts create mode 100644 docs/affiliate-referral-comparison.md create mode 100644 docs/referrals.md create mode 100644 packages/lib/types/referral-me.ts create mode 100644 packages/ui/src/components/layout/header-invite-button.tsx diff --git a/README.md b/README.md index d6439da63..fd1cd658e 100644 --- a/README.md +++ b/README.md @@ -1158,8 +1158,9 @@ See `docs/STRIPE_SETUP.md` for detailed Stripe configuration. - **Referrals**: Signup-based. When a user signs up with a referral code, we record the link; when they buy credits (Stripe or x402), we **redistribute 100%** of that purchase in a 50/40/10 split (ElizaCloud / app owner / creator). Signup and qualified bonuses ($1 + $0.50 + $0.50) are minted as marketing spend, not carved from revenue. **Why:** One predictable split model; no risk of over-paying (splits always sum to 100%). - **Affiliates**: Link-based. Users can be linked to an affiliate code; on **auto top-up** and **MCP usage** we add a markup (default 20%) to what the customer pays and pay that to the affiliate. **Why:** Affiliate cost is passed to the customer, so we never over-allocate. - **No double-apply:** Referral splits apply only to Stripe checkout and x402; affiliate markup only to auto top-up and MCP. No single transaction pays both. +- **Invite links (referral):** Signed-in users can copy `…/login?ref=` from the dashboard header (**Invite**) and from the **Invite friends** card on `/dashboard/affiliates`. **`GET /api/v1/referrals`** returns `{ code, total_referrals, is_active }` and creates a code on first use. **`POST /api/v1/referrals/apply`** still applies someone else’s code after login. **Why:** Referral economics existed in code, but there was no product surface for “my link”; flat JSON and a dedicated card avoid confusing referral URLs (`?ref=`) with affiliate URLs (`?affiliate=`). -See [docs/referrals.md](./docs/referrals.md) for flow, API, and revenue math; [docs/affiliate-referral-comparison.md](./docs/affiliate-referral-comparison.md) for comparison with the other cloud repo. +See [docs/referrals.md](./docs/referrals.md) for flow, APIs, UI behavior, and WHYs; [docs/affiliate-referral-comparison.md](./docs/affiliate-referral-comparison.md) for a side-by-side with affiliates. #### Signup codes diff --git a/app/api/v1/referrals/route.ts b/app/api/v1/referrals/route.ts new file mode 100644 index 000000000..53a537a97 --- /dev/null +++ b/app/api/v1/referrals/route.ts @@ -0,0 +1,90 @@ +/** + * Authenticated "my referral code" endpoint for dashboard clients. + * + * WHY GET (not POST) for creation: One idempotent read that ensures `referral_codes` has a row—no + * separate "create" step in the UI. Duplicate calls from header + Affiliates page are safe. + * + * WHY flat JSON (`ReferralMeResponse`): Nested `{ code: { code } }` shapes confuse parsers and + * low-context clients; the share URL is always built client-side with `encodeURIComponent`. + * + * WHY `force-dynamic`: This handler may insert on first hit; caching would be wrong. + * + * WHY `ForbiddenError` before `isAuthError`: Auth errors return 401; missing org / feature gate + * returns 403 with a clear message instead of masking as 500. + */ +import { type NextRequest, NextResponse } from "next/server"; +import { ForbiddenError } from "@/lib/api/errors"; +import { requireAuthOrApiKeyWithOrg } from "@/lib/auth"; +import { RateLimitPresets, withRateLimit } from "@/lib/middleware/rate-limit"; +import { referralsService } from "@/lib/services/referrals"; +import type { ReferralMeResponse } from "@/lib/types/referral-me"; +import { getCorsHeaders } from "@/lib/utils/cors"; +import { logger } from "@/lib/utils/logger"; + +export const dynamic = "force-dynamic"; + +/** WHY broad message match: Session + API key auth throw varied `AuthenticationError` messages; all should map to 401 for this route. */ +function isAuthError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + return ( + error.message.includes("Unauthorized") || + error.message.includes("Authentication required") || + error.message.includes("Invalid or expired token") || + error.message.includes("Invalid or expired API key") || + error.message.includes("Invalid wallet signature") || + error.message.includes("Wallet authentication failed") + ); +} + +export async function OPTIONS(request: NextRequest) { + const origin = request.headers.get("origin"); + return new NextResponse(null, { + status: 204, + headers: getCorsHeaders(origin), + }); +} + +/** + * GET /api/v1/referrals + * Returns the current user's referral code (creates one if missing). + */ +async function handleGET(request: NextRequest) { + const origin = request.headers.get("origin"); + const corsHeaders = getCorsHeaders(origin); + + try { + const { user } = await requireAuthOrApiKeyWithOrg(request); + const row = await referralsService.getOrCreateCode(user.id); + + const totalReferrals = + typeof row.total_referrals === "number" + ? row.total_referrals + : Number(row.total_referrals); + + const body: ReferralMeResponse = { + code: row.code, + total_referrals: Number.isFinite(totalReferrals) ? totalReferrals : 0, + is_active: row.is_active, + }; + + return NextResponse.json(body, { headers: corsHeaders }); + } catch (error) { + if (error instanceof ForbiddenError) { + return NextResponse.json({ error: error.message }, { status: 403, headers: corsHeaders }); + } + + if (isAuthError(error)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401, headers: corsHeaders }); + } + + logger.error("[Referrals API] Error getting referral code", { + error: error instanceof Error ? error.message : String(error), + }); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500, headers: corsHeaders }, + ); + } +} + +export const GET = withRateLimit(handleGET, RateLimitPresets.STANDARD); diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7ee9b2c42..1b16a6e81 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -6,6 +6,12 @@ High-level direction and rationale. Dates are targets, not commitments. ## Done +### Referral invite links — GET `/api/v1/referrals` + dashboard UX (Mar 2026) + +- **What:** Authenticated users can copy a referral invite URL (`/login?ref=…`) from the header **Invite** button and from an **Invite friends** card on `/dashboard/affiliates`; `GET /api/v1/referrals` ensures a `referral_codes` row exists and returns flat JSON; inactive codes block copy in the header and show a clear state on the Affiliates page; `403` returned for `ForbiddenError` (e.g. missing org). +- **Why:** Referral attribution already existed (`apply`, login query params, revenue splits) but users had no first-class way to discover their code or link. Colocating with Affiliates under Monetization keeps one “growth links” area without implying affiliate and referral are the same program. **Why not nested JSON in GET:** Reduces parser mistakes in clients and small models. +- **Follow-ups (later):** Vanity codes, optional `intent=signup` on links, shared client cache (SWR) if duplicate GETs become noisy—see [referrals.md](./referrals.md). + ### Anthropic Messages API compatibility (Jan 2026) - **What:** POST `/api/v1/messages` with Anthropic request/response format, tools, streaming SSE. @@ -31,6 +37,11 @@ High-level direction and rationale. Dates are targets, not commitments. ## Later +### Referral program polish + +- **Vanity referral codes** — User-chosen strings with strict validation and uniqueness. *Why: memorability; requires abuse review and collision handling.* +- **Single client cache for `GET /api/v1/referrals`** — e.g. React context or SWR so header and Affiliates page share one request. *Why: fewer redundant GETs; today idempotent DB writes make duplicates harmless.* + ### Multi-provider parity - **Google Gemini REST compatibility** — If demand exists, a Gemini-style route (e.g. `generateContent`) could reuse the same credits and gateway. *Why: same “one key, one bill” story for Gemini-native tools.* diff --git a/docs/affiliate-referral-comparison.md b/docs/affiliate-referral-comparison.md new file mode 100644 index 000000000..4d8c6a55f --- /dev/null +++ b/docs/affiliate-referral-comparison.md @@ -0,0 +1,14 @@ +# Affiliate vs referral — quick comparison + +Use this table when documenting, debugging payouts, or designing UI copy. Full detail and APIs: [referrals.md](./referrals.md). + +| | **Referral** | **Affiliate** | +|---|-------------|---------------| +| **Primary goal** | Attribute signups and share **purchase revenue** (50/40/10) plus signup/qualified **bonuses** | Charge a **markup** on specific usage; affiliate earns the markup | +| **Typical share URL** | `/login?ref=CODE` (also `referral_code=`) | `/login?affiliate=CODE` | +| **Apply / link API** | `POST /api/v1/referrals/apply` | `POST /api/v1/affiliates/link` (and header `X-Affiliate-Code` on API calls) | +| **“My code / link” API** | `GET /api/v1/referrals` (flat JSON: `code`, `total_referrals`, `is_active`) | `GET` / `POST` / `PUT` `/api/v1/affiliates` | +| **Revenue source** | Stripe checkout + x402 purchases (splits) | Auto top-up + MCP (markup) | +| **Double-dip** | **No** — same transaction does not run referral splits and affiliate markup | Same rule | + +**Why two programs:** Referral economics are baked into **purchase price distribution** (must sum to 100%). Affiliate economics are **optional markup** on certain flows so cost is visible to the end customer and we never over-allocate platform revenue. diff --git a/docs/referrals.md b/docs/referrals.md new file mode 100644 index 000000000..7c9d04839 --- /dev/null +++ b/docs/referrals.md @@ -0,0 +1,115 @@ +# Referrals and invite links + +This document describes the **referral program** (signup attribution, bonuses, 50/40/10 purchase splits), how it differs from **affiliates**, and how users obtain and share invite links. It includes API details and design **WHYs**. + +--- + +## Concepts + +### Referral program + +- **What:** A referred user signs up with someone’s **referral code**. We store a `referral_signups` row linking referrer and referred user. On signup, both sides can receive **bonus credits** (minted as marketing spend, not taken from purchase revenue). When the referred user buys credits (Stripe checkout or x402), **100%** of that purchase is split **50% Eliza Cloud / 40% app owner / 10% creator** (with optional multi-tier rules for creator vs editor). See `REFERRAL_REVENUE_SPLITS` in [`packages/lib/services/referrals.ts`](../packages/lib/services/referrals.ts). + +- **Why separate from affiliates:** Referrals attach to **purchase revenue** and one-time/qualified **bonuses**. Affiliates attach a **markup** on specific flows (auto top-up, MCP). Same transaction must not apply both (see README). **Why:** Predictable economics—referral splits always sum to 100% of the purchase; affiliate cost is passed through to the customer. + +### Affiliate program + +- **What:** Users share an **affiliate** link (`?affiliate=CODE`) or API clients send `X-Affiliate-Code`. Linked users pay a **markup** on marked-up flows; the affiliate earns from that markup. + +- **Why not call affiliates “referrals” in the UI:** The word “referral” in this codebase means the **50/40/10 + signup bonus** program. Affiliate signups are a **different** ledger and product surface (`/dashboard/affiliates`). **Why:** Prevents users and integrators from confusing two URLs (`?ref=` vs `?affiliate=`) and two payout models. + +--- + +## How users get and share an invite link + +### Automatic code creation + +- Each full account can have at most one row in `referral_codes`. The string `code` is generated by `referralsService.getOrCreateCode` (prefix from user id + random suffix) unless you add custom tooling. + +- **`GET /api/v1/referrals`** calls `getOrCreateCode` on first success. **Why GET creates a row:** One round trip for the dashboard—no separate “create my code” step. The operation is **idempotent**; duplicate calls are safe. + +### Where the link appears + +1. **Dashboard header — “Invite”** + - Visible only when the user is **not** anonymous and **not** in `authGraceActive` (session still settling). **Why:** Avoids 401s and confusing errors during Privy grace; keeps anonymous users on sign-up CTAs only. + +2. **`/dashboard/affiliates` — “Invite friends” card** + - Placed **above** the affiliate link card, with a distinct visual treatment. **Why:** Both programs live under Monetization, but mixing orange “affiliate” styling with “invite friends” caused low-signal UIs to merge them mentally. + +### Share URL shape + +- **Referral invite:** `{origin}/login?ref={encodeURIComponent(code)}` + Login also accepts `referral_code` as a query param for the same purpose. + +- **Why `encodeURIComponent`:** Codes are alphanumeric + hyphen; encoding stays correct if the format ever changes and matches browser URL rules. + +### Inactive codes + +- If `referral_codes.is_active` is `false`, `POST /api/v1/referrals/apply` rejects the code. The **GET** endpoint still returns the row so the owner can see status. + +- **UI:** Header **Invite** does not copy an inactive link (toast explains). The Affiliates page shows an **inactive** state with read-only URL and no Copy. **Why:** Stops sharing links that will fail at apply time; still lets support/debug see which code is paused. + +--- + +## API reference + +### `GET /api/v1/referrals` + +- **Auth:** Session cookie or API key via `requireAuthOrApiKeyWithOrg` (same family as `GET /api/v1/affiliates`). + +- **Success (200)** — flat JSON (do **not** nest under `{ code: { ... } }`; **Why:** Fewer client parser bugs and matches TypeScript `ReferralMeResponse`): + +```json +{ + "code": "ABCD-A1B2C3", + "total_referrals": 0, + "is_active": true +} +``` + +- **Errors** + - **401** — Not authenticated (messages matched include `Authentication required`, invalid API key/token, wallet signature failures—aligned with other v1 routes). + - **403** — Authenticated but forbidden (e.g. missing org / `ForbiddenError`). **Why:** Distinguishes “who are you?” from “you can’t use this feature yet.” + - **500** — Unexpected server error (logged). + +- **CORS / rate limit:** `OPTIONS` + `getCorsHeaders`; `withRateLimit(..., STANDARD)`. **Why:** Same cross-origin and abuse posture as other authenticated GET v1 routes. + +### `POST /api/v1/referrals/apply` + +- **Auth:** Required (session or API key with org). + +- **Body:** `{ "code": "..." }` (trimmed and uppercased server-side). + +- **WHYs:** One referral signup per referred user; idempotent replay of the **same** code returns success; self-referral and inactive codes are rejected. See implementation in `referralsService.applyReferralCode`. + +### Applying a code at login + +- Query params `ref` or `referral_code` on `/login` are stored and, after authentication, posted to `/api/v1/referrals/apply`. **Why:** Lets marketing links be simple; attribution survives OAuth redirects via `sessionStorage`. + +--- + +## Related code (for maintainers) + +| Area | Path | +|------|------| +| Referral service (splits, apply, getOrCreateCode) | `packages/lib/services/referrals.ts` | +| Schemas | `packages/db/schemas/referrals.ts` | +| GET me + OPTIONS | `app/api/v1/referrals/route.ts` | +| Apply | `app/api/v1/referrals/apply/route.ts` | +| Response typing / parse helper | `packages/lib/types/referral-me.ts` | +| Header invite | `packages/ui/src/components/layout/header-invite-button.tsx` | +| Affiliates + invite card | `packages/ui/src/components/affiliates/affiliates-page-client.tsx` | + +--- + +## Signup codes (distinct) + +Campaign codes (`SIGNUP_CODES_JSON`, `POST /api/signup-code/redeem`) are **not** referral codes. **Why:** Signup codes are one-off org bonuses; referrals drive ongoing split + referral bonuses. See [signup-codes.md](./signup-codes.md). + +--- + +## Roadmap / out of scope (see [ROADMAP.md](./ROADMAP.md)) + +- Vanity / custom referral strings (needs validation, uniqueness, and possibly admin). +- Optional `intent=signup` on invite URLs for analytics or UX. +- Centralized client cache (SWR/React context) to dedupe GET across header + page—currently two GETs are acceptable because `getOrCreateCode` is idempotent. diff --git a/packages/content/changelog.mdx b/packages/content/changelog.mdx index 3000f792e..6d2955421 100644 --- a/packages/content/changelog.mdx +++ b/packages/content/changelog.mdx @@ -13,6 +13,15 @@ Stay up to date with the latest changes to elizaOS Cloud. ## March 2026 +### Mar 30, 2026 + +**Referral invite links — API, dashboard UI, docs** + +- **`GET /api/v1/referrals`** — Returns the signed-in user’s referral row as flat JSON (`code`, `total_referrals`, `is_active`); creates a code via `getOrCreateCode` on first success. **Why:** One round trip for “my link,” no separate create endpoint; idempotent and safe for duplicate calls from header + Affiliates page. +- **HTTP semantics** — `401` for auth failures (including `Authentication required` and API key errors), **`403` for `ForbiddenError`** (e.g. no org), `500` for unexpected errors. **Why:** Callers can tell “not logged in” from “logged in but not allowed” instead of masking 403 as 500. +- **Dashboard** — Header **Invite** (lazy fetch, clipboard, toast; hidden for anonymous users and during `authGraceActive`). Affiliates page **Invite friends** card above the affiliate link (distinct styling); inactive codes show a warning and block copy in header. **Why:** Discoverability without a new nav item; avoids conflating `?ref=` with `?affiliate=`; grace-period gating avoids broken clicks mid-session sync. +- **Docs** — [docs/referrals.md](../../docs/referrals.md) (flows, APIs, WHYs), [docs/affiliate-referral-comparison.md](../../docs/affiliate-referral-comparison.md); README subsection updated; [ROADMAP.md](../../docs/ROADMAP.md) entry. Shared type `ReferralMeResponse` + `parseReferralMeResponse` in `packages/lib/types/referral-me.ts`. **Why:** README previously linked to missing referral docs; flat JSON and parser reduce client bugs. + ### Mar 8, 2026 **Anthropic Messages API compatibility** diff --git a/packages/lib/services/referrals.ts b/packages/lib/services/referrals.ts index a8d0c7d50..6a3da41c6 100644 --- a/packages/lib/services/referrals.ts +++ b/packages/lib/services/referrals.ts @@ -115,6 +115,11 @@ function generateReferralCode(userId: string): string { * Service for managing referral programs and social sharing rewards. */ export class ReferralsService { + /** + * Ensures the user has exactly one `referral_codes` row (generated string). + * Exposed to HTTP clients via GET `/api/v1/referrals`. WHY idempotent create here: Dashboard can + * call repeatedly from header + Affiliates without a separate "create code" mutation. + */ async getOrCreateCode(userId: string): Promise { const existing = await referralCodesRepository.findByUserId(userId); if (existing) return existing; diff --git a/packages/lib/types/referral-me.ts b/packages/lib/types/referral-me.ts new file mode 100644 index 000000000..c15070b05 --- /dev/null +++ b/packages/lib/types/referral-me.ts @@ -0,0 +1,28 @@ +/** + * JSON body for GET /api/v1/referrals. + * + * WHY a dedicated type + parser: Fetch JSON is untrusted at the type level; `parseReferralMeResponse` + * fails closed on wrong shapes instead of using `any`. WHY flat top-level fields: matches the API + * contract documented in docs/referrals.md—do not nest the string `code` under an object key also + * named `code`. + */ +export interface ReferralMeResponse { + code: string; + total_referrals: number; + is_active: boolean; +} + +export function parseReferralMeResponse(data: unknown): ReferralMeResponse | null { + if (typeof data !== "object" || data === null) return null; + const o = data as Record; + if (typeof o.code !== "string" || o.code.length === 0) return null; + const tr = o.total_referrals; + const totalReferrals = typeof tr === "number" ? tr : Number(tr); + if (!Number.isFinite(totalReferrals)) return null; + if (typeof o.is_active !== "boolean") return null; + return { + code: o.code, + total_referrals: totalReferrals, + is_active: o.is_active, + }; +} diff --git a/packages/tests/e2e/v1/affiliates.test.ts b/packages/tests/e2e/v1/affiliates.test.ts index 936f97167..d6b9de987 100644 --- a/packages/tests/e2e/v1/affiliates.test.ts +++ b/packages/tests/e2e/v1/affiliates.test.ts @@ -81,6 +81,11 @@ describe("Referrals API", () => { expect([401, 403]).toContain(response.status); }); + test("GET /api/v1/referrals requires auth", async () => { + const response = await api.get("/api/v1/referrals"); + expect([401, 403]).toContain(response.status); + }); + test.skipIf(!api.hasApiKey())("POST /api/v1/referrals/apply with invalid code", async () => { const response = await api.post( "/api/v1/referrals/apply", @@ -89,6 +94,25 @@ describe("Referrals API", () => { ); expect([200, 400, 404]).toContain(response.status); }); + + test.skipIf(!api.hasApiKey())("GET /api/v1/referrals returns flat code payload with auth", async () => { + const first = await api.get("/api/v1/referrals", { authenticated: true }); + expect(first.status).toBe(200); + const body = (await first.json()) as { + code: string; + total_referrals: number; + is_active: boolean; + }; + expect(typeof body.code).toBe("string"); + expect(body.code.length).toBeGreaterThan(0); + expect(typeof body.total_referrals).toBe("number"); + expect(typeof body.is_active).toBe("boolean"); + + const second = await api.get("/api/v1/referrals", { authenticated: true }); + expect(second.status).toBe(200); + const body2 = (await second.json()) as { code: string }; + expect(body2.code).toBe(body.code); + }); }); describe("Analytics API", () => { diff --git a/packages/ui/src/components/affiliates/affiliates-page-client.tsx b/packages/ui/src/components/affiliates/affiliates-page-client.tsx index 5cfecf885..051c067d5 100644 --- a/packages/ui/src/components/affiliates/affiliates-page-client.tsx +++ b/packages/ui/src/components/affiliates/affiliates-page-client.tsx @@ -1,10 +1,18 @@ "use client"; import { BrandCard, Button, Input, Skeleton } from "@elizaos/cloud-ui"; -import { AlertTriangle, CheckCircle2, Copy, Link as LinkIcon, UserCog } from "lucide-react"; +import { + AlertTriangle, + CheckCircle2, + Copy, + Link as LinkIcon, + UserCog, + Users, +} from "lucide-react"; import Link from "next/link"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; +import { parseReferralMeResponse, type ReferralMeResponse } from "@/lib/types/referral-me"; import { getAppUrl } from "@/lib/utils/app-url"; interface AffiliateData { @@ -21,6 +29,10 @@ export function AffiliatesPageClient() { const [markupPercent, setMarkupPercent] = useState("20.00"); const [isSaving, setIsSaving] = useState(false); const [copied, setCopied] = useState(false); + const [referralMe, setReferralMe] = useState(null); + const [loadingReferral, setLoadingReferral] = useState(true); + const [referralFetchFailed, setReferralFetchFailed] = useState(false); + const [referralCopied, setReferralCopied] = useState(false); const createAffiliateCode = useCallback(async (initialMarkup = 20) => { const res = await fetch("/api/v1/affiliates", { @@ -62,6 +74,36 @@ export function AffiliatesPageClient() { fetchAffiliateData(); }, [fetchAffiliateData]); + useEffect(() => { + let cancelled = false; + const loadReferral = async () => { + setLoadingReferral(true); + setReferralFetchFailed(false); + const res = await fetch("/api/v1/referrals", { method: "GET", credentials: "include" }); + if (cancelled) return; + if (!res.ok) { + setReferralFetchFailed(true); + setReferralMe(null); + setLoadingReferral(false); + return; + } + const json: unknown = await res.json(); + const parsed = parseReferralMeResponse(json); + if (cancelled) return; + if (!parsed) { + setReferralFetchFailed(true); + setReferralMe(null); + } else { + setReferralMe(parsed); + } + setLoadingReferral(false); + }; + void loadReferral(); + return () => { + cancelled = true; + }; + }, []); + const handleCopyLink = () => { if (!affiliateData) return; const url = `${window.location.origin}/login?affiliate=${affiliateData.code}`; @@ -106,6 +148,7 @@ export function AffiliatesPageClient() { return (
+
); @@ -135,12 +178,100 @@ export function AffiliatesPageClient() { + {/* Referral invite: uses GET /api/v1/referrals (parallel to affiliate fetch, own loading state). + WHY separate from affiliate card: Different URL (?ref= vs ?affiliate=), economics, and copy. + WHY cyan accent: Visually distinct from orange affiliate branding so users don’t merge the two mentally. */} + +
+ +
+

Invite friends

+

+ Share your invite link—you both earn bonus credits when they sign up, and you earn a + share of their purchases on Eliza Cloud. +

+
+
+ + {loadingReferral ? ( + + ) : referralFetchFailed || !referralMe ? ( +

+ Could not load your invite link. Use the Invite button in the header or try again later. +

+ ) : !referralMe.is_active ? ( +
+

Invite link inactive

+

+ New signups cannot use your code until it is turned back on. Contact support if this + looks wrong. +

+

+ {typeof window !== "undefined" + ? `${window.location.origin}/login?ref=${encodeURIComponent(referralMe.code)}` + : `${getAppUrl()}/login?ref=${encodeURIComponent(referralMe.code)}`} +

+
+ ) : ( + <> +

+ {referralMe.total_referrals === 0 + ? "No friends have joined yet—share your link to get started." + : referralMe.total_referrals === 1 + ? "1 friend has joined with your link." + : `${referralMe.total_referrals} friends have joined with your link.`} +

+
+ +
+ {typeof window !== "undefined" + ? `${window.location.origin}/login?ref=${encodeURIComponent(referralMe.code)}` + : `${getAppUrl()}/login?ref=${encodeURIComponent(referralMe.code)}`} +
+ +
+ + )} +
+ {/* Affiliate Link */}

Your Affiliate Link

- Copy this link and share it anywhere. Users who create an account using this link will be - automatically tracked as your referrals. + Copy this link and share it anywhere. Users who sign up with it are tracked as your + affiliate signups for marked-up top-ups and MCP usage—not the same as friend invites + above.

diff --git a/packages/ui/src/components/affiliates/affiliates-page-wrapper.tsx b/packages/ui/src/components/affiliates/affiliates-page-wrapper.tsx index 061b8600b..3819a38c9 100644 --- a/packages/ui/src/components/affiliates/affiliates-page-wrapper.tsx +++ b/packages/ui/src/components/affiliates/affiliates-page-wrapper.tsx @@ -5,8 +5,8 @@ import { AffiliatesPageClient } from "./affiliates-page-client"; export function AffiliatesPageWrapper() { useSetPageHeader({ - title: "Affiliates", - description: "Manage your affiliate link and customize your markup percentage", + title: "Affiliates & Referrals", + description: "Share your invite link and manage your affiliate markup", }); return ; diff --git a/packages/ui/src/components/layout/header-invite-button.tsx b/packages/ui/src/components/layout/header-invite-button.tsx new file mode 100644 index 000000000..63eabecca --- /dev/null +++ b/packages/ui/src/components/layout/header-invite-button.tsx @@ -0,0 +1,113 @@ +/** + * One-click copy of `{origin}/login?ref=…` for signed-in dashboard users. + * + * WHY lazy fetch on click (not on mount): Avoids an extra API call on every dashboard paint; most + * sessions never use Invite. + * + * WHY cache + in-flight dedupe: Double-clicks or strict-mode remounts must not spam + * `getOrCreateCode`; the promise ref collapses concurrent requests into one. + * + * WHY block copy when `!is_active`: Apply rejects inactive codes; sharing would waste invitees’ time. + */ +"use client"; + +import { BrandButton } from "@elizaos/cloud-ui"; +import { Loader2, UserPlus } from "lucide-react"; +import { useCallback, useRef, useState } from "react"; +import { toast } from "sonner"; +import { parseReferralMeResponse, type ReferralMeResponse } from "@/lib/types/referral-me"; + +const REFERRALS_ME_PATH = "/api/v1/referrals"; + +export function HeaderInviteButton() { + const [loading, setLoading] = useState(false); + const cachedRef = useRef(null); + const inFlightRef = useRef | null>(null); + + const resolveMe = useCallback(async (): Promise => { + if (cachedRef.current) { + return cachedRef.current; + } + if (inFlightRef.current) { + return inFlightRef.current; + } + + const promise = (async (): Promise => { + const res = await fetch(REFERRALS_ME_PATH, { + method: "GET", + credentials: "include", + }); + if (!res.ok) { + const errBody = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(errBody.error || `Request failed (${res.status})`); + } + const json: unknown = await res.json(); + const parsed = parseReferralMeResponse(json); + if (!parsed) { + throw new Error("Invalid response from server"); + } + cachedRef.current = parsed; + return parsed; + })(); + + inFlightRef.current = promise; + const result = await promise.finally(() => { + inFlightRef.current = null; + }); + return result; + }, []); + + const onClick = useCallback(async () => { + if (typeof window === "undefined") return; + const origin = window.location.origin; + if (!origin) { + toast.error("Could not build invite link"); + return; + } + + setLoading(true); + const me = await resolveMe().catch((e: unknown) => { + const msg = e instanceof Error ? e.message : "Could not load invite link"; + toast.error(msg); + return null; + }); + setLoading(false); + + if (!me) return; + + if (!me.is_active) { + toast.error("Your invite link is inactive and cannot be shared."); + return; + } + + const url = `${origin}/login?ref=${encodeURIComponent(me.code)}`; + await navigator.clipboard.writeText(url).then( + () => { + toast.success("Invite link copied!"); + }, + () => { + toast.error("Could not copy to clipboard"); + }, + ); + }, [resolveMe]); + + return ( + void onClick()} + disabled={loading} + aria-label="Copy invite link" + title="Copy invite link" + > + {loading ? ( + + ) : ( + + )} + Invite + + ); +} diff --git a/packages/ui/src/components/layout/header.tsx b/packages/ui/src/components/layout/header.tsx index 7e134d616..5667617e3 100644 --- a/packages/ui/src/components/layout/header.tsx +++ b/packages/ui/src/components/layout/header.tsx @@ -14,6 +14,7 @@ import { BrandButton, usePageHeader } from "@elizaos/cloud-ui"; import { LogIn, Menu } from "lucide-react"; import { usePathname } from "next/navigation"; import { memo, useState } from "react"; +import { HeaderInviteButton } from "./header-invite-button"; import UserMenu from "./user-menu"; interface HeaderProps { @@ -79,6 +80,8 @@ function HeaderComponent({ ) : (
+ {/* WHY hide Invite during authGraceActive: Session may not be ready; fetch would 401 and confuse users next to UserMenu preserveWhileUnauthed. */} + {!authGraceActive ? : null}
)} From ad8db6857eac80bce9f784eeb7ba563443e03c57 Mon Sep 17 00:00:00 2001 From: Odilitime Date: Mon, 30 Mar 2026 05:14:13 +0000 Subject: [PATCH 02/22] Referrals UX and API hardening; fix Web UI URL tests - Add GET /api/v1/referrals with auth, CORS, rate limit, and strict total_referrals validation (fail 500 on corrupt DB values). Map auth failures with AuthenticationError and existing 401 handling. - Tighten parseReferralMeResponse for total_referrals; add shared buildReferralInviteLoginUrl helper and wire Affiliates card + header Invite. - Revalidate invite on each header click (no stale cache); pairing-token unit tests use real milady-web-ui + env instead of global mock. - Coalesce empty ELIZA_CLOUD_AGENT_BASE_DOMAIN in getMiladyAgentPublicWebUiUrl; simplify compat-envelope Web UI URL call. Update docs and affiliates e2e. Made-with: Cursor --- app/api/v1/referrals/route.ts | 73 +++++++++++++----- docs/referrals.md | 10 ++- packages/lib/api/compat-envelope.ts | 4 +- packages/lib/milady-web-ui.ts | 10 +-- packages/lib/services/referrals.ts | 3 + packages/lib/types/referral-me.ts | 13 +++- packages/lib/utils/referral-invite-url.ts | 11 +++ packages/tests/e2e/v1/affiliates.test.ts | 39 +++++----- .../unit/milaidy-pairing-token-route.test.ts | 25 ++++--- .../affiliates/affiliates-page-client.tsx | 74 ++++++++++--------- .../layout/header-invite-button.tsx | 20 +++-- 11 files changed, 174 insertions(+), 108 deletions(-) create mode 100644 packages/lib/utils/referral-invite-url.ts diff --git a/app/api/v1/referrals/route.ts b/app/api/v1/referrals/route.ts index 53a537a97..1de207186 100644 --- a/app/api/v1/referrals/route.ts +++ b/app/api/v1/referrals/route.ts @@ -9,11 +9,15 @@ * * WHY `force-dynamic`: This handler may insert on first hit; caching would be wrong. * - * WHY `ForbiddenError` before `isAuthError`: Auth errors return 401; missing org / feature gate - * returns 403 with a clear message instead of masking as 500. + * WHY `ForbiddenError` before auth mapping: Missing org / feature gate returns 403 instead of 500. + * + * REST note — GET performs `getOrCreateCode` (may INSERT): This intentionally trades strict + * HTTP safety for one round-trip UX. Callers must be authenticated and rate-limited; we do not + * rely on CDN cache. Automated clients with a valid session could create a row; risk is bounded + * by auth + `referral_codes` unique(user_id). A stricter design would be POST to create + GET read-only. */ import { type NextRequest, NextResponse } from "next/server"; -import { ForbiddenError } from "@/lib/api/errors"; +import { AuthenticationError, ForbiddenError, getErrorStatusCode } from "@/lib/api/errors"; import { requireAuthOrApiKeyWithOrg } from "@/lib/auth"; import { RateLimitPresets, withRateLimit } from "@/lib/middleware/rate-limit"; import { referralsService } from "@/lib/services/referrals"; @@ -23,17 +27,27 @@ import { logger } from "@/lib/utils/logger"; export const dynamic = "force-dynamic"; -/** WHY broad message match: Session + API key auth throw varied `AuthenticationError` messages; all should map to 401 for this route. */ -function isAuthError(error: unknown): boolean { - if (!(error instanceof Error)) return false; - return ( - error.message.includes("Unauthorized") || - error.message.includes("Authentication required") || - error.message.includes("Invalid or expired token") || - error.message.includes("Invalid or expired API key") || - error.message.includes("Invalid wallet signature") || - error.message.includes("Wallet authentication failed") - ); +/** True when `requireAuthOrApiKeyWithOrg` (and other auth paths) threw {@link AuthenticationError}. */ +function isAuthError(error: Error): boolean { + return error instanceof AuthenticationError; +} + +/** + * Maps thrown errors to “treat as 401” for this route. + * Uses {@link AuthenticationError} for auth failures from `requireAuthOrApiKeyWithOrg`, then + * `getErrorStatusCode` for other `ApiError` shapes, then wallet fail-closed messages until those + * paths throw typed errors. + */ +function isUnauthorizedError(error: unknown): boolean { + if (error instanceof Error && isAuthError(error)) return true; + if (getErrorStatusCode(error) === 401) return true; + if (error instanceof Error) { + return ( + error.message.includes("Invalid wallet signature") || + error.message.includes("Wallet authentication failed") + ); + } + return false; } export async function OPTIONS(request: NextRequest) { @@ -56,14 +70,33 @@ async function handleGET(request: NextRequest) { const { user } = await requireAuthOrApiKeyWithOrg(request); const row = await referralsService.getOrCreateCode(user.id); - const totalReferrals = - typeof row.total_referrals === "number" - ? row.total_referrals - : Number(row.total_referrals); + const rawTotal: unknown = row.total_referrals; + let totalReferrals: number; + if (typeof rawTotal === "number") { + totalReferrals = rawTotal; + } else if (typeof rawTotal === "string" && /^\d+$/.test(rawTotal.trim())) { + totalReferrals = parseInt(rawTotal, 10); + } else if (typeof rawTotal === "bigint") { + totalReferrals = Number(rawTotal); + } else { + throw new Error( + `Referrals API: total_referrals must be a number, bigint, or digit-only string (row.total_referrals=${String(rawTotal)})`, + ); + } + + if ( + !Number.isFinite(totalReferrals) || + !Number.isInteger(totalReferrals) || + totalReferrals < 0 + ) { + throw new Error( + `Referrals API: total_referrals must be a non-negative finite integer (row.total_referrals=${String(rawTotal)})`, + ); + } const body: ReferralMeResponse = { code: row.code, - total_referrals: Number.isFinite(totalReferrals) ? totalReferrals : 0, + total_referrals: totalReferrals, is_active: row.is_active, }; @@ -73,7 +106,7 @@ async function handleGET(request: NextRequest) { return NextResponse.json({ error: error.message }, { status: 403, headers: corsHeaders }); } - if (isAuthError(error)) { + if (isUnauthorizedError(error)) { return NextResponse.json({ error: "Unauthorized" }, { status: 401, headers: corsHeaders }); } diff --git a/docs/referrals.md b/docs/referrals.md index 7c9d04839..a9d47c840 100644 --- a/docs/referrals.md +++ b/docs/referrals.md @@ -28,6 +28,8 @@ This document describes the **referral program** (signup attribution, bonuses, 5 - **`GET /api/v1/referrals`** calls `getOrCreateCode` on first success. **Why GET creates a row:** One round trip for the dashboard—no separate “create my code” step. The operation is **idempotent**; duplicate calls are safe. +- **REST / safety:** Strictly speaking, GET should not mutate state; here we trade that for UX. Mitigations: **auth required**, **rate limiting**, **`force-dynamic`** (no CDN cache). A caller with a valid session could create a row; `user_id` is **UNIQUE** so at most one row per user. Concurrent first requests hit **Postgres unique violations**—handled inside `getOrCreateCode` by re-reading `user_id`. A purist alternative is `POST` to create + `GET` read-only. + ### Where the link appears 1. **Dashboard header — “Invite”** @@ -38,8 +40,7 @@ This document describes the **referral program** (signup attribution, bonuses, 5 ### Share URL shape -- **Referral invite:** `{origin}/login?ref={encodeURIComponent(code)}` - Login also accepts `referral_code` as a query param for the same purpose. +- **Referral invite:** Use `buildReferralInviteLoginUrl(origin, code)` from `packages/lib/utils/referral-invite-url.ts` so all surfaces stay aligned. Equivalent: `{origin}/login?ref={encodeURIComponent(code)}`. Login also accepts `referral_code` as a query param for the same purpose. - **Why `encodeURIComponent`:** Codes are alphanumeric + hyphen; encoding stays correct if the format ever changes and matches browser URL rules. @@ -49,6 +50,8 @@ This document describes the **referral program** (signup attribution, bonuses, 5 - **UI:** Header **Invite** does not copy an inactive link (toast explains). The Affiliates page shows an **inactive** state with read-only URL and no Copy. **Why:** Stops sharing links that will fail at apply time; still lets support/debug see which code is paused. +- **Header cache:** The Invite button caches the last successful `GET` response in-memory until reload. If an admin sets `is_active` to false **after** that fetch, the client may still hold `is_active: true` until refresh—**copy** is re-validated against cached `is_active` only (stale “active” could allow one bad copy). **Why accepted:** Rare; full consistency would need polling or invalidation events. + --- ## API reference @@ -68,7 +71,7 @@ This document describes the **referral program** (signup attribution, bonuses, 5 ``` - **Errors** - - **401** — Not authenticated (messages matched include `Authentication required`, invalid API key/token, wallet signature failures—aligned with other v1 routes). + - **401** — Not authenticated. The route uses `getErrorStatusCode` from `@/lib/api/errors` (typed `ApiError` / legacy message heuristics) plus explicit handling for wallet **plain `Error`** strings (`Invalid wallet signature`, `Wallet authentication failed`) that `requireAuthOrApiKey` still throws outside `AuthenticationError`. - **403** — Authenticated but forbidden (e.g. missing org / `ForbiddenError`). **Why:** Distinguishes “who are you?” from “you can’t use this feature yet.” - **500** — Unexpected server error (logged). @@ -97,6 +100,7 @@ This document describes the **referral program** (signup attribution, bonuses, 5 | GET me + OPTIONS | `app/api/v1/referrals/route.ts` | | Apply | `app/api/v1/referrals/apply/route.ts` | | Response typing / parse helper | `packages/lib/types/referral-me.ts` | +| Share URL helper | `packages/lib/utils/referral-invite-url.ts` | | Header invite | `packages/ui/src/components/layout/header-invite-button.tsx` | | Affiliates + invite card | `packages/ui/src/components/affiliates/affiliates-page-client.tsx` | diff --git a/packages/lib/api/compat-envelope.ts b/packages/lib/api/compat-envelope.ts index 259e1a61a..eeea40f77 100644 --- a/packages/lib/api/compat-envelope.ts +++ b/packages/lib/api/compat-envelope.ts @@ -24,9 +24,7 @@ import type { MiladySandbox, MiladySandboxStatus } from "@/db/schemas/milady-san import { getMiladyAgentPublicWebUiUrl } from "@/lib/milady-web-ui"; function getAgentWebUiUrl(sandbox: MiladySandbox): string | null { - return getMiladyAgentPublicWebUiUrl(sandbox, { - baseDomain: process.env.ELIZA_CLOUD_AGENT_BASE_DOMAIN, - }); + return getMiladyAgentPublicWebUiUrl(sandbox); } // --------------------------------------------------------------------------- diff --git a/packages/lib/milady-web-ui.ts b/packages/lib/milady-web-ui.ts index 763645b7b..fb254f62d 100644 --- a/packages/lib/milady-web-ui.ts +++ b/packages/lib/milady-web-ui.ts @@ -56,12 +56,10 @@ export function getMiladyAgentPublicWebUiUrl( sandbox: Pick, options: MiladyWebUiUrlOptions = {}, ): string | null { - const normalizedDomain = normalizeAgentBaseDomain( - options.baseDomain ?? process.env.ELIZA_CLOUD_AGENT_BASE_DOMAIN ?? DEFAULT_AGENT_BASE_DOMAIN, - ); - if (!normalizedDomain) { - return null; - } + const normalizedDomain = + normalizeAgentBaseDomain(options.baseDomain) ?? + normalizeAgentBaseDomain(process.env.ELIZA_CLOUD_AGENT_BASE_DOMAIN) ?? + DEFAULT_AGENT_BASE_DOMAIN; return applyPath(`https://${sandbox.id}.${normalizedDomain}`, options.path); } diff --git a/packages/lib/services/referrals.ts b/packages/lib/services/referrals.ts index 6a3da41c6..f5e957201 100644 --- a/packages/lib/services/referrals.ts +++ b/packages/lib/services/referrals.ts @@ -119,6 +119,9 @@ export class ReferralsService { * Ensures the user has exactly one `referral_codes` row (generated string). * Exposed to HTTP clients via GET `/api/v1/referrals`. WHY idempotent create here: Dashboard can * call repeatedly from header + Affiliates without a separate "create code" mutation. + * + * Concurrency: `user_id` is UNIQUE; two parallel first-time callers may both miss `findByUserId` + * and one INSERT can hit 23505—handled by re-fetching by `user_id` and retrying code generation. */ async getOrCreateCode(userId: string): Promise { const existing = await referralCodesRepository.findByUserId(userId); diff --git a/packages/lib/types/referral-me.ts b/packages/lib/types/referral-me.ts index c15070b05..5b8aa4b33 100644 --- a/packages/lib/types/referral-me.ts +++ b/packages/lib/types/referral-me.ts @@ -17,8 +17,17 @@ export function parseReferralMeResponse(data: unknown): ReferralMeResponse | nul const o = data as Record; if (typeof o.code !== "string" || o.code.length === 0) return null; const tr = o.total_referrals; - const totalReferrals = typeof tr === "number" ? tr : Number(tr); - if (!Number.isFinite(totalReferrals)) return null; + let totalReferrals: number; + if (typeof tr === "number") { + if (!Number.isFinite(tr) || !Number.isInteger(tr) || tr < 0) return null; + totalReferrals = tr; + } else if (typeof tr === "string") { + const s = tr.trim(); + if (!/^\d+$/.test(s)) return null; + totalReferrals = parseInt(s, 10); + } else { + return null; + } if (typeof o.is_active !== "boolean") return null; return { code: o.code, diff --git a/packages/lib/utils/referral-invite-url.ts b/packages/lib/utils/referral-invite-url.ts new file mode 100644 index 000000000..d96f03cee --- /dev/null +++ b/packages/lib/utils/referral-invite-url.ts @@ -0,0 +1,11 @@ +/** + * Builds the login URL used for referral attribution (`ref` query param). + * + * WHY a single helper: Same shape is used in the Affiliates card, inactive state, copy handler, + * and header Invite button—avoids drift if we add `intent=signup` or rename params later. + * Login also honors `referral_code`; we standardize on `ref` for share links (see app/login). + */ +export function buildReferralInviteLoginUrl(origin: string, code: string): string { + const base = origin.replace(/\/$/, ""); + return `${base}/login?ref=${encodeURIComponent(code)}`; +} diff --git a/packages/tests/e2e/v1/affiliates.test.ts b/packages/tests/e2e/v1/affiliates.test.ts index d6b9de987..dba30b945 100644 --- a/packages/tests/e2e/v1/affiliates.test.ts +++ b/packages/tests/e2e/v1/affiliates.test.ts @@ -95,24 +95,27 @@ describe("Referrals API", () => { expect([200, 400, 404]).toContain(response.status); }); - test.skipIf(!api.hasApiKey())("GET /api/v1/referrals returns flat code payload with auth", async () => { - const first = await api.get("/api/v1/referrals", { authenticated: true }); - expect(first.status).toBe(200); - const body = (await first.json()) as { - code: string; - total_referrals: number; - is_active: boolean; - }; - expect(typeof body.code).toBe("string"); - expect(body.code.length).toBeGreaterThan(0); - expect(typeof body.total_referrals).toBe("number"); - expect(typeof body.is_active).toBe("boolean"); - - const second = await api.get("/api/v1/referrals", { authenticated: true }); - expect(second.status).toBe(200); - const body2 = (await second.json()) as { code: string }; - expect(body2.code).toBe(body.code); - }); + test.skipIf(!api.hasApiKey())( + "GET /api/v1/referrals returns flat code payload with auth", + async () => { + const first = await api.get("/api/v1/referrals", { authenticated: true }); + expect(first.status).toBe(200); + const body = (await first.json()) as { + code: string; + total_referrals: number; + is_active: boolean; + }; + expect(typeof body.code).toBe("string"); + expect(body.code.length).toBeGreaterThan(0); + expect(typeof body.total_referrals).toBe("number"); + expect(typeof body.is_active).toBe("boolean"); + + const second = await api.get("/api/v1/referrals", { authenticated: true }); + expect(second.status).toBe(200); + const body2 = (await second.json()) as { code: string }; + expect(body2.code).toBe(body.code); + }, + ); }); describe("Analytics API", () => { diff --git a/packages/tests/unit/milaidy-pairing-token-route.test.ts b/packages/tests/unit/milaidy-pairing-token-route.test.ts index b3635f061..38bf5f807 100644 --- a/packages/tests/unit/milaidy-pairing-token-route.test.ts +++ b/packages/tests/unit/milaidy-pairing-token-route.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; import { NextRequest } from "next/server"; import { routeParams } from "./api/route-test-helpers"; @@ -6,7 +6,8 @@ import { routeParams } from "./api/route-test-helpers"; const mockRequireAuthOrApiKeyWithOrg = mock(); const mockFindByIdAndOrg = mock(); const mockGenerateToken = mock(); -const mockGetMiladyAgentPublicWebUiUrl = mock(); + +const savedAgentBaseDomain = process.env.ELIZA_CLOUD_AGENT_BASE_DOMAIN; mock.module("@/lib/auth", () => ({ requireAuthOrApiKeyWithOrg: mockRequireAuthOrApiKeyWithOrg, @@ -24,18 +25,15 @@ mock.module("@/lib/services/pairing-token", () => ({ }), })); -mock.module("@/lib/milady-web-ui", () => ({ - getMiladyAgentPublicWebUiUrl: mockGetMiladyAgentPublicWebUiUrl, -})); - import { POST } from "@/app/api/v1/milaidy/agents/[agentId]/pairing-token/route"; describe("POST /api/v1/milaidy/agents/[agentId]/pairing-token", () => { beforeEach(() => { + process.env.ELIZA_CLOUD_AGENT_BASE_DOMAIN = "waifu.fun"; + mockRequireAuthOrApiKeyWithOrg.mockReset(); mockFindByIdAndOrg.mockReset(); mockGenerateToken.mockReset(); - mockGetMiladyAgentPublicWebUiUrl.mockReset(); mockRequireAuthOrApiKeyWithOrg.mockResolvedValue({ user: { @@ -45,6 +43,15 @@ describe("POST /api/v1/milaidy/agents/[agentId]/pairing-token", () => { }); }); + afterAll(() => { + if (savedAgentBaseDomain === undefined) { + delete process.env.ELIZA_CLOUD_AGENT_BASE_DOMAIN; + } else { + process.env.ELIZA_CLOUD_AGENT_BASE_DOMAIN = savedAgentBaseDomain; + } + mock.restore(); + }); + test("returns 404 when the agent is not visible in the caller org", async () => { mockFindByIdAndOrg.mockResolvedValue(null); @@ -66,11 +73,11 @@ describe("POST /api/v1/milaidy/agents/[agentId]/pairing-token", () => { mockFindByIdAndOrg.mockResolvedValue({ id: "agent-1", status: "running", + headscale_ip: null, environment_vars: { MILADY_API_TOKEN: "ui-token", }, }); - mockGetMiladyAgentPublicWebUiUrl.mockReturnValue("https://agent-1.waifu.fun"); mockGenerateToken.mockResolvedValue("pair-token"); const response = await POST( @@ -101,12 +108,12 @@ describe("POST /api/v1/milaidy/agents/[agentId]/pairing-token", () => { mockFindByIdAndOrg.mockResolvedValue({ id: "agent-1", status: "running", + headscale_ip: null, environment_vars: { MILADY_API_TOKEN: "", ELIZA_API_TOKEN: "", }, }); - mockGetMiladyAgentPublicWebUiUrl.mockReturnValue("https://agent-1.waifu.fun"); mockGenerateToken.mockResolvedValue("pair-token"); const response = await POST( diff --git a/packages/ui/src/components/affiliates/affiliates-page-client.tsx b/packages/ui/src/components/affiliates/affiliates-page-client.tsx index 051c067d5..b8ccc11c7 100644 --- a/packages/ui/src/components/affiliates/affiliates-page-client.tsx +++ b/packages/ui/src/components/affiliates/affiliates-page-client.tsx @@ -1,19 +1,13 @@ "use client"; import { BrandCard, Button, Input, Skeleton } from "@elizaos/cloud-ui"; -import { - AlertTriangle, - CheckCircle2, - Copy, - Link as LinkIcon, - UserCog, - Users, -} from "lucide-react"; +import { AlertTriangle, CheckCircle2, Copy, Link as LinkIcon, UserCog, Users } from "lucide-react"; import Link from "next/link"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; import { parseReferralMeResponse, type ReferralMeResponse } from "@/lib/types/referral-me"; import { getAppUrl } from "@/lib/utils/app-url"; +import { buildReferralInviteLoginUrl } from "@/lib/utils/referral-invite-url"; interface AffiliateData { id: string; @@ -79,24 +73,33 @@ export function AffiliatesPageClient() { const loadReferral = async () => { setLoadingReferral(true); setReferralFetchFailed(false); - const res = await fetch("/api/v1/referrals", { method: "GET", credentials: "include" }); - if (cancelled) return; - if (!res.ok) { - setReferralFetchFailed(true); - setReferralMe(null); - setLoadingReferral(false); - return; - } - const json: unknown = await res.json(); - const parsed = parseReferralMeResponse(json); - if (cancelled) return; - if (!parsed) { - setReferralFetchFailed(true); - setReferralMe(null); - } else { - setReferralMe(parsed); + try { + const res = await fetch("/api/v1/referrals", { method: "GET", credentials: "include" }); + if (cancelled) return; + if (!res.ok) { + setReferralFetchFailed(true); + setReferralMe(null); + return; + } + const json = await res.json(); + if (cancelled) return; + const parsed = parseReferralMeResponse(json); + if (!parsed) { + setReferralFetchFailed(true); + setReferralMe(null); + } else { + setReferralMe(parsed); + } + } catch { + if (!cancelled) { + setReferralFetchFailed(true); + setReferralMe(null); + } + } finally { + if (!cancelled) { + setLoadingReferral(false); + } } - setLoadingReferral(false); }; void loadReferral(); return () => { @@ -181,10 +184,7 @@ export function AffiliatesPageClient() { {/* Referral invite: uses GET /api/v1/referrals (parallel to affiliate fetch, own loading state). WHY separate from affiliate card: Different URL (?ref= vs ?affiliate=), economics, and copy. WHY cyan accent: Visually distinct from orange affiliate branding so users don’t merge the two mentally. */} - +
@@ -210,9 +210,10 @@ export function AffiliatesPageClient() { looks wrong.

- {typeof window !== "undefined" - ? `${window.location.origin}/login?ref=${encodeURIComponent(referralMe.code)}` - : `${getAppUrl()}/login?ref=${encodeURIComponent(referralMe.code)}`} + {buildReferralInviteLoginUrl( + typeof window !== "undefined" ? window.location.origin : getAppUrl(), + referralMe.code, + )}

) : ( @@ -227,9 +228,10 @@ export function AffiliatesPageClient() {
- {typeof window !== "undefined" - ? `${window.location.origin}/login?ref=${encodeURIComponent(referralMe.code)}` - : `${getAppUrl()}/login?ref=${encodeURIComponent(referralMe.code)}`} + {buildReferralInviteLoginUrl( + typeof window !== "undefined" ? window.location.origin : getAppUrl(), + referralMe.code, + )}
+
) : !referralMe.is_active ? (

Invite link inactive

From daaa26c8d033a80111a1e1f06efa3b7c80879088 Mon Sep 17 00:00:00 2001 From: Odilitime Date: Thu, 2 Apr 2026 22:11:13 +0000 Subject: [PATCH 20/22] packages: 4. `fetchReferralMe` discards HTTP status code Iteration 1 prr-fix:ic-4180751744-3 --- packages/lib/utils/referral-me-fetch.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/lib/utils/referral-me-fetch.ts b/packages/lib/utils/referral-me-fetch.ts index 6af87340d..546826490 100644 --- a/packages/lib/utils/referral-me-fetch.ts +++ b/packages/lib/utils/referral-me-fetch.ts @@ -2,6 +2,23 @@ import { parseReferralMeResponse, type ReferralMeResponse } from "@/lib/types/re export const REFERRALS_ME_API_PATH = "/api/v1/referrals"; +/** + * Error thrown when the API returns an HTTP error response. + * Exposes `status` so callers can distinguish 401 from 403, etc. + */ +export class ApiResponseError extends Error { + readonly status: number; + readonly serverMessage?: string; + + constructor(status: number, serverMessage?: string) { + const message = serverMessage || `Request failed (${status})`; + super(message); + this.name = "ApiResponseError"; + this.status = status; + this.serverMessage = serverMessage; + } +} + /** * Authenticated GET `/api/v1/referrals` from the browser. Throws on network/HTTP/parse errors. */ @@ -12,7 +29,7 @@ export async function fetchReferralMe(): Promise { }); if (!res.ok) { const errBody = (await res.json().catch(() => ({}))) as { error?: string }; - throw new Error(errBody.error || `Request failed (${res.status})`); + throw new ApiResponseError(res.status, errBody.error); } const json = await res.json(); const parsed = parseReferralMeResponse(json); From 7a7da3834d1f049ed543ba65268b852eab756cf3 Mon Sep 17 00:00:00 2001 From: Odilitime Date: Thu, 2 Apr 2026 22:12:31 +0000 Subject: [PATCH 21/22] packages: improve performance (use-dashboard-referral-me.ts) Iteration 1 prr-fix:ic-4180758975-4 --- .../ui/src/components/hooks/use-dashboard-referral-me.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/ui/src/components/hooks/use-dashboard-referral-me.ts b/packages/ui/src/components/hooks/use-dashboard-referral-me.ts index 82831a5ae..8b8b371ba 100644 --- a/packages/ui/src/components/hooks/use-dashboard-referral-me.ts +++ b/packages/ui/src/components/hooks/use-dashboard-referral-me.ts @@ -12,6 +12,13 @@ export interface UseDashboardReferralMeResult { refetch: () => void; } +/** + * Hook to fetch and manage referral data for the dashboard. + * + * Note: Uses stale-while-revalidate pattern — on refetch(), loadingReferral + * becomes true while referralMe retains its previous value. This allows UI to + * show existing data with a loading indicator rather than flashing to empty state. + */ export function useDashboardReferralMe(): UseDashboardReferralMeResult { const [referralMe, setReferralMe] = useState(null); const [loadingReferral, setLoadingReferral] = useState(true); From 6ad179101158bee9b4bed05f193c13b07851293f Mon Sep 17 00:00:00 2001 From: Odilitime Date: Thu, 2 Apr 2026 22:13:02 +0000 Subject: [PATCH 22/22] packages: refactor for clarity (compat-envelope.ts) Iteration 2 prr-fix:ic-4180758975-0 --- packages/lib/api/compat-envelope.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/lib/api/compat-envelope.ts b/packages/lib/api/compat-envelope.ts index eeea40f77..ad123ebd1 100644 --- a/packages/lib/api/compat-envelope.ts +++ b/packages/lib/api/compat-envelope.ts @@ -23,7 +23,16 @@ import type { MiladySandbox, MiladySandboxStatus } from "@/db/schemas/milady-sandboxes"; import { getMiladyAgentPublicWebUiUrl } from "@/lib/milady-web-ui"; +/** + * Get the public web UI URL for an agent sandbox. + * + * This wrapper intentionally omits the baseDomain option, relying on + * getMiladyAgentPublicWebUiUrl's default behavior (env var / hardcoded fallback). + * This avoids the edge case where passing { baseDomain: "" } would return null + * instead of falling through to the default URL. + */ function getAgentWebUiUrl(sandbox: MiladySandbox): string | null { + // Note: Do not add baseDomain option here; empty string would bypass env/default fallback return getMiladyAgentPublicWebUiUrl(sandbox); }