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..d65a0dde4 --- /dev/null +++ b/app/api/v1/referrals/route.ts @@ -0,0 +1,139 @@ +/** + * 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 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 { 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"; +import { coerceNonNegativeIntegerCount, type ReferralMeResponse } from "@/lib/types/referral-me"; +import { getCorsHeaders } from "@/lib/utils/cors"; +import { logger } from "@/lib/utils/logger"; + +export const dynamic = "force-dynamic"; + +/** + * Known wallet authentication failure message patterns. + * Note: These string patterns are fragile—if wallet auth error messages change upstream, + * this will incorrectly return 500 instead of 401. Track as technical debt. + */ +const WALLET_AUTH_FAILURE_PATTERNS = [ + "Invalid wallet signature", + "Wallet authentication failed", +] as const; + +/** + * Checks if error indicates wallet authentication failure via message patterns. + * Used to convert untyped wallet errors to {@link AuthenticationError}. + */ +function isWalletAuthFailure(error: Error): boolean { + return WALLET_AUTH_FAILURE_PATTERNS.some((pattern) => error.message.includes(pattern)); +} + +/** + * Wraps auth call and converts known wallet auth failures to typed {@link AuthenticationError}. + * This contains the fragile message-matching in one place and ensures downstream code + * only needs to check for typed errors. + */ +async function requireAuthWithTypedErrors(request: NextRequest) { + try { + return await requireAuthOrApiKeyWithOrg(request); + } catch (error) { + // Convert wallet auth failures to typed AuthenticationError + if (error instanceof Error && isWalletAuthFailure(error)) { + throw new AuthenticationError(error.message); + } + throw error; + } +} + +/** + * Maps thrown errors to "treat as 401" for this route. + * Uses {@link AuthenticationError} for auth failures, then `getErrorStatusCode` for other + * `ApiError` shapes with 401 status. + */ +function isUnauthorizedError(error: unknown): boolean { + if (error instanceof AuthenticationError) return true; + if (getErrorStatusCode(error) === 401) return true; + return false; +} + +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 requireAuthWithTypedErrors(request); + const row = await referralsService.getOrCreateCode(user.id); + + if (row == null || typeof row !== "object") { + throw new Error("Referrals API: getOrCreateCode returned no referral row"); + } + if (typeof row.code !== "string" || row.code.length === 0) { + throw new Error("Referrals API: referral row missing code"); + } + if (typeof row.is_active !== "boolean") { + throw new Error("Referrals API: referral row missing is_active"); + } + + const totalReferrals = coerceNonNegativeIntegerCount(row.total_referrals); + if (totalReferrals === null) { + throw new Error( + `Referrals API: total_referrals is not a valid non-negative integer (row.total_referrals=${String(row.total_referrals)})`, + ); + } + + const body: ReferralMeResponse = { + code: row.code, + total_referrals: totalReferrals, + 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 (isUnauthorizedError(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/app/dashboard/milady/page.tsx b/app/dashboard/milady/page.tsx index 5801abcc6..6e886f871 100644 --- a/app/dashboard/milady/page.tsx +++ b/app/dashboard/milady/page.tsx @@ -26,11 +26,16 @@ export default async function MiladyDashboardPage() { // Table likely missing — show empty list } - // Compute canonical Web UI URLs server-side so the client table can link them - const baseDomain = process.env.ELIZA_CLOUD_AGENT_BASE_DOMAIN; + // Compute canonical Web UI URLs server-side so the client table can link them. + // Omit baseDomain when env is empty/whitespace so resolution falls through to default domain. + const rawAgentBaseDomain = process.env.ELIZA_CLOUD_AGENT_BASE_DOMAIN; + const miladyPublicWebUiOptions = + rawAgentBaseDomain !== undefined && rawAgentBaseDomain.trim() !== "" + ? { baseDomain: rawAgentBaseDomain } + : {}; const sandboxesWithUrls = sandboxes.map((sandbox) => ({ ...sandbox, - canonical_web_ui_url: getMiladyAgentPublicWebUiUrl(sandbox, { baseDomain }), + canonical_web_ui_url: getMiladyAgentPublicWebUiUrl(sandbox, miladyPublicWebUiOptions), })); // Count agents by status for pricing banner 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..86270f582 --- /dev/null +++ b/docs/referrals.md @@ -0,0 +1,125 @@ +# 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. + +- **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”** + - 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:** 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. + +### 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. + +- **Invite button freshness:** Each header **Invite** click calls `GET /api/v1/referrals` again (concurrent clicks dedupe on one in-flight request). `is_active` reflects the server at click time, not a stale page-load cache. + +- **Clipboard:** Copy helpers use the Clipboard API when available, then fall back to `document.execCommand('copy')` for plain HTTP or older browsers. **Production dashboard should use HTTPS** (or localhost); some environments still block clipboard access entirely. + +--- + +## 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 +} +``` + + - `code` — The user's unique referral code string. + - `total_referrals` — Count of successful referral signups (rows in `referral_signups` where this user is the referrer). + - `is_active` — Whether the code can be used for new signups. + +- **Errors** + - **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). + +- **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` | +| 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` | + +--- + +## 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/api/compat-envelope.ts b/packages/lib/api/compat-envelope.ts index 259e1a61a..ad123ebd1 100644 --- a/packages/lib/api/compat-envelope.ts +++ b/packages/lib/api/compat-envelope.ts @@ -23,10 +23,17 @@ 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 { - return getMiladyAgentPublicWebUiUrl(sandbox, { - baseDomain: process.env.ELIZA_CLOUD_AGENT_BASE_DOMAIN, - }); + // Note: Do not add baseDomain option here; empty string would bypass env/default fallback + return getMiladyAgentPublicWebUiUrl(sandbox); } // --------------------------------------------------------------------------- diff --git a/packages/lib/constants/copy-feedback.ts b/packages/lib/constants/copy-feedback.ts new file mode 100644 index 000000000..d43c7a77e --- /dev/null +++ b/packages/lib/constants/copy-feedback.ts @@ -0,0 +1,2 @@ +/** Duration (ms) to show “copied” UI before resetting. */ +export const COPY_FEEDBACK_DURATION_MS = 2000; diff --git a/packages/lib/milady-web-ui.ts b/packages/lib/milady-web-ui.ts index 763645b7b..abcc8ea1d 100644 --- a/packages/lib/milady-web-ui.ts +++ b/packages/lib/milady-web-ui.ts @@ -52,17 +52,36 @@ function applyPath(baseUrl: string, path = "/"): string { // Server-only: reads process.env. Do not import in client components. // For client use, pass canonical_web_ui_url from the server and call // getClientSafeMiladyAgentWebUiUrl instead. +/** + * Public HTTPS URL `{sandbox.id}.{domain}`. + * + * **Omit `baseDomain` or set it to `undefined`:** resolve from `ELIZA_CLOUD_AGENT_BASE_DOMAIN`, + * then the built-in default domain (`waifu.fun`). Empty env is treated like unset (same as + * {@link getAgentBaseDomain}). + * + * **Pass any other `baseDomain` (including `null` or `""`):** use only that value after + * normalization. If it does not yield a valid hostname, returns **`null`** — no silent fallback to + * the default domain (callers use `null` to mean “no public URL for this override”). + */ 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 rawOpt = options.baseDomain; + const baseDomainOptionSupplied = Object.hasOwn(options, "baseDomain") && rawOpt !== undefined; + + if (baseDomainOptionSupplied) { + const explicit = normalizeAgentBaseDomain(rawOpt); + if (explicit === null) { + return null; + } + return applyPath(`https://${sandbox.id}.${explicit}`, options.path); } + const normalizedDomain = + 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 a8d0c7d50..f5e957201 100644 --- a/packages/lib/services/referrals.ts +++ b/packages/lib/services/referrals.ts @@ -115,6 +115,14 @@ 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. + * + * 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); 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..cea6d659c --- /dev/null +++ b/packages/lib/types/referral-me.ts @@ -0,0 +1,51 @@ +/** + * 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; +} + +/** + * Shared coercion for non-negative integer counts (DB row values and JSON `total_referrals`). + * Rejects null, booleans, decimals, non-digit strings, and unsafe bigint magnitudes. + */ +export function coerceNonNegativeIntegerCount(val: unknown): number | null { + if (typeof val === "number") { + if (!Number.isFinite(val) || !Number.isInteger(val) || val < 0) return null; + return val; + } + if (typeof val === "string") { + const s = val.trim(); + if (!/^(0|[1-9]\d*)$/.test(s)) return null; + const n = parseInt(s, 10); + if (!Number.isSafeInteger(n)) return null; + return n; + } + if (typeof val === "bigint") { + const n = Number(val); + if (!Number.isSafeInteger(n) || n < 0) return null; + return n; + } + return null; +} + +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 totalReferrals = coerceNonNegativeIntegerCount(o.total_referrals); + if (totalReferrals === null) 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/lib/utils/copy-to-clipboard.ts b/packages/lib/utils/copy-to-clipboard.ts new file mode 100644 index 000000000..6c18f475e --- /dev/null +++ b/packages/lib/utils/copy-to-clipboard.ts @@ -0,0 +1,41 @@ +/** + * Copy text to the clipboard in browsers. Tries the Clipboard API first (requires secure context), + * then `document.execCommand('copy')` for plain HTTP or older browsers. + */ +export async function copyTextToClipboard(text: string): Promise { + if (typeof window === "undefined") { + return false; + } + + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { + /* fall through to execCommand */ + } + + return copyViaExecCommand(text); +} + +function copyViaExecCommand(text: string): boolean { + if (typeof document === "undefined") { + return false; + } + if (!document.body) { + return false; + } + + const node = document.createElement("textarea"); + node.value = text; + node.setAttribute("readonly", ""); + node.style.position = "fixed"; + node.style.left = "-9999px"; + document.body.appendChild(node); + node.select(); + + const ok = document.execCommand("copy"); + document.body.removeChild(node); + return ok; +} 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/lib/utils/referral-me-fetch.ts b/packages/lib/utils/referral-me-fetch.ts new file mode 100644 index 000000000..546826490 --- /dev/null +++ b/packages/lib/utils/referral-me-fetch.ts @@ -0,0 +1,40 @@ +import { parseReferralMeResponse, type ReferralMeResponse } from "@/lib/types/referral-me"; + +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. + */ +export async function fetchReferralMe(): Promise { + const res = await fetch(REFERRALS_ME_API_PATH, { + method: "GET", + credentials: "include", + }); + if (!res.ok) { + const errBody = (await res.json().catch(() => ({}))) as { error?: string }; + throw new ApiResponseError(res.status, errBody.error); + } + const json = await res.json(); + const parsed = parseReferralMeResponse(json); + if (!parsed) { + throw new Error("Invalid response from server"); + } + return parsed; +} diff --git a/packages/tests/e2e/v1/affiliates.test.ts b/packages/tests/e2e/v1/affiliates.test.ts index 936f97167..c7180166a 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 (401 vs 403 depends on auth layer)", 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,48 @@ 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 validates JSON shape (active or inactive)", + async () => { + // Note: In fresh test environments codes are always active; inactive branch + // requires a seeded fixture. We validate shape unconditionally since the + // payload structure is identical for both active and inactive codes. + const res = await api.get("/api/v1/referrals", { authenticated: true }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + code: string; + total_referrals: number; + is_active: boolean; + }; + expect(typeof body.is_active).toBe("boolean"); + expect(body.code.length).toBeGreaterThan(0); + expect(Number.isInteger(body.total_referrals)).toBe(true); + expect(body.total_referrals).toBeGreaterThanOrEqual(0); + }, + ); }); describe("Analytics API", () => { diff --git a/packages/tests/support/bun-partial-module-shims.ts b/packages/tests/support/bun-partial-module-shims.ts new file mode 100644 index 000000000..e4de7e1f7 --- /dev/null +++ b/packages/tests/support/bun-partial-module-shims.ts @@ -0,0 +1,52 @@ +/** + * Bun 1.3.9+ validates `mock.module` factories: runtime named exports must match the source module. + * Partial mocks that omit `UsersRepository` / `CreditsService` / etc. break `export *` re-exports + * (e.g. `@/db/repositories`) and later imports in the same process. + */ + +const COST_BUFFER = Number(process.env.CREDIT_COST_BUFFER) || 1.5; +const MIN_RESERVATION = 0.000001; + +/** Minimal interface for stubbing usersRepository in tests. */ +interface UsersRepositoryStub { + listByOrganization?: (organizationId: string) => Promise<{ id: string; email: string }[]>; +} + +/** Shape returned by stubUsersRepositoryModule matching the real module exports. */ +interface UsersRepositoryModuleStub { + UsersRepository: new () => UsersRepositoryStub; + usersRepository: UsersRepositoryStub; +} + +export function stubUsersRepositoryModule(overrides: { + usersRepository: UsersRepositoryStub; +}): UsersRepositoryModuleStub { + class UsersRepository {} + return { + UsersRepository, + usersRepository: overrides.usersRepository, + }; +} + +export const creditsModuleRuntimeShim = { + COST_BUFFER, + MIN_RESERVATION, + EPSILON: MIN_RESERVATION * 0.1, + DEFAULT_OUTPUT_TOKENS: 500, + InsufficientCreditsError: class InsufficientCreditsError extends Error { + public readonly required: number; + public readonly available: number; + public readonly reason?: string; + + constructor(required: number, available: number, reason?: string) { + super( + `Insufficient credits. Required: $${required.toFixed(4)}, Available: $${available.toFixed(4)}`, + ); + this.name = "InsufficientCreditsError"; + this.required = required; + this.available = available; + this.reason = reason; + } + }, + CreditsService: class CreditsService {}, +}; diff --git a/packages/tests/unit/admin-service-pricing-route.test.ts b/packages/tests/unit/admin-service-pricing-route.test.ts index fca78aee9..8af4cbbae 100644 --- a/packages/tests/unit/admin-service-pricing-route.test.ts +++ b/packages/tests/unit/admin-service-pricing-route.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; import { NextRequest, NextResponse } from "next/server"; const mockRequireAdminWithResponse = mock(async () => ({ @@ -27,7 +27,7 @@ mock.module("@/lib/api/admin-auth", () => ({ requireAdminWithResponse: mockRequireAdminWithResponse, })); -mock.module("@/db/repositories", () => ({ +mock.module("@/db/repositories/service-pricing", () => ({ servicePricingRepository: { listByService: mockListByService, upsert: mockUpsert, @@ -224,4 +224,8 @@ describe("Admin Service Pricing API", () => { const data = await response.json(); expect(data.cache_invalidated).toBe(false); }); + + afterAll(() => { + mock.restore(); + }); }); diff --git a/packages/tests/unit/api/elevenlabs-routes.test.ts b/packages/tests/unit/api/elevenlabs-routes.test.ts index ef156b312..96a0cf613 100644 --- a/packages/tests/unit/api/elevenlabs-routes.test.ts +++ b/packages/tests/unit/api/elevenlabs-routes.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { creditsModuleRuntimeShim } from "@/tests/support/bun-partial-module-shims"; import { createFile, formDataRequest, jsonRequest, routeParams } from "./route-test-helpers"; @@ -66,6 +67,7 @@ mock.module("@/lib/services/usage", () => ({ })); mock.module("@/lib/services/credits", () => ({ + ...creditsModuleRuntimeShim, creditsService: { reserve: mockCreditsReserve, }, diff --git a/packages/tests/unit/api/v1-generation-routes.test.ts b/packages/tests/unit/api/v1-generation-routes.test.ts index c008d7ff2..5f494f2ea 100644 --- a/packages/tests/unit/api/v1-generation-routes.test.ts +++ b/packages/tests/unit/api/v1-generation-routes.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { creditsModuleRuntimeShim } from "@/tests/support/bun-partial-module-shims"; import { jsonRequest } from "./route-test-helpers"; @@ -90,6 +91,7 @@ mock.module("@/lib/services/usage", () => ({ })); mock.module("@/lib/services/credits", () => ({ + ...creditsModuleRuntimeShim, creditsService: { reserve: mockCreditsReserve, createAnonymousReservation: mockCreateAnonymousReservation, diff --git a/packages/tests/unit/api/v1-messages-route.test.ts b/packages/tests/unit/api/v1-messages-route.test.ts index 37f141052..3551edc45 100644 --- a/packages/tests/unit/api/v1-messages-route.test.ts +++ b/packages/tests/unit/api/v1-messages-route.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { NextRequest } from "next/server"; +import { creditsModuleRuntimeShim } from "@/tests/support/bun-partial-module-shims"; import { jsonRequest } from "./route-test-helpers"; @@ -65,6 +66,7 @@ mock.module("@/lib/services/ai-billing", () => ({ })); mock.module("@/lib/services/credits", () => ({ + ...creditsModuleRuntimeShim, creditsService: { createAnonymousReservation: mockCreateAnonymousReservation, }, diff --git a/packages/tests/unit/api/v1-responses-route.test.ts b/packages/tests/unit/api/v1-responses-route.test.ts index ed5dc4677..b709bec33 100644 --- a/packages/tests/unit/api/v1-responses-route.test.ts +++ b/packages/tests/unit/api/v1-responses-route.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { creditsModuleRuntimeShim } from "@/tests/support/bun-partial-module-shims"; import { jsonRequest } from "./route-test-helpers"; @@ -62,6 +63,7 @@ mock.module("@/lib/services/content-moderation", () => ({ })); mock.module("@/lib/services/credits", () => ({ + ...creditsModuleRuntimeShim, creditsService: { reserveAndDeductCredits: mockReserveAndDeductCredits, reconcile: mockReconcileCredits, diff --git a/packages/tests/unit/evm-rpc-proxy-route.test.ts b/packages/tests/unit/evm-rpc-proxy-route.test.ts index 46538a8fc..78f5f317f 100644 --- a/packages/tests/unit/evm-rpc-proxy-route.test.ts +++ b/packages/tests/unit/evm-rpc-proxy-route.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { NextRequest } from "next/server"; +import { creditsModuleRuntimeShim } from "@/tests/support/bun-partial-module-shims"; const mockRequireAuthOrApiKeyWithOrg = mock(); const mockDeductCredits = mock(); @@ -20,6 +21,7 @@ mock.module("@/lib/auth", () => ({ })); mock.module("@/lib/services/credits", () => ({ + ...creditsModuleRuntimeShim, creditsService: { deductCredits: mockDeductCredits, }, diff --git a/packages/tests/unit/mcp-proxy-affiliate-pricing.test.ts b/packages/tests/unit/mcp-proxy-affiliate-pricing.test.ts index d95e619be..a031134c5 100644 --- a/packages/tests/unit/mcp-proxy-affiliate-pricing.test.ts +++ b/packages/tests/unit/mcp-proxy-affiliate-pricing.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { NextRequest } from "next/server"; +import { creditsModuleRuntimeShim } from "@/tests/support/bun-partial-module-shims"; const mockRequireAuthOrApiKeyWithOrg = mock(); const mockGetAffiliateCodeById = mock(); @@ -32,6 +33,7 @@ mock.module("@/lib/services/user-mcps", () => ({ })); mock.module("@/lib/services/credits", () => ({ + ...creditsModuleRuntimeShim, creditsService: { reserveAndDeductCredits: mockReserveAndDeductCredits, refundCredits: mockRefundCredits, diff --git a/packages/tests/unit/mcp-tools.test.ts b/packages/tests/unit/mcp-tools.test.ts index 8c68914ee..26e541e73 100644 --- a/packages/tests/unit/mcp-tools.test.ts +++ b/packages/tests/unit/mcp-tools.test.ts @@ -3,7 +3,7 @@ * Verifies all tools register without import/config errors */ -import { describe, expect, mock, test } from "bun:test"; +import { afterAll, describe, expect, mock, test } from "bun:test"; mock.module("isomorphic-dompurify", () => ({ default: { @@ -22,4 +22,8 @@ describe("MCP Tools Registration", () => { }, { timeout: 30000 }, ); + + afterAll(() => { + mock.restore(); + }); }); diff --git a/packages/tests/unit/milady-billing-route.test.ts b/packages/tests/unit/milady-billing-route.test.ts index 52394440f..10c10f74b 100644 --- a/packages/tests/unit/milady-billing-route.test.ts +++ b/packages/tests/unit/milady-billing-route.test.ts @@ -1,5 +1,6 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { NextRequest } from "next/server"; +import { stubUsersRepositoryModule } from "@/tests/support/bun-partial-module-shims"; const TEST_SECRET = "milady-cron-secret"; @@ -90,11 +91,13 @@ mock.module("@/db/client", () => ({ }, })); -mock.module("@/db/repositories", () => ({ - usersRepository: { - listByOrganization: mockListByOrganization, - }, -})); +mock.module("@/db/repositories", () => + stubUsersRepositoryModule({ + usersRepository: { + listByOrganization: mockListByOrganization, + }, + }), +); mock.module("@/lib/services/email", () => ({ emailService: { @@ -194,6 +197,10 @@ describe("Milady billing cron", () => { } }); + afterAll(() => { + mock.restore(); + }); + test("skips a sandbox cleanly when another run already billed it", async () => { enqueueBaseReadState({ sandbox: { diff --git a/packages/tests/unit/milady-web-ui.test.ts b/packages/tests/unit/milady-web-ui.test.ts index 15fba5c5a..420f45f5f 100644 --- a/packages/tests/unit/milady-web-ui.test.ts +++ b/packages/tests/unit/milady-web-ui.test.ts @@ -56,6 +56,18 @@ describe("getMiladyAgentPublicWebUiUrl", () => { "https://aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.waifu.fun", ); }); + + test("returns null when explicit baseDomain fails normalization (no silent default)", () => { + expect(getMiladyAgentPublicWebUiUrl(makeSandbox(), { baseDomain: "" })).toBeNull(); + expect(getMiladyAgentPublicWebUiUrl(makeSandbox(), { baseDomain: " " })).toBeNull(); + }); + + test("treats baseDomain: undefined like omitted (env then default)", () => { + process.env.ELIZA_CLOUD_AGENT_BASE_DOMAIN = "custom.example"; + expect(getMiladyAgentPublicWebUiUrl(makeSandbox(), { baseDomain: undefined })).toBe( + "https://aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.custom.example", + ); + }); }); describe("getPreferredMiladyAgentWebUiUrl", () => { 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/tests/unit/privy-sync.test.ts b/packages/tests/unit/privy-sync.test.ts index 274120110..f6a0119e8 100644 --- a/packages/tests/unit/privy-sync.test.ts +++ b/packages/tests/unit/privy-sync.test.ts @@ -1,6 +1,10 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; import { readFileSync } from "node:fs"; import { Client } from "pg"; +import { + creditsModuleRuntimeShim, + stubUsersRepositoryModule, +} from "@/tests/support/bun-partial-module-shims"; const mockGetByPrivyId = mock(); const mockGetByPrivyIdForWrite = mock(); @@ -93,20 +97,26 @@ mock.module("@/lib/services/api-keys", () => ({ })); mock.module("@/lib/services/credits", () => ({ + ...creditsModuleRuntimeShim, creditsService: { addCredits: mockAddCredits, }, })); -mock.module("@/db/repositories", () => ({ +mock.module("@/db/repositories/organization-invites", () => ({ organizationInvitesRepository: { markAsAccepted: mockMarkInviteAccepted, }, - usersRepository: { - delete: mockDeleteUserRecord, - }, })); +mock.module("@/db/repositories/users", () => + stubUsersRepositoryModule({ + usersRepository: { + delete: mockDeleteUserRecord, + }, + }), +); + mock.module("@/lib/services/abuse-detection", () => ({ abuseDetectionService: { checkSignupAbuse: mockCheckSignupAbuse, @@ -139,6 +149,10 @@ mock.module("@/lib/utils/default-eliza-character", () => ({ }), })); +afterAll(() => { + mock.restore(); +}); + describe("syncUserFromPrivy", () => { beforeEach(() => { mockGetByPrivyId.mockReset(); diff --git a/packages/tests/unit/proxy-engine.test.ts b/packages/tests/unit/proxy-engine.test.ts index 44430801c..cf51bd4f1 100644 --- a/packages/tests/unit/proxy-engine.test.ts +++ b/packages/tests/unit/proxy-engine.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { NextRequest } from "next/server"; import { AuthenticationError } from "@/lib/api/errors"; +import { creditsModuleRuntimeShim } from "@/tests/support/bun-partial-module-shims"; const mockRequireAuth = mock(); const mockRequireAuthWithOrg = mock(); @@ -36,6 +37,7 @@ class MockInsufficientCreditsError extends Error { } mock.module("@/lib/services/credits", () => ({ + ...creditsModuleRuntimeShim, creditsService: { reserve: mockReserve, }, diff --git a/packages/tests/unit/proxy-pricing.test.ts b/packages/tests/unit/proxy-pricing.test.ts index 2fdf4db17..72fe83a63 100644 --- a/packages/tests/unit/proxy-pricing.test.ts +++ b/packages/tests/unit/proxy-pricing.test.ts @@ -17,7 +17,7 @@ mock.module("@/lib/cache/client", () => ({ }, })); -mock.module("@/db/repositories", () => ({ +mock.module("@/db/repositories/service-pricing", () => ({ servicePricingRepository: { listByService: mockListByService, }, diff --git a/packages/tests/unit/referral-me.test.ts b/packages/tests/unit/referral-me.test.ts new file mode 100644 index 000000000..a54de63cd --- /dev/null +++ b/packages/tests/unit/referral-me.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; +import { coerceNonNegativeIntegerCount, parseReferralMeResponse } from "@/lib/types/referral-me"; + +describe("coerceNonNegativeIntegerCount", () => { + test("accepts non-negative integers and digit-only strings", () => { + expect(coerceNonNegativeIntegerCount(0)).toBe(0); + expect(coerceNonNegativeIntegerCount(42)).toBe(42); + expect(coerceNonNegativeIntegerCount(" 7 ")).toBe(7); + expect(coerceNonNegativeIntegerCount(0n)).toBe(0); + expect(coerceNonNegativeIntegerCount(9007199254740991n)).toBe(Number.MAX_SAFE_INTEGER); + }); + + test("rejects null, boolean, float, empty string, decimal string, unsafe bigint", () => { + expect(coerceNonNegativeIntegerCount(null)).toBeNull(); + expect(coerceNonNegativeIntegerCount(undefined)).toBeNull(); + expect(coerceNonNegativeIntegerCount(true)).toBeNull(); + expect(coerceNonNegativeIntegerCount(1.5)).toBeNull(); + expect(coerceNonNegativeIntegerCount("")).toBeNull(); + expect(coerceNonNegativeIntegerCount("01")).toBeNull(); + expect(coerceNonNegativeIntegerCount("1.5")).toBeNull(); + expect(coerceNonNegativeIntegerCount(BigInt(Number.MAX_SAFE_INTEGER) + 1n)).toBeNull(); + }); +}); + +describe("parseReferralMeResponse", () => { + test("parses valid payload", () => { + expect( + parseReferralMeResponse({ + code: "ABC12", + total_referrals: 3, + is_active: true, + }), + ).toEqual({ + code: "ABC12", + total_referrals: 3, + is_active: true, + }); + }); + + test("accepts inactive codes and string total_referrals", () => { + expect( + parseReferralMeResponse({ + code: "XYZ99", + total_referrals: "0", + is_active: false, + }), + ).toEqual({ + code: "XYZ99", + total_referrals: 0, + is_active: false, + }); + }); + + test("rejects malformed total_referrals", () => { + expect( + parseReferralMeResponse({ + code: "ABC", + total_referrals: Number.NaN, + is_active: true, + }), + ).toBeNull(); + }); +}); diff --git a/packages/tests/unit/referrals-service.test.ts b/packages/tests/unit/referrals-service.test.ts index 7584d82fc..3f122a871 100644 --- a/packages/tests/unit/referrals-service.test.ts +++ b/packages/tests/unit/referrals-service.test.ts @@ -1,4 +1,8 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { + creditsModuleRuntimeShim, + stubUsersRepositoryModule, +} from "@/tests/support/bun-partial-module-shims"; const mockFindByUserId = mock(); const mockFindById = mock(); @@ -28,13 +32,16 @@ mock.module("@/db/repositories/referrals", () => ({ socialShareRewardsRepository: {}, })); -mock.module("@/db/repositories/users", () => ({ - usersRepository: { - findById: mockFindUserById, - }, -})); +mock.module("@/db/repositories/users", () => + stubUsersRepositoryModule({ + usersRepository: { + findById: mockFindUserById, + }, + }), +); mock.module("@/lib/services/credits", () => ({ + ...creditsModuleRuntimeShim, creditsService: { addCredits: mockAddCredits, }, diff --git a/packages/ui/src/components/affiliates/affiliates-page-client.tsx b/packages/ui/src/components/affiliates/affiliates-page-client.tsx index 5cfecf885..a25e7e2e3 100644 --- a/packages/ui/src/components/affiliates/affiliates-page-client.tsx +++ b/packages/ui/src/components/affiliates/affiliates-page-client.tsx @@ -1,11 +1,15 @@ "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 { useDashboardReferralMe } from "@/components/hooks/use-dashboard-referral-me"; +import { COPY_FEEDBACK_DURATION_MS } from "@/lib/constants/copy-feedback"; import { getAppUrl } from "@/lib/utils/app-url"; +import { copyTextToClipboard } from "@/lib/utils/copy-to-clipboard"; +import { buildReferralInviteLoginUrl } from "@/lib/utils/referral-invite-url"; interface AffiliateData { id: string; @@ -21,6 +25,8 @@ export function AffiliatesPageClient() { const [markupPercent, setMarkupPercent] = useState("20.00"); const [isSaving, setIsSaving] = useState(false); const [copied, setCopied] = useState(false); + const [referralCopied, setReferralCopied] = useState(false); + const { referralMe, loadingReferral, referralFetchFailed, refetch: refetchReferral } = useDashboardReferralMe(); const createAffiliateCode = useCallback(async (initialMarkup = 20) => { const res = await fetch("/api/v1/affiliates", { @@ -62,13 +68,17 @@ export function AffiliatesPageClient() { fetchAffiliateData(); }, [fetchAffiliateData]); - const handleCopyLink = () => { + const handleCopyLink = async () => { if (!affiliateData) return; const url = `${window.location.origin}/login?affiliate=${affiliateData.code}`; - navigator.clipboard.writeText(url); - setCopied(true); - toast.success("Link copied to clipboard!"); - setTimeout(() => setCopied(false), 2000); + const ok = await copyTextToClipboard(url); + if (ok) { + setCopied(true); + toast.success("Link copied to clipboard!"); + setTimeout(() => setCopied(false), COPY_FEEDBACK_DURATION_MS); + } else { + toast.error("Could not copy to clipboard"); + } }; const handleSaveMarkup = async () => { @@ -106,11 +116,14 @@ export function AffiliatesPageClient() { return (
+
); } + const pageOrigin = typeof window !== "undefined" ? window.location.origin : getAppUrl(); + return (
{/* Introduction Banner */} @@ -135,12 +148,109 @@ 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. +

+ +
+ ) : !referralMe.is_active ? ( +
+

Invite link inactive

+

+ Your referral code is turned off for new signups. Only an Eliza Cloud administrator + can re-enable it. If you believe this is a mistake,{" "} + + email support@eliza.cloud + + . +

+

+ {buildReferralInviteLoginUrl(pageOrigin, 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.`} +

+
+ +
+ {buildReferralInviteLoginUrl(pageOrigin, 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.

@@ -234,15 +344,21 @@ export function AffiliatesPageClient() { size="sm" className="h-6 w-6 p-0 text-white/40 hover:text-white" onClick={() => { - const codeSnippet = `curl -X POST https://api.elizacloud.ai/v1/chat/completions \\ + void (async () => { + const codeSnippet = `curl -X POST https://api.elizacloud.ai/v1/chat/completions \\ -H "Authorization: Bearer YOUR_API_KEY" \\ -H "X-Affiliate-Code: ${affiliateData?.code || "YOUR_CODE_HERE"}" \\ -d '{ "model": "google/gemini-2.5-flash", "messages": [{"role": "user", "content": "Hello!"}] }'`; - navigator.clipboard.writeText(codeSnippet); - toast.success("Code snippet copied!"); + const ok = await copyTextToClipboard(codeSnippet); + if (ok) { + toast.success("Code snippet copied!"); + } else { + toast.error("Could not copy to clipboard"); + } + })(); }} > 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/hooks/use-dashboard-referral-me.ts b/packages/ui/src/components/hooks/use-dashboard-referral-me.ts new file mode 100644 index 000000000..8b8b371ba --- /dev/null +++ b/packages/ui/src/components/hooks/use-dashboard-referral-me.ts @@ -0,0 +1,62 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import type { ReferralMeResponse } from "@/lib/types/referral-me"; +import { fetchReferralMe } from "@/lib/utils/referral-me-fetch"; + +export interface UseDashboardReferralMeResult { + referralMe: ReferralMeResponse | null; + loadingReferral: boolean; + referralFetchFailed: boolean; + /** Re-fetch referral data (e.g. after a transient network failure). */ + 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); + const [referralFetchFailed, setReferralFetchFailed] = useState(false); + const [fetchTrigger, setFetchTrigger] = useState(0); + + const refetch = useCallback(() => { + setFetchTrigger((n) => n + 1); + }, []); + + useEffect(() => { + let cancelled = false; + + const load = async () => { + setLoadingReferral(true); + setReferralFetchFailed(false); + try { + const parsed = await fetchReferralMe(); + if (!cancelled) { + setReferralMe(parsed); + } + } catch { + if (!cancelled) { + setReferralFetchFailed(true); + setReferralMe(null); + } + } finally { + if (!cancelled) { + setLoadingReferral(false); + } + } + }; + + void load(); + return () => { + cancelled = true; + }; + }, [fetchTrigger]); + + return { referralMe, loadingReferral, referralFetchFailed, refetch }; +} 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..5e6cb2715 --- /dev/null +++ b/packages/ui/src/components/layout/header-invite-button.tsx @@ -0,0 +1,97 @@ +/** + * One-click copy of `{origin}/login?ref=…` for signed-in dashboard users. + * + * WHY fetch on each click (no in-memory cache of `ReferralMeResponse`): Every click calls + * GET `/api/v1/referrals` so `is_active` matches the server; concurrent clicks dedupe via + * `inFlightRef` only. + * + * WHY in-flight dedupe: Concurrent clicks share one promise so we do not spam `getOrCreateCode`. + * + * WHY block copy when `!is_active`: Apply rejects inactive codes; sharing would waste invitees’ time. + * + * Clipboard: `copyTextToClipboard` falls back to `document.execCommand('copy')` when the Clipboard + * API is unavailable (e.g. plain HTTP). Production dashboard should still use HTTPS. + */ +"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 type { ReferralMeResponse } from "@/lib/types/referral-me"; +import { copyTextToClipboard } from "@/lib/utils/copy-to-clipboard"; +import { buildReferralInviteLoginUrl } from "@/lib/utils/referral-invite-url"; +import { fetchReferralMe } from "@/lib/utils/referral-me-fetch"; + +export function HeaderInviteButton() { + const [loading, setLoading] = useState(false); + const inFlightRef = useRef | null>(null); + + const resolveMe = useCallback(async (): Promise => { + if (inFlightRef.current) { + return inFlightRef.current; + } + + const promise = fetchReferralMe(); + + inFlightRef.current = promise; + return promise.finally(() => { + inFlightRef.current = null; + }); + }, []); + + 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); + try { + const me = await resolveMe().catch((e: unknown) => { + const msg = e instanceof Error ? e.message : "Could not load invite link"; + toast.error(msg); + return null; + }); + + if (!me) return; + + if (!me.is_active) { + toast.error("Your invite link is inactive and cannot be shared."); + return; + } + + const url = buildReferralInviteLoginUrl(origin, me.code); + const ok = await copyTextToClipboard(url); + if (ok) { + toast.success("Invite link copied!"); + } else { + toast.error("Could not copy to clipboard"); + } + } finally { + setLoading(false); + } + }, [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}
)}