Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b4be8ca
feat(referrals): invite links via GET /api/v1/referrals and dashboard UX
odilitime Mar 30, 2026
ad8db68
Referrals UX and API hardening; fix Web UI URL tests
odilitime Mar 30, 2026
24d7200
Harden referrals UX, tests, and CI-safe mocks
odilitime Mar 30, 2026
009e52c
fix(tests): satisfy Bun 1.3.9 mock.module exports; tighten Milady URL…
odilitime Mar 30, 2026
9ebc2fc
packages: String branch missing safe integer check unlike bigint branch
odilitime Apr 2, 2026
2db387d
packages: improve this (use-dashboard-referral-me.ts)
odilitime Apr 2, 2026
c3c6799
docs: Potential issue | 🟡 Minor (referrals.md)
odilitime Apr 2, 2026
b90c5cd
packages: add tests for milady-web-ui (milady-web-ui.ts)
odilitime Apr 2, 2026
fdd69fa
packages: Potential issue | 🟡 Minor (copy-to-clipboard.ts)
odilitime Apr 2, 2026
2d4be30
packages: Potential issue | 🟠 Major (bun-partial-module-shims.ts)
odilitime Apr 2, 2026
58593de
packages: add tests for milady-billing-route.test
odilitime Apr 2, 2026
a926ea6
packages: add tests for milady-web-ui.test (milady-web-ui.test.ts)
odilitime Apr 2, 2026
f33e4f3
packages: Potential issue | 🟠 Major (header-invite-button.tsx)
odilitime Apr 2, 2026
ff2b739
packages: add tests for bun-partial-module-shims
odilitime Apr 2, 2026
03f85a7
app: add tests for route (route.ts)
odilitime Apr 2, 2026
32e4e32
packages: improve a guard (affiliates-page-client.tsx)
odilitime Apr 2, 2026
5658068
packages: add tests for affiliates.test (affiliates.test.ts)
odilitime Apr 2, 2026
4e7914c
packages: fix issues in packages (affiliates-page-client.tsx)
odilitime Apr 2, 2026
2ccf5a7
packages: Minor: `refetch` is never exposed on the affiliates page
odilitime Apr 2, 2026
daaa26c
packages: 4. `fetchReferralMe` discards HTTP status code
odilitime Apr 2, 2026
7a7da38
packages: improve performance (use-dashboard-referral-me.ts)
odilitime Apr 2, 2026
6ad1791
packages: refactor for clarity (compat-envelope.ts)
odilitime Apr 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<code>` 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

Expand Down
139 changes: 139 additions & 0 deletions app/api/v1/referrals/route.ts
Original file line number Diff line number Diff line change
@@ -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);
11 changes: 8 additions & 3 deletions app/dashboard/milady/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.*
Expand Down
14 changes: 14 additions & 0 deletions docs/affiliate-referral-comparison.md
Original file line number Diff line number Diff line change
@@ -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.
125 changes: 125 additions & 0 deletions docs/referrals.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading