-
Notifications
You must be signed in to change notification settings - Fork 42
Auto-register template creators as referrers in echo-start #674
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Trynax
wants to merge
4
commits into
Merit-Systems:master
Choose a base branch
from
Trynax:feature/template-referral-system
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 2 commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
5570d2e
add template referral registration endpoint
Trynax 1feed4c
auto register template creators as referrers in echo-start
Trynax ce56d03
use case-insensitive match for GitHub usernames
Trynax f2cb8f8
added auth requirement, with echo.config.json for referral code
Trynax File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
108 changes: 108 additions & 0 deletions
108
packages/app/control/scripts/seed-test-referral-data.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| /** | ||
| * Script to seed test data for local template referral testing | ||
| * | ||
| * Creates: | ||
| * - Test user with GitHub link | ||
| * - Test app with membership | ||
| * - Returns app ID for use with echo-start | ||
| */ | ||
|
|
||
| import { PrismaClient } from '../src/generated/prisma/index.js'; | ||
|
|
||
| const prisma = new PrismaClient(); | ||
|
|
||
| async function main() { | ||
| console.log('Seeding test data for template referral system...\n'); | ||
|
|
||
| // Create or get test user | ||
| const testEmail = '[email protected]'; | ||
| let user = await prisma.user.findUnique({ | ||
| where: { email: testEmail }, | ||
| include: { githubLink: true }, | ||
| }); | ||
|
|
||
| if (!user) { | ||
| console.log('Creating test user...'); | ||
| user = await prisma.user.create({ | ||
| data: { | ||
| email: testEmail, | ||
| name: 'Test Template User', | ||
| githubLink: { | ||
| create: { | ||
| githubId: 123456, | ||
| githubType: 'user', | ||
| githubUrl: 'https://github.com/Trynax', | ||
| }, | ||
| }, | ||
| }, | ||
| include: { githubLink: true }, | ||
| }); | ||
| console.log(`Created user: ${user.email} (ID: ${user.id})`); | ||
| } else { | ||
| console.log(`Found existing user: ${user.email} (ID: ${user.id})`); | ||
|
|
||
| // Ensure GitHub link exists | ||
| if (!user.githubLink) { | ||
| await prisma.githubLink.create({ | ||
| data: { | ||
| userId: user.id, | ||
| githubId: 123456, | ||
| githubType: 'user', | ||
| githubUrl: 'https://github.com/Trynax', | ||
| }, | ||
| }); | ||
| console.log('✅ Added GitHub link for Trynax'); | ||
| } | ||
| } | ||
|
|
||
| // Create or get test app | ||
| const testAppName = 'Test Template Referral App'; | ||
| let app = await prisma.echoApp.findFirst({ | ||
| where: { name: testAppName }, | ||
| include: { appMemberships: true }, | ||
| }); | ||
|
|
||
| if (!app) { | ||
| console.log('\nCreating test app...'); | ||
| app = await prisma.echoApp.create({ | ||
| data: { | ||
| name: testAppName, | ||
| appMemberships: { | ||
| create: { | ||
| userId: user.id, | ||
| role: 'OWNER', | ||
| totalSpent: 0, | ||
| }, | ||
| }, | ||
| }, | ||
| include: { appMemberships: true }, | ||
| }); | ||
| console.log(`Created app: ${app.name} (ID: ${app.id})`); | ||
| } else { | ||
| console.log(`\nFound existing app: ${app.name} (ID: ${app.id})`); | ||
| } | ||
|
|
||
| console.log('\nTest Data Summary:'); | ||
| console.log('─────────────────────────────────────────────────────'); | ||
| console.log(`User ID: ${user.id}`); | ||
| console.log(`User Email: ${user.email}`); | ||
| console.log(`GitHub URL: ${user.githubLink?.githubUrl || 'https://github.com/Trynax'}`); | ||
| console.log(`App ID: ${app.id}`); | ||
| console.log(`App Name: ${app.name}`); | ||
| console.log('─────────────────────────────────────────────────────'); | ||
|
|
||
| console.log('\nTest Command:'); | ||
| console.log(`cd /tmp && /root/developments/opensource/echo/packages/sdk/echo-start/dist/index.js \\`); | ||
| console.log(` --template https://github.com/Trynax/commitcraft \\`); | ||
| console.log(` --app-id ${app.id} \\`); | ||
| console.log(` test-echo-local\n`); | ||
| } | ||
|
|
||
| main() | ||
| .catch((e) => { | ||
| console.error('Error seeding data:', e); | ||
| process.exit(1); | ||
| }) | ||
| .finally(async () => { | ||
| await prisma.$disconnect(); | ||
| }); |
76 changes: 76 additions & 0 deletions
76
packages/app/control/src/app/api/v1/referrals/register-template/route.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| import { NextResponse, NextRequest } from 'next/server'; | ||
| import { | ||
| registerTemplateReferral, | ||
| registerTemplateReferralSchema, | ||
| } from '@/services/db/apps/template-referral'; | ||
| import { db } from '@/services/db/client'; | ||
| import { AppRole } from '@/services/db/apps/permissions/types'; | ||
|
|
||
| export async function POST(request: NextRequest) { | ||
| try { | ||
| const body = await request.json(); | ||
| const validatedData = registerTemplateReferralSchema.parse(body); | ||
| const { appId, githubUsername, templateUrl } = validatedData; | ||
|
|
||
| const app = await db.echoApp.findUnique({ | ||
| where: { id: appId }, | ||
| include: { | ||
| appMemberships: { | ||
| where: { role: AppRole.OWNER }, | ||
| take: 1, | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| if (!app || app.appMemberships.length === 0) { | ||
| return NextResponse.json( | ||
| { success: false, message: 'App not found' }, | ||
| { status: 404 } | ||
| ); | ||
| } | ||
|
|
||
| const userId = app.appMemberships[0].userId; | ||
|
|
||
| const result = await registerTemplateReferral(userId, { | ||
| appId, | ||
| githubUsername, | ||
| templateUrl, | ||
| }); | ||
|
|
||
| if (result.status === 'registered') { | ||
| return NextResponse.json({ | ||
| success: true, | ||
| status: 'registered', | ||
| message: `Template creator ${result.referrerUsername} registered as referrer`, | ||
| referrerUsername: result.referrerUsername, | ||
| }); | ||
| } | ||
|
|
||
| if (result.status === 'skipped') { | ||
| return NextResponse.json({ | ||
| success: true, | ||
| status: 'skipped', | ||
| message: result.reason || 'Referral skipped', | ||
| reason: result.reason, | ||
| }); | ||
| } | ||
|
|
||
| return NextResponse.json({ | ||
| success: true, | ||
| status: 'not_found', | ||
| message: result.reason || 'Template creator not found on Echo', | ||
| reason: result.reason, | ||
| }); | ||
| } catch (error) { | ||
| const message = | ||
| error instanceof Error ? error.message : 'Unknown error occurred'; | ||
|
|
||
| return NextResponse.json( | ||
| { | ||
| success: false, | ||
| message, | ||
| }, | ||
| { status: 400 } | ||
| ); | ||
| } | ||
| } | ||
137 changes: 137 additions & 0 deletions
137
packages/app/control/src/services/db/apps/template-referral.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| import { z } from 'zod'; | ||
| import { db } from '@/services/db/client'; | ||
| import { appIdSchema } from './lib/schemas'; | ||
| import { AppRole } from './permissions/types'; | ||
|
|
||
| export const registerTemplateReferralSchema = z.object({ | ||
| appId: appIdSchema, | ||
| githubUsername: z.string().min(1), | ||
| templateUrl: z.string().url(), | ||
| }); | ||
|
|
||
| export type RegisterTemplateReferralInput = z.infer< | ||
| typeof registerTemplateReferralSchema | ||
| >; | ||
|
|
||
| export interface RegisterTemplateReferralResult { | ||
| status: 'registered' | 'skipped' | 'not_found'; | ||
| reason?: string; | ||
| referrerUsername?: string; | ||
| referralCodeId?: string; | ||
| } | ||
|
|
||
| export async function registerTemplateReferral( | ||
| userId: string, | ||
| input: RegisterTemplateReferralInput | ||
| ): Promise<RegisterTemplateReferralResult> { | ||
| const { appId, githubUsername, templateUrl } = input; | ||
|
|
||
| const app = await db.echoApp.findUnique({ | ||
| where: { id: appId }, | ||
| include: { | ||
| appMemberships: { | ||
| where: { | ||
| userId, | ||
| role: AppRole.OWNER, | ||
| }, | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| if (!app || app.appMemberships.length === 0) { | ||
| throw new Error('App not found or user is not the owner'); | ||
| } | ||
|
|
||
| const membership = await db.appMembership.findUnique({ | ||
| where: { | ||
| userId_echoAppId: { | ||
| userId, | ||
| echoAppId: appId, | ||
| }, | ||
| }, | ||
| select: { | ||
| referrerId: true, | ||
| }, | ||
| }); | ||
|
|
||
| if (!membership) { | ||
| throw new Error('App membership not found'); | ||
| } | ||
|
|
||
| if (membership.referrerId) { | ||
| return { | ||
| status: 'skipped', | ||
| reason: 'existing_referrer', | ||
| }; | ||
| } | ||
|
|
||
| const githubLink = await db.githubLink.findFirst({ | ||
| where: { | ||
| githubUrl: { | ||
| contains: githubUsername, | ||
| }, | ||
| githubType: 'user', | ||
| isArchived: false, | ||
| }, | ||
| include: { | ||
| user: { | ||
| select: { | ||
| id: true, | ||
| name: true, | ||
| }, | ||
| }, | ||
| }, | ||
| }); | ||
Trynax marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| if (!githubLink || !githubLink.user) { | ||
| return { | ||
| status: 'not_found', | ||
| reason: 'template_creator_not_on_echo', | ||
| }; | ||
| } | ||
|
|
||
| let referralCode = await db.referralCode.findFirst({ | ||
| where: { | ||
| userId: githubLink.user.id, | ||
| isArchived: false, | ||
| }, | ||
| select: { | ||
| id: true, | ||
| }, | ||
| }); | ||
|
|
||
| if (!referralCode) { | ||
| const code = crypto.randomUUID(); | ||
| const expiresAt = new Date(); | ||
| expiresAt.setFullYear(expiresAt.getFullYear() + 1); | ||
|
|
||
| referralCode = await db.referralCode.create({ | ||
| data: { | ||
| code, | ||
| userId: githubLink.user.id, | ||
| expiresAt, | ||
| }, | ||
| select: { | ||
| id: true, | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| await db.appMembership.update({ | ||
| where: { | ||
| userId_echoAppId: { | ||
| userId, | ||
| echoAppId: appId, | ||
| }, | ||
| }, | ||
| data: { | ||
| referrerId: referralCode.id, | ||
| }, | ||
| }); | ||
|
|
||
| return { | ||
| status: 'registered', | ||
| referrerUsername: githubUsername, | ||
| referralCodeId: referralCode.id, | ||
| }; | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
/api/v1/referrals/register-templateendpoint lacks authentication and accepts anyappId, allowing anyone to set arbitrary referrers for any app in the system. This is a critical authorization bypass.View Details
📝 Patch Details
Analysis
Unauthenticated API endpoint allows unauthorized referral modification
What fails:
/api/v1/referrals/register-templateendpoint accepts anyappIdwithout authentication, allowing attackers to set themselves as referrers for arbitrary appsHow to reproduce:
Result: Returns 200 OK and sets
attackeras referrer for the target app's owner without permission checks. The endpoint looks up the app owner from the database and modifies their referral data directly.Expected: Should return 401/403 Unauthorized. API endpoints that modify user data require authentication via
authRoutemiddleware (like/api/v1/user/referral/route.ts)Impact: Authorization bypass allows hijacking referral earnings by setting arbitrary referrers for any app in the system
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yoo @rsproule the authentication concern on this
/api/v1/referrals/register-template, I believe this isn't significant because app builders typically won't reveal their app ID publicly before scaffolding with echo-start, and once a referrer is set, subsequent calls are automatically skipped so it can't be overridden by echo start.