From 79fd7c64a6c6dd5e5bd31e7ece6b0258663a9969 Mon Sep 17 00:00:00 2001 From: pannous Date: Sun, 15 Mar 2026 06:05:53 +0100 Subject: [PATCH 1/2] feature(minor): add template referral system to echo-start When scaffolding from an external GitHub template that contains an echo.json with a referralCode, echo-start now automatically registers the template creator as the referrer for the new app via the template-referral API. The echo.json file is cleaned up after registration to keep the scaffolded project tidy. Closes #612 --- packages/app/control/docs/money/referrals.mdx | 30 +++++++++ packages/app/control/prisma/schema.prisma | 5 +- packages/sdk/echo-start/src/index.ts | 63 ++++++++++++++++++- 3 files changed, 96 insertions(+), 2 deletions(-) diff --git a/packages/app/control/docs/money/referrals.mdx b/packages/app/control/docs/money/referrals.mdx index 142e2b325..054285354 100644 --- a/packages/app/control/docs/money/referrals.mdx +++ b/packages/app/control/docs/money/referrals.mdx @@ -162,6 +162,36 @@ Applies a referral code to the authenticated user. - Invalid or expired codes should fail silently on the client to avoid UX disruption - Referral earnings are calculated and paid out automatically by Echo +## Template Referrals + +If you publish an Echo template on GitHub, you can automatically be registered as a referrer when someone scaffolds an app using your template via `echo-start`. + +### How to Set Up + +Add an `echo.json` file to the root of your template repository: + +```json +{ + "referralCode": "YOUR_REFERRAL_CODE" +} +``` + +When a user runs `npx echo-start -t https://github.com/you/your-template`, echo-start will detect your referral code and register you as the template referrer for their new app. You'll earn your referral percentage on all profits generated by that app. + +### How It Works + +1. Create a referral code for one of your apps (via the referral API or dashboard) +2. Add that code to `echo.json` in your template repository +3. When someone scaffolds from your template, echo-start sends the code to Echo +4. You're registered as the template referrer (first-come-first-served per app) +5. The `echo.json` file is removed from the scaffolded project automatically + +### Important Notes + +- Template referrals are separate from user-to-user referrals +- Only the first referral code registered per app is kept +- The referral code must be valid and not expired + ## Beta Status This feature is in early beta and may not work as expected. Reach out in [Discord](https://discord.gg/merit) if you're interested in setting up referrals for your application. diff --git a/packages/app/control/prisma/schema.prisma b/packages/app/control/prisma/schema.prisma index b18061082..ad10fdc40 100644 --- a/packages/app/control/prisma/schema.prisma +++ b/packages/app/control/prisma/schema.prisma @@ -106,6 +106,8 @@ model EchoApp { currentReferralRewardId String? @db.Uuid // Reference to current active referral reward currentReferralReward ReferralReward? @relation("CurrentReferralReward", fields: [currentReferralRewardId], references: [id]) ReferralRewards ReferralReward[] @relation("AppReferralRewards") // All referral rewards for this app + templateReferrerCodeId String? @db.Uuid // Referral code of the external template creator + templateReferrerCode ReferralCode? @relation("TemplateReferrer", fields: [templateReferrerCodeId], references: [id]) appSessions AppSession[] payouts Payout[] OutboundEmailSent OutboundEmailSent[] @@ -412,7 +414,8 @@ model ReferralCode { createdAt DateTime @default(now()) @db.Timestamptz updatedAt DateTime @updatedAt @db.Timestamptz - appMemberships AppMembership[] + appMemberships AppMembership[] + templateReferredApps EchoApp[] @relation("TemplateReferrer") user User? @relation(fields: [userId], references: [id], onDelete: Cascade) Transaction Transaction[] diff --git a/packages/sdk/echo-start/src/index.ts b/packages/sdk/echo-start/src/index.ts index c249d50d1..789960ec1 100644 --- a/packages/sdk/echo-start/src/index.ts +++ b/packages/sdk/echo-start/src/index.ts @@ -14,7 +14,13 @@ import chalk from 'chalk'; import { spawn } from 'child_process'; import { Command } from 'commander'; import degit from 'degit'; -import { existsSync, readdirSync, readFileSync, writeFileSync } from 'fs'; +import { + existsSync, + readdirSync, + readFileSync, + unlinkSync, + writeFileSync, +} from 'fs'; import path from 'path'; const program = new Command(); @@ -178,6 +184,42 @@ interface CreateAppOptions { skipInstall?: boolean; } +interface EchoTemplateConfig { + referralCode?: string; +} + +const ECHO_CONTROL_URL = 'https://echo.merit.systems'; + +function readEchoTemplateConfig(projectPath: string): EchoTemplateConfig | null { + const configPath = path.join(projectPath, 'echo.json'); + if (!existsSync(configPath)) return null; + try { + return JSON.parse(readFileSync(configPath, 'utf-8')); + } catch { + return null; + } +} + +async function registerTemplateReferral( + appId: string, + referralCode: string +): Promise { + try { + const response = await fetch( + `${ECHO_CONTROL_URL}/api/v1/apps/template-referral`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ echoAppId: appId, referralCode }), + } + ); + const data = (await response.json()) as { success?: boolean }; + return data.success === true; + } catch { + return false; + } +} + function isExternalTemplate(template: string): boolean { return ( template.startsWith('https://github.com/') || @@ -414,6 +456,25 @@ async function createApp(projectDir: string, options: CreateAppOptions) { log.message(`Created .env.local with ${envVarName}`); } + // Register template referral for external templates + if (isExternal) { + const echoConfig = readEchoTemplateConfig(absoluteProjectPath); + if (echoConfig?.referralCode) { + const registered = await registerTemplateReferral( + appId!, + echoConfig.referralCode + ); + if (registered) { + log.message('Template creator registered as referrer'); + } + // Remove echo.json from the scaffolded project — it's only for referral tracking + const echoConfigPath = path.join(absoluteProjectPath, 'echo.json'); + if (existsSync(echoConfigPath)) { + unlinkSync(echoConfigPath); + } + } + } + log.step('Project setup completed successfully'); // Auto-install dependencies unless skipped From e8937b86c09bde46c3cb5be4f6d5c3fd5a917dd5 Mon Sep 17 00:00:00 2001 From: pannous Date: Mon, 16 Mar 2026 10:38:12 +0100 Subject: [PATCH 2/2] fix: address Copilot review feedback on template referral PR - Validate parsed JSON shape and referralCode type in readEchoTemplateConfig - Always remove echo.json for external templates regardless of parse outcome - Wrap unlinkSync in try/catch to avoid aborting scaffolding on cleanup failure - Add missing API route for /api/v1/apps/template-referral - Add Prisma migration for templateReferrerCodeId column --- .../migration.sql | 5 ++ .../api/v1/apps/template-referral/route.ts | 72 +++++++++++++++++++ packages/sdk/echo-start/src/index.ts | 21 +++++- 3 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 packages/app/control/prisma/migrations/20260316000000_add_template_referrer/migration.sql create mode 100644 packages/app/control/src/app/api/v1/apps/template-referral/route.ts diff --git a/packages/app/control/prisma/migrations/20260316000000_add_template_referrer/migration.sql b/packages/app/control/prisma/migrations/20260316000000_add_template_referrer/migration.sql new file mode 100644 index 000000000..b803dc800 --- /dev/null +++ b/packages/app/control/prisma/migrations/20260316000000_add_template_referrer/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "echo_apps" ADD COLUMN "templateReferrerCodeId" UUID; + +-- AddForeignKey +ALTER TABLE "echo_apps" ADD CONSTRAINT "echo_apps_templateReferrerCodeId_fkey" FOREIGN KEY ("templateReferrerCodeId") REFERENCES "referral_codes"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/app/control/src/app/api/v1/apps/template-referral/route.ts b/packages/app/control/src/app/api/v1/apps/template-referral/route.ts new file mode 100644 index 000000000..2e13fb439 --- /dev/null +++ b/packages/app/control/src/app/api/v1/apps/template-referral/route.ts @@ -0,0 +1,72 @@ +/** + * Template Referral API + * + * POST /api/v1/apps/template-referral - Register a template creator as the referrer for an app. + * Called by echo-start when scaffolding from an external template that includes a referral code. + * No auth required — the referral code itself authenticates the template creator. + */ +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { createZodRoute } from '@/lib/api/create-route'; +import { appIdSchema } from '@/services/db/apps/lib/schemas'; +import { db } from '@/services/db/client'; + +const publicRoute = createZodRoute(); + +const templateReferralSchema = z.object({ + echoAppId: appIdSchema, + referralCode: z.string().min(1), +}); + +export const POST = publicRoute + .body(templateReferralSchema) + .handler(async (_, context) => { + const { echoAppId, referralCode } = context.body; + + const code = await db.referralCode.findUnique({ + where: { code: referralCode }, + }); + + if (!code || code.isArchived) { + return NextResponse.json( + { success: false, message: 'Invalid referral code' }, + { status: 400 } + ); + } + + if (code.expiresAt < new Date()) { + return NextResponse.json( + { success: false, message: 'Referral code has expired' }, + { status: 400 } + ); + } + + const app = await db.echoApp.findUnique({ + where: { id: echoAppId }, + }); + + if (!app) { + return NextResponse.json( + { success: false, message: 'App not found' }, + { status: 404 } + ); + } + + // Only set template referrer if not already set (first-come-first-served) + if (app.templateReferrerCodeId) { + return NextResponse.json({ + success: true, + message: 'Template referrer already registered', + }); + } + + await db.echoApp.update({ + where: { id: echoAppId }, + data: { templateReferrerCodeId: code.id }, + }); + + return NextResponse.json({ + success: true, + message: 'Template referrer registered successfully', + }); + }); diff --git a/packages/sdk/echo-start/src/index.ts b/packages/sdk/echo-start/src/index.ts index 789960ec1..565e6db69 100644 --- a/packages/sdk/echo-start/src/index.ts +++ b/packages/sdk/echo-start/src/index.ts @@ -194,7 +194,20 @@ function readEchoTemplateConfig(projectPath: string): EchoTemplateConfig | null const configPath = path.join(projectPath, 'echo.json'); if (!existsSync(configPath)) return null; try { - return JSON.parse(readFileSync(configPath, 'utf-8')); + const parsed = JSON.parse(readFileSync(configPath, 'utf-8')); + if ( + typeof parsed !== 'object' || + parsed === null || + Array.isArray(parsed) + ) { + return null; + } + const code = parsed.referralCode; + if (code !== undefined && typeof code !== 'string') return null; + if (typeof code === 'string') { + return { referralCode: code.trim() }; + } + return {}; } catch { return null; } @@ -467,11 +480,15 @@ async function createApp(projectDir: string, options: CreateAppOptions) { if (registered) { log.message('Template creator registered as referrer'); } - // Remove echo.json from the scaffolded project — it's only for referral tracking + } + // Always remove echo.json from the scaffolded project — it's only for referral tracking + try { const echoConfigPath = path.join(absoluteProjectPath, 'echo.json'); if (existsSync(echoConfigPath)) { unlinkSync(echoConfigPath); } + } catch { + log.warning('Could not remove echo.json from scaffolded project'); } }