Skip to content

Priority Build Implementation Plan

Created: February 4, 2026 Based on: docs/audits/PRICING_CLAIMS_INFRASTRUCTURE_AUDIT_2026-02-04.md Status: Pre-GTM Critical Items


Overview

This plan covers three critical areas that must be completed before GTM launch:

Area Current Score Target Effort Estimate
API Controllers 20% 100% 3-4 days
Security Critical Fixes 33% 90%+ 2-3 days
Rate Limit Tier Differentiation 0% 100% 1 day

Total Estimated Effort: 6-8 days (can be parallelized)


1. API Controllers Implementation

Current State

  • Routes defined in routes/api_v1.php (lines 284-318)
  • Controllers referenced but DO NOT EXIST
  • Customers hitting these endpoints get 404 errors

Missing Controllers

Controller Methods Needed Priority
PartnerController profile() HIGH
PartnerListingController index(), show(), update(), seoScore(), recommendations() HIGH
CoSellController index(), store(), show(), update(), timeline() HIGH
PartnerAnalyticsController summary(), reports(), showReport() MEDIUM
WebhookController index(), store(), destroy(), events(), test() HIGH

Implementation Steps

Step 1.1: Create Base Partner Controller

File: app/Http/Controllers/Api/V1/PartnerController.php

<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

/**
 * @OA\Tag(name="Partner", description="Partner profile and settings")
 */
class PartnerController extends Controller
{
    /**
     * @OA\Get(
     *     path="/api/v1/partners/profile",
     *     summary="Get partner profile",
     *     tags={"Partner"},
     *     security={{"bearerAuth":{}}},
     *     @OA\Response(response=200, description="Partner profile data")
     * )
     */
    public function profile(Request $request): JsonResponse
    {
        $user = $request->user();
        $partner = $user->partner;

        if (!$partner) {
            return response()->json([
                'error' => 'No partner profile found',
                'code' => 'PARTNER_NOT_FOUND'
            ], 404);
        }

        return response()->json([
            'data' => [
                'id' => $partner->id,
                'name' => $partner->name,
                'company' => $partner->company_name,
                'tier' => $user->activePlan()?->getTier()?->value,
                'created_at' => $partner->created_at,
            ],
            'meta' => [
                'api_version' => 'v1',
            ]
        ]);
    }
}

Step 1.2: Create Partner Listing Controller

File: app/Http/Controllers/Api/V1/PartnerListingController.php

<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\CustomExtensions\CloudMarketplace\System\Models\Listing;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;

/**
 * @OA\Tag(name="Listings", description="Partner marketplace listings")
 */
class PartnerListingController extends Controller
{
    /**
     * List all listings for the authenticated partner
     */
    public function index(Request $request): JsonResponse
    {
        $user = $request->user();
        $listings = Listing::where('user_id', $user->id)
            ->orderBy('updated_at', 'desc')
            ->paginate($request->get('per_page', 20));

        return response()->json([
            'data' => $listings->items(),
            'meta' => [
                'current_page' => $listings->currentPage(),
                'last_page' => $listings->lastPage(),
                'per_page' => $listings->perPage(),
                'total' => $listings->total(),
            ]
        ]);
    }

    /**
     * Get a specific listing
     */
    public function show(Request $request, int $id): JsonResponse
    {
        $user = $request->user();
        $listing = Listing::where('id', $id)
            ->where('user_id', $user->id)
            ->first();

        if (!$listing) {
            return response()->json([
                'error' => 'Listing not found',
                'code' => 'LISTING_NOT_FOUND'
            ], 404);
        }

        return response()->json(['data' => $listing]);
    }

    /**
     * Update a listing
     */
    public function update(Request $request, int $id): JsonResponse
    {
        $user = $request->user();
        $listing = Listing::where('id', $id)
            ->where('user_id', $user->id)
            ->first();

        if (!$listing) {
            return response()->json([
                'error' => 'Listing not found',
                'code' => 'LISTING_NOT_FOUND'
            ], 404);
        }

        $validator = Validator::make($request->all(), [
            'title' => 'sometimes|string|max:255',
            'description' => 'sometimes|string',
            'status' => 'sometimes|in:draft,active,archived',
        ]);

        if ($validator->fails()) {
            return response()->json([
                'error' => 'Validation failed',
                'details' => $validator->errors()
            ], 422);
        }

        $listing->update($validator->validated());

        return response()->json(['data' => $listing->fresh()]);
    }

    /**
     * Get SEO score for a listing
     */
    public function seoScore(Request $request, int $id): JsonResponse
    {
        $user = $request->user();
        $listing = Listing::where('id', $id)
            ->where('user_id', $user->id)
            ->first();

        if (!$listing) {
            return response()->json([
                'error' => 'Listing not found',
                'code' => 'LISTING_NOT_FOUND'
            ], 404);
        }

        // TODO: Integrate with MarketplaceListingSEOCapability
        return response()->json([
            'data' => [
                'listing_id' => $id,
                'overall_score' => $listing->seo_score ?? 0,
                'factors' => [
                    'title_optimization' => $listing->seo_title_score ?? 0,
                    'description_quality' => $listing->seo_description_score ?? 0,
                    'keyword_density' => $listing->seo_keyword_score ?? 0,
                ],
                'last_analyzed_at' => $listing->seo_analyzed_at,
            ]
        ]);
    }

    /**
     * Get SEO recommendations for a listing
     */
    public function recommendations(Request $request, int $id): JsonResponse
    {
        $user = $request->user();
        $listing = Listing::where('id', $id)
            ->where('user_id', $user->id)
            ->first();

        if (!$listing) {
            return response()->json([
                'error' => 'Listing not found',
                'code' => 'LISTING_NOT_FOUND'
            ], 404);
        }

        // TODO: Integrate with MarketplaceListingSEOCapability for AI recommendations
        return response()->json([
            'data' => [
                'listing_id' => $id,
                'recommendations' => $listing->seo_recommendations ?? [],
            ]
        ]);
    }
}

Step 1.3: Create CoSell Controller

File: app/Http/Controllers/Api/V1/CoSellController.php

<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\CustomExtensions\CloudMarketplace\System\Models\CoSellOpportunity;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;

/**
 * @OA\Tag(name="CoSell", description="Co-sell opportunity management")
 */
class CoSellController extends Controller
{
    public function index(Request $request): JsonResponse
    {
        $user = $request->user();
        $opportunities = CoSellOpportunity::where('user_id', $user->id)
            ->orderBy('updated_at', 'desc')
            ->paginate($request->get('per_page', 20));

        return response()->json([
            'data' => $opportunities->items(),
            'meta' => [
                'current_page' => $opportunities->currentPage(),
                'last_page' => $opportunities->lastPage(),
                'per_page' => $opportunities->perPage(),
                'total' => $opportunities->total(),
            ]
        ]);
    }

    public function store(Request $request): JsonResponse
    {
        $validator = Validator::make($request->all(), [
            'partner_id' => 'required|exists:partners,id',
            'opportunity_name' => 'required|string|max:255',
            'customer_name' => 'required|string|max:255',
            'estimated_value' => 'required|numeric|min:0',
            'stage' => 'required|in:prospect,qualified,proposal,negotiation,closed_won,closed_lost',
            'expected_close_date' => 'required|date|after:today',
        ]);

        if ($validator->fails()) {
            return response()->json([
                'error' => 'Validation failed',
                'details' => $validator->errors()
            ], 422);
        }

        $opportunity = CoSellOpportunity::create([
            'user_id' => $request->user()->id,
            ...$validator->validated()
        ]);

        return response()->json(['data' => $opportunity], 201);
    }

    public function show(Request $request, int $id): JsonResponse
    {
        $user = $request->user();
        $opportunity = CoSellOpportunity::where('id', $id)
            ->where('user_id', $user->id)
            ->first();

        if (!$opportunity) {
            return response()->json([
                'error' => 'Opportunity not found',
                'code' => 'OPPORTUNITY_NOT_FOUND'
            ], 404);
        }

        return response()->json(['data' => $opportunity]);
    }

    public function update(Request $request, int $id): JsonResponse
    {
        $user = $request->user();
        $opportunity = CoSellOpportunity::where('id', $id)
            ->where('user_id', $user->id)
            ->first();

        if (!$opportunity) {
            return response()->json([
                'error' => 'Opportunity not found',
                'code' => 'OPPORTUNITY_NOT_FOUND'
            ], 404);
        }

        $validator = Validator::make($request->all(), [
            'opportunity_name' => 'sometimes|string|max:255',
            'customer_name' => 'sometimes|string|max:255',
            'estimated_value' => 'sometimes|numeric|min:0',
            'stage' => 'sometimes|in:prospect,qualified,proposal,negotiation,closed_won,closed_lost',
            'expected_close_date' => 'sometimes|date',
        ]);

        if ($validator->fails()) {
            return response()->json([
                'error' => 'Validation failed',
                'details' => $validator->errors()
            ], 422);
        }

        $opportunity->update($validator->validated());

        return response()->json(['data' => $opportunity->fresh()]);
    }

    public function timeline(Request $request, int $id): JsonResponse
    {
        $user = $request->user();
        $opportunity = CoSellOpportunity::where('id', $id)
            ->where('user_id', $user->id)
            ->with('activities')
            ->first();

        if (!$opportunity) {
            return response()->json([
                'error' => 'Opportunity not found',
                'code' => 'OPPORTUNITY_NOT_FOUND'
            ], 404);
        }

        return response()->json([
            'data' => [
                'opportunity_id' => $id,
                'timeline' => $opportunity->activities ?? [],
            ]
        ]);
    }
}

Step 1.4: Create Partner Analytics Controller

File: app/Http/Controllers/Api/V1/PartnerAnalyticsController.php

<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

/**
 * @OA\Tag(name="Analytics", description="Partner analytics and reporting")
 */
class PartnerAnalyticsController extends Controller
{
    public function summary(Request $request): JsonResponse
    {
        $user = $request->user();
        $period = $request->get('period', '30d');

        // TODO: Integrate with actual analytics service
        return response()->json([
            'data' => [
                'period' => $period,
                'listings' => [
                    'total' => 0,
                    'active' => 0,
                    'views' => 0,
                ],
                'cosell' => [
                    'opportunities' => 0,
                    'pipeline_value' => 0,
                    'closed_won' => 0,
                ],
                'content' => [
                    'generated' => 0,
                    'credits_used' => 0,
                ],
            ]
        ]);
    }

    public function reports(Request $request): JsonResponse
    {
        $user = $request->user();

        // TODO: Integrate with reporting system
        return response()->json([
            'data' => [],
            'meta' => [
                'available_reports' => [
                    'listing_performance',
                    'cosell_pipeline',
                    'content_usage',
                    'attribution',
                ]
            ]
        ]);
    }

    public function showReport(Request $request, string $reportType): JsonResponse
    {
        $user = $request->user();
        $validReports = ['listing_performance', 'cosell_pipeline', 'content_usage', 'attribution'];

        if (!in_array($reportType, $validReports)) {
            return response()->json([
                'error' => 'Invalid report type',
                'code' => 'INVALID_REPORT_TYPE',
                'valid_types' => $validReports
            ], 400);
        }

        // TODO: Generate actual report data
        return response()->json([
            'data' => [
                'report_type' => $reportType,
                'generated_at' => now()->toISOString(),
                'data' => [],
            ]
        ]);
    }
}

Step 1.5: Create Webhook Controller

File: app/Http/Controllers/Api/V1/WebhookController.php

<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Models\Webhook;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;

/**
 * @OA\Tag(name="Webhooks", description="Webhook management")
 */
class WebhookController extends Controller
{
    public function index(Request $request): JsonResponse
    {
        $user = $request->user();
        $webhooks = Webhook::where('user_id', $user->id)
            ->orderBy('created_at', 'desc')
            ->paginate($request->get('per_page', 20));

        return response()->json([
            'data' => $webhooks->items(),
            'meta' => [
                'current_page' => $webhooks->currentPage(),
                'last_page' => $webhooks->lastPage(),
                'per_page' => $webhooks->perPage(),
                'total' => $webhooks->total(),
            ]
        ]);
    }

    public function store(Request $request): JsonResponse
    {
        $validator = Validator::make($request->all(), [
            'url' => 'required|url|max:2048',
            'events' => 'required|array|min:1',
            'events.*' => 'string|in:listing.created,listing.updated,cosell.created,cosell.updated,content.generated',
            'secret' => 'sometimes|string|min:32',
            'active' => 'sometimes|boolean',
        ]);

        if ($validator->fails()) {
            return response()->json([
                'error' => 'Validation failed',
                'details' => $validator->errors()
            ], 422);
        }

        $data = $validator->validated();
        $data['user_id'] = $request->user()->id;
        $data['secret'] = $data['secret'] ?? Str::random(64);
        $data['active'] = $data['active'] ?? true;

        $webhook = Webhook::create($data);

        return response()->json([
            'data' => $webhook,
            'meta' => [
                'secret_shown_once' => $data['secret'],
            ]
        ], 201);
    }

    public function destroy(Request $request, int $id): JsonResponse
    {
        $user = $request->user();
        $webhook = Webhook::where('id', $id)
            ->where('user_id', $user->id)
            ->first();

        if (!$webhook) {
            return response()->json([
                'error' => 'Webhook not found',
                'code' => 'WEBHOOK_NOT_FOUND'
            ], 404);
        }

        $webhook->delete();

        return response()->json(null, 204);
    }

    public function events(Request $request): JsonResponse
    {
        return response()->json([
            'data' => [
                'events' => [
                    [
                        'name' => 'listing.created',
                        'description' => 'Triggered when a new listing is created',
                    ],
                    [
                        'name' => 'listing.updated',
                        'description' => 'Triggered when a listing is updated',
                    ],
                    [
                        'name' => 'cosell.created',
                        'description' => 'Triggered when a new co-sell opportunity is created',
                    ],
                    [
                        'name' => 'cosell.updated',
                        'description' => 'Triggered when a co-sell opportunity is updated',
                    ],
                    [
                        'name' => 'content.generated',
                        'description' => 'Triggered when AI content is generated',
                    ],
                ]
            ]
        ]);
    }

    public function test(Request $request, int $id): JsonResponse
    {
        $user = $request->user();
        $webhook = Webhook::where('id', $id)
            ->where('user_id', $user->id)
            ->first();

        if (!$webhook) {
            return response()->json([
                'error' => 'Webhook not found',
                'code' => 'WEBHOOK_NOT_FOUND'
            ], 404);
        }

        // TODO: Dispatch test webhook job
        // TestWebhookJob::dispatch($webhook);

        return response()->json([
            'data' => [
                'message' => 'Test webhook dispatched',
                'webhook_id' => $id,
            ]
        ]);
    }
}

Step 1.6: Add Authentication Middleware to Routes

File: routes/api_v1.php - Update partner routes (around line 282)

// Current (missing auth):
Route::prefix('partners')->middleware(['throttle:partner'])->group(function () {

// Updated (with auth):
Route::prefix('partners')->middleware(['auth:api', 'throttle:partner'])->group(function () {

2. Security Critical Fixes

Issue Tracker

# Issue Severity File Status
1 CORS wildcards CRITICAL config/cors.php FIXED
2 Broad CSRF exemptions CRITICAL app/Http/Middleware/VerifyCsrfToken.php TODO
3 Session SameSite=none CRITICAL config/session.php TODO
4 Missing authorization in DocumentsApiController CRITICAL app/Http/Controllers/Api/DocumentsApiController.php TODO
5 Weak OTP (4 digits) CRITICAL app/Http/Controllers/Auth/AuthenticatedSessionController.php TODO
6 XSS in Blade templates CRITICAL Multiple views TODO
7 Mass assignment vulnerabilities HIGH 25+ models TODO
8 Session encryption disabled HIGH config/session.php TODO

Fix 2.1: Remove Broad CSRF Exemption

File: app/Http/Middleware/VerifyCsrfToken.php

Current (lines 18-31):

protected $except = [
    'pdf/getContent',
    'stripe/*',
    'webhooks/*',
    'dashboard/*',           // <-- REMOVE THIS LINE
    'dashboard/user/payment/iyzico/*',
    'chatbot/*',
    'generator/webhook/fal-ai',
    'dashboard/admin/config/more',
    'translations/lang/update-all',
    'social-media/*',
    'chatbot/instagram/*',
];

Fixed:

protected $except = [
    'pdf/getContent',
    'stripe/*',
    'webhooks/*',
    // Specific dashboard endpoints that legitimately need CSRF exemption
    'dashboard/user/payment/iyzico/*',
    'chatbot/*',
    'generator/webhook/fal-ai',
    'dashboard/admin/config/more',
    'translations/lang/update-all',
    'social-media/*',
    'chatbot/instagram/*',
];

Fix 2.2: Update Session Configuration

File: config/session.php

Line 199 - Change SameSite:

// Current:
'same_site' => 'none',

// Fixed:
'same_site' => env('SESSION_SAME_SITE', 'lax'),

Line 49 - Enable session encryption:

// Current:
'encrypt' => false,

// Fixed:
'encrypt' => env('SESSION_ENCRYPT', true),

Fix 2.3: Add Authorization to DocumentsApiController

File: app/Http/Controllers/Api/DocumentsApiController.php

Method: getDoc() (lines 300-315)

public function getDoc(Request $request)
{
    if ($request->id == null) {
        return response()->json(['error' => __('ID is required')], 412);
    }

    $document = UserOpenai::select('user_openai.*', 'openai.title as ai_title', 'openai.image as ai_image', 'openai.color as ai_color')
        ->join('openai', 'openai.id', '=', 'user_openai.openai_id')
        ->where('user_openai.id', $request->id)
        ->where('user_openai.user_id', auth()->id())  // ADD THIS LINE
        ->first();

    if ($document == null) {
        return response()->json(['error' => __('Document not found')], 404);
    }

    return response()->json($document, 200);
}

Method: saveDoc() (lines 381-407)

// Add after line 393:
->where('user_openai.user_id', auth()->id())

Method: deleteDoc() (lines 454-472)

// Add after line 463:
->where('user_openai.user_id', auth()->id())

Fix 2.4: Strengthen OTP Implementation

File: app/Http/Controllers/Auth/AuthenticatedSessionController.php (line 84)

Current:

$otp = mt_rand(1000, 9999);  // Only 9,000 possibilities

Fixed:

$otp = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT);  // 1,000,000 possibilities

Also add rate limiting for OTP verification route in routes/web.php or routes/api.php:

Route::post('/verify-otp', [AuthenticatedSessionController::class, 'verifyOtp'])
    ->middleware('throttle:5,15');  // 5 attempts per 15 minutes

Fix 2.5: Sanitize XSS in Blade Templates

Option A: Use HTMLPurifier (Recommended)

Install: composer require ezyang/htmlpurifier

Create helper in app/Helpers/helpers.php:

function clean_html(string $html): string
{
    $config = HTMLPurifier_Config::createDefault();
    $config->set('HTML.Allowed', 'p,br,strong,em,ul,ol,li,a[href],h1,h2,h3,h4,h5,h6,blockquote,pre,code');
    $purifier = new HTMLPurifier($config);
    return $purifier->purify($html);
}

Option B: Use Laravel's built-in e() for simple cases

Files to update: - resources/views/social-media-front/page/index.blade.php (line 26) - resources/views/default/page/index.blade.php (line 41) - resources/views/social-media-front/blog/post.blade.php (line 19) - resources/views/default/blog/post.blade.php (line 19)

Change:

{!! $page->content !!}

To:

{!! clean_html($page->content) !!}

Fix 2.6: Update Mass Assignment Protection

For each model with protected $guarded = [], convert to explicit $fillable:

Example for app/Models/UserSupport.php:

// Current:
protected $guarded = [];

// Fixed:
protected $fillable = [
    'user_id',
    'subject',
    'message',
    'status',
    'priority',
    'category',
];

Priority models to fix: 1. UserSupport.php 2. UserOpenai.php 3. Activity.php 4. OpenAIGenerator.php 5. Setting.php


3. Rate Limit Tier Differentiation

Current State

  • All users share same rate limits (60 req/min for API, 100 req/min for partner API)
  • No tier-based differentiation

Target State (Per Pricing Claims)

Tier Rate Limit API Keys
Starter 60 req/min 1
Accelerate 100 req/min 1
Command 1000 req/min Unlimited

Implementation

File: app/Providers/RouteServiceProvider.php

Replace lines 41-58 with:

use App\Enums\Plan\PlanTier;

protected function configureRateLimiting(): void
{
    // Default API rate limit (unauthenticated or free users)
    RateLimiter::for('api', static function (Request $request) {
        $user = $request->user();

        if (!$user) {
            return Limit::perMinute(30)->by($request->ip());
        }

        $tier = $user->activePlan()?->getTier();

        return match ($tier) {
            PlanTier::STARTER,
            PlanTier::MARKETPLACE_STARTER => Limit::perMinute(60)->by($user->id),

            PlanTier::ACCELERATE,
            PlanTier::MARKETPLACE_ACCELERATE => Limit::perMinute(100)->by($user->id),

            PlanTier::COMMAND_PLUS,
            PlanTier::MARKETPLACE_COMMAND_PLUS => Limit::perMinute(1000)->by($user->id),

            PlanTier::ENTERPRISE => Limit::perMinute(5000)->by($user->id),

            default => Limit::perMinute(60)->by($user->id),
        };
    });

    // Partner API rate limit (tier-based)
    RateLimiter::for('partner', static function (Request $request) {
        $user = $request->user();
        $partnerId = $user?->partner_id ?? $request->header('X-Partner-Id');

        if (!$user) {
            return Limit::perMinute(30)->by($request->ip());
        }

        $tier = $user->activePlan()?->getTier();

        return match ($tier) {
            PlanTier::STARTER,
            PlanTier::MARKETPLACE_STARTER => [
                Limit::perMinute(60)->by($partnerId ?: $user->id),
                Limit::perHour(1000)->by($partnerId ?: $user->id),
            ],

            PlanTier::ACCELERATE,
            PlanTier::MARKETPLACE_ACCELERATE => [
                Limit::perMinute(100)->by($partnerId ?: $user->id),
                Limit::perHour(2000)->by($partnerId ?: $user->id),
            ],

            PlanTier::COMMAND_PLUS,
            PlanTier::MARKETPLACE_COMMAND_PLUS => [
                Limit::perMinute(1000)->by($partnerId ?: $user->id),
                Limit::perHour(20000)->by($partnerId ?: $user->id),
            ],

            PlanTier::ENTERPRISE => [
                Limit::perMinute(5000)->by($partnerId ?: $user->id),
                Limit::perHour(100000)->by($partnerId ?: $user->id),
            ],

            default => [
                Limit::perMinute(60)->by($partnerId ?: $user->id),
                Limit::perHour(1000)->by($partnerId ?: $user->id),
            ],
        };
    });
}

File: app/Http/Middleware/AddRateLimitHeaders.php

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class AddRateLimitHeaders
{
    public function handle(Request $request, Closure $next)
    {
        $response = $next($request);

        $user = $request->user();
        $tier = $user?->activePlan()?->getTier()?->value ?? 'free';

        $response->headers->set('X-RateLimit-Tier', $tier);

        return $response;
    }
}

Register in app/Http/Kernel.php under $middlewareGroups['api'].


4. Implementation Checklist

Week 1: API Controllers (3-4 days)

  • Create app/Http/Controllers/Api/V1/ directory structure
  • Implement PartnerController.php
  • Implement PartnerListingController.php
  • Implement CoSellController.php
  • Implement PartnerAnalyticsController.php
  • Implement WebhookController.php
  • Create Webhook model and migration if not exists
  • Add auth:api middleware to partner routes
  • Write API tests for each controller
  • Update OpenAPI documentation

Week 1: Security Fixes (2-3 days, can parallelize)

  • Remove dashboard/* from CSRF exemptions
  • Update session same_site to lax
  • Enable session encryption
  • Add user ownership checks to DocumentsApiController
  • Upgrade OTP from 4 to 6 digits
  • Add rate limiting to OTP verification
  • Install HTMLPurifier and create clean_html() helper
  • Update Blade templates to use sanitized output
  • Convert top 5 models from $guarded = [] to $fillable
  • Run security regression tests

Week 1: Rate Limiting (1 day)

  • Update RouteServiceProvider.php with tier-based logic
  • Add AddRateLimitHeaders middleware
  • Test rate limits for each tier
  • Document rate limits in API docs

5. Testing Strategy

API Controller Tests

// tests/Feature/Api/V1/PartnerListingControllerTest.php
public function test_user_can_only_access_own_listings()
{
    $user1 = User::factory()->create();
    $user2 = User::factory()->create();
    $listing = Listing::factory()->create(['user_id' => $user1->id]);

    $this->actingAs($user2, 'api')
        ->getJson("/api/v1/partners/listings/{$listing->id}")
        ->assertStatus(404);
}

Security Tests

// tests/Feature/Security/DocumentAuthorizationTest.php
public function test_user_cannot_access_other_users_documents()
{
    $user1 = User::factory()->create();
    $user2 = User::factory()->create();
    $doc = UserOpenai::factory()->create(['user_id' => $user1->id]);

    $this->actingAs($user2, 'api')
        ->postJson('/api/documents/get', ['id' => $doc->id])
        ->assertStatus(404);
}

Rate Limit Tests

// tests/Feature/RateLimitTest.php
public function test_starter_tier_has_60_requests_per_minute()
{
    $user = User::factory()->withPlan('starter')->create();

    for ($i = 0; $i < 60; $i++) {
        $this->actingAs($user, 'api')
            ->getJson('/api/v1/partners/profile')
            ->assertStatus(200);
    }

    $this->actingAs($user, 'api')
        ->getJson('/api/v1/partners/profile')
        ->assertStatus(429);
}

6. Rollback Plan

If issues arise after deployment:

  1. API Controllers: Can be reverted by removing controllers and routes will 404 (same as current state)
  2. Security Fixes:
  3. CSRF: Re-add dashboard/* exemption if legitimate flows break
  4. Session: Revert same_site to none if third-party integrations break
  5. Rate Limits: Revert to flat limits in RouteServiceProvider.php

Plan created by Claude Code on February 4, 2026