From 7d2c26a2307bd1343e18d2d19b4ba7d878de5ed4 Mon Sep 17 00:00:00 2001 From: thefadah <152818509+thefadah@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:48:26 +0100 Subject: [PATCH 01/10] dto(teams): cap TeamMemberSplitDto.role with @MaxLength(50) (#151) --- src/teams/dto/create-team.dto.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/teams/dto/create-team.dto.ts b/src/teams/dto/create-team.dto.ts index 8dfa7bc..f24ee24 100644 --- a/src/teams/dto/create-team.dto.ts +++ b/src/teams/dto/create-team.dto.ts @@ -7,6 +7,7 @@ import { IsString, IsUUID, Max, + MaxLength, Min, ValidateNested, } from 'class-validator'; @@ -16,9 +17,10 @@ export class TeamMemberSplitDto { @IsUUID() userId: string; - @ApiProperty({ required: false, example: 'frontend' }) + @ApiProperty({ required: false, example: 'frontend', maxLength: 50 }) @IsOptional() @IsString() + @MaxLength(50) role?: string; @ApiProperty({ example: 40, minimum: 0.01, maximum: 100 }) From 9551ef392f4665496608015b451df6acdc4d93a3 Mon Sep 17 00:00:00 2001 From: thefadah <152818509+thefadah@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:48:29 +0100 Subject: [PATCH 02/10] dto(milestones): cap title/description with @MaxLength (#151) --- src/milestones/dto/create-milestone.dto.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/milestones/dto/create-milestone.dto.ts b/src/milestones/dto/create-milestone.dto.ts index d98ef88..f80c25e 100644 --- a/src/milestones/dto/create-milestone.dto.ts +++ b/src/milestones/dto/create-milestone.dto.ts @@ -1,5 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsISO8601, IsOptional, IsString, IsUUID } from 'class-validator'; +import { + IsISO8601, + IsOptional, + IsString, + IsUUID, + MaxLength, +} from 'class-validator'; import { AssetType } from '../../common/enums'; import { IsMoneyAmount, @@ -16,13 +22,15 @@ export class CreateMilestoneDto { @IsUUID() sponsorId?: string; - @ApiProperty() + @ApiProperty({ maxLength: 200 }) @IsString() + @MaxLength(200) title: string; - @ApiProperty({ required: false }) + @ApiProperty({ required: false, maxLength: 2000 }) @IsOptional() @IsString() + @MaxLength(2000) description?: string; @ApiProperty() From df3f195dbe9de31269e5a52ad0db15dac7ca66d4 Mon Sep 17 00:00:00 2001 From: thefadah <152818509+thefadah@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:48:32 +0100 Subject: [PATCH 03/10] dto(maintenance-pool): cap CreatePoolDto.name with @MaxLength(100) (#151) --- src/maintenance-pool/dto/create-pool.dto.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/maintenance-pool/dto/create-pool.dto.ts b/src/maintenance-pool/dto/create-pool.dto.ts index fbe16b1..5f428f5 100644 --- a/src/maintenance-pool/dto/create-pool.dto.ts +++ b/src/maintenance-pool/dto/create-pool.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsOptional, IsString, IsUUID } from 'class-validator'; +import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; import { AssetType } from '../../common/enums'; import { IsMoneyAmount, @@ -7,8 +7,9 @@ import { } from '../../common/validators/money.validator'; export class CreatePoolDto { - @ApiProperty() + @ApiProperty({ maxLength: 100 }) @IsString() + @MaxLength(100) name: string; @ApiProperty({ required: false }) From 040faaa35ba0f7e0445ec34a8625e21a6591687a Mon Sep 17 00:00:00 2001 From: thefadah <152818509+thefadah@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:48:35 +0100 Subject: [PATCH 04/10] entity(team-member-split): bound role to varchar(50) (#151) --- src/common/entities/team-member-split.entity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/entities/team-member-split.entity.ts b/src/common/entities/team-member-split.entity.ts index 65482ee..9aa25e4 100644 --- a/src/common/entities/team-member-split.entity.ts +++ b/src/common/entities/team-member-split.entity.ts @@ -29,7 +29,7 @@ export class TeamMemberSplit { userId: string; /** Free-text label describing the member's contribution, e.g. "frontend". */ - @Column({ type: 'varchar', nullable: true }) + @Column({ type: 'varchar', length: 50, nullable: true }) role: string | null; /** Percentage of the bounty payout, 0-100. Sum across a team must equal 100. */ From e7184d395892232773b77a4fb03a385a14406a39 Mon Sep 17 00:00:00 2001 From: thefadah <152818509+thefadah@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:48:39 +0100 Subject: [PATCH 05/10] entity(milestone): bound title to varchar(200) (#151) --- src/common/entities/milestone.entity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/entities/milestone.entity.ts b/src/common/entities/milestone.entity.ts index c06ccb1..be14359 100644 --- a/src/common/entities/milestone.entity.ts +++ b/src/common/entities/milestone.entity.ts @@ -27,7 +27,7 @@ export class Milestone { @Column() repositoryId: string; - @Column() + @Column({ type: 'varchar', length: 200 }) title: string; @Column({ type: 'text', nullable: true }) From 03d0af554374ccc947bc316d3270f9faefbfc1be Mon Sep 17 00:00:00 2001 From: thefadah <152818509+thefadah@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:48:42 +0100 Subject: [PATCH 06/10] entity(maintenance-pool): bound name to varchar(100) (#151) --- src/common/entities/maintenance-pool.entity.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/entities/maintenance-pool.entity.ts b/src/common/entities/maintenance-pool.entity.ts index a956bcf..08c96c0 100644 --- a/src/common/entities/maintenance-pool.entity.ts +++ b/src/common/entities/maintenance-pool.entity.ts @@ -18,7 +18,7 @@ export class MaintenancePool { @PrimaryGeneratedColumn('uuid') id: string; - @Column() + @Column({ type: 'varchar', length: 100 }) name: string; @ManyToOne(() => Repository, { onDelete: 'CASCADE', nullable: true }) From 245bdd0593a04c3e182369117272050decd25bdb Mon Sep 17 00:00:00 2001 From: thefadah <152818509+thefadah@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:48:45 +0100 Subject: [PATCH 07/10] db: migration capping the free-text column lengths (#151) --- ...784800000000-BoundFreeTextColumnLengths.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/database/migrations/1784800000000-BoundFreeTextColumnLengths.ts diff --git a/src/database/migrations/1784800000000-BoundFreeTextColumnLengths.ts b/src/database/migrations/1784800000000-BoundFreeTextColumnLengths.ts new file mode 100644 index 0000000..370f657 --- /dev/null +++ b/src/database/migrations/1784800000000-BoundFreeTextColumnLengths.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Caps previously-unbounded free-text varchar columns so an arbitrarily large + * string can no longer be stored in a team member's `role`, a milestone + * `title`, or a maintenance pool `name` (#151). Matches the `@MaxLength(...)` + * constraints added to the corresponding DTOs. + */ +export class BoundFreeTextColumnLengths1784800000000 + implements MigrationInterface +{ + name = 'BoundFreeTextColumnLengths1784800000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "team_member_splits" ALTER COLUMN "role" TYPE character varying(50)`, + ); + await queryRunner.query( + `ALTER TABLE "milestones" ALTER COLUMN "title" TYPE character varying(200)`, + ); + await queryRunner.query( + `ALTER TABLE "maintenance_pools" ALTER COLUMN "name" TYPE character varying(100)`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "maintenance_pools" ALTER COLUMN "name" TYPE character varying`, + ); + await queryRunner.query( + `ALTER TABLE "milestones" ALTER COLUMN "title" TYPE character varying`, + ); + await queryRunner.query( + `ALTER TABLE "team_member_splits" ALTER COLUMN "role" TYPE character varying`, + ); + } +} From 8d90d1424b318f70bf54e4eb06d4e3cc84af2a18 Mon Sep 17 00:00:00 2001 From: thefadah <152818509+thefadah@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:48:48 +0100 Subject: [PATCH 08/10] ci: always run the E2E test step instead of gating it on unset secrets (#164) --- .github/workflows/ci.yml | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67f9344..e334661 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,21 +80,12 @@ jobs: NODE_ENV: test run: npm run test:cov - - name: Run E2E Tests (Conditional) + # The e2e specs run entirely against the local Postgres service and a + # mocked Stellar SDK; none of them use GITHUB_CLIENT_ID/SECRET. The step + # used to be gated on those (never-configured) secrets, so `npm run + # test:e2e` had never actually executed in CI (#164). Run it always. + - name: Run E2E Tests env: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/mergefi NODE_ENV: test - # Map GitHub Secrets if configured in the repository - GITHUB_CLIENT_ID: ${{ secrets.GITHUB_CLIENT_ID }} - GITHUB_CLIENT_SECRET: ${{ secrets.GITHUB_CLIENT_SECRET }} - run: | - if [ -z "$GITHUB_CLIENT_ID" ] || [ -z "$GITHUB_CLIENT_SECRET" ]; then - echo "==========================================================================" - echo "WARNING: Skipping E2E tests because external GitHub OAuth secrets are not" - echo "configured in the repository settings (GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET)." - echo "To enable E2E tests in CI, please set these secrets in GitHub." - echo "==========================================================================" - else - npm run test:e2e - fi - shell: bash + run: npm run test:e2e From 71a89227dfd746eeaa311f62207f5f9bb2f34601 Mon Sep 17 00:00:00 2001 From: thefadah <152818509+thefadah@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:48:52 +0100 Subject: [PATCH 09/10] escrow: document the real mergefi-milestones allocate/release_issue model (#160, #162) --- src/escrow/soroban-client.service.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/escrow/soroban-client.service.ts b/src/escrow/soroban-client.service.ts index 3c3a9d4..83224cc 100644 --- a/src/escrow/soroban-client.service.ts +++ b/src/escrow/soroban-client.service.ts @@ -43,6 +43,19 @@ export interface ContractInvocationResult { * live balance — see `EscrowService.poolWithdraw` (#163): * fn deposit(env: Env, sponsor: Address, pool_id: BytesN<32>, amount: i128, token: Address) * fn withdraw(env: Env, pool_id: BytesN<32>, recipient: Address, amount: i128) -> i128 + * + * The `mergefi-milestones` contract is a two-step allocate/release model with + * no "partially drain one locked escrow" primitive (#160, #162): + * `create_milestone()` opens a budget pool, `allocate(milestone_id, issue_id, + * amount)` reserves a slice of the unallocated remainder for one issue + * (admin-only, rejects over-allocation), and `release_issue(milestone_id, + * issue_id, recipients)` pays out that issue's already-reserved slice. + * `MilestonesService.resolveIssue` therefore needs per-issue allocation + * tracking rather than repeated `releasePartial` calls against a single + * ever-LOCKED escrow row: + * fn create_milestone(env: Env, sponsor: Address, milestone_id: BytesN<32>, budget: i128, token: Address) + * fn allocate(env: Env, milestone_id: BytesN<32>, issue_id: BytesN<32>, amount: i128) + * fn release_issue(env: Env, milestone_id: BytesN<32>, issue_id: BytesN<32>, recipients: Vec<(Address, u32)>) -> i128 */ @Injectable() export class SorobanClientService { From cd417f5cb79ff51ee6de7612d3553170b52bf00d Mon Sep 17 00:00:00 2001 From: thefadah <152818509+thefadah@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:48:56 +0100 Subject: [PATCH 10/10] milestones: reject resolving an issue that is already resolved (#162) --- src/milestones/milestones.service.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/milestones/milestones.service.ts b/src/milestones/milestones.service.ts index 3a6222c..2b3e759 100644 --- a/src/milestones/milestones.service.ts +++ b/src/milestones/milestones.service.ts @@ -132,6 +132,18 @@ export class MilestonesService { ); } + // Pay out each issue at most once. The real mergefi-milestones contract + // tracks a per-issue allocation and `release_issue` can only be called + // once per issue_id; here the resolved issue is moved to CLOSED in the + // transaction below, so resolving an already-CLOSED issue (while other + // issues are still open) must be rejected rather than double-paying it + // (#162). + if (issue.state !== 'open') { + throw new BadRequestException( + `Issue ${issueId} has already been resolved for milestone ${milestoneId}`, + ); + } + const unresolvedCount = openIssues.length; const remainingBudget = Number(milestone.budget) - Number(milestone.distributed);