Skip to content
16 changes: 14 additions & 2 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
<!-- @EXTENDS: ../../.github/.github/copilot-instructions.md -->
<!-- INHERITANCE: Core Principles + Org Rules from parent, Laravel-specific rules below -->

<laravel-boost-guidelines>
=== foundation rules ===

# Laravel Boost Guidelines

The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
Laravel Boost guidelines are AI-optimized and generated by Laravel maintainers for this application.
These override generic Laravel guidelines when conflicts arise.

## Foundational Context

Expand All @@ -12,6 +16,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
- php - 8.4.12
- laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0
- laravel/sanctum (SANCTUM) - v4
- larastan/larastan (LARASTAN) - v3
- laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1
Expand Down Expand Up @@ -318,4 +323,11 @@ $pages = visit(['/', '/about', '/contact']);

$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
</code-snippet>
</laravel-boost-guidelines>

=== tests rules ===

## Test Enforcement

- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
</laravel-boost-guidelines>
14 changes: 14 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ SPDX-License-Identifier: CC0-1.0

Quick start guide for SecPal API development.

## ⚠️ Core Principles (READ FIRST)

**These principles are non-negotiable and are enforced in `.github/copilot-instructions.md`:**

1. **🎯 Quality First** - Clean before quick, maintainable before feature-complete
2. **πŸ§ͺ TDD** - Write failing test FIRST, then implement
3. **πŸ”„ DRY** - Check for existing code before writing new
4. **🧹 Clean Before Quick** - Refactor when you touch code
5. **πŸ‘€ Self Review Before Push** - Run all quality gates locally

**πŸ“‹ Quick Reminder Patterns:** See [`docs/COPILOT_REMINDER_PATTERNS.md`](./docs/COPILOT_REMINDER_PATTERNS.md) for prompts to keep Copilot aligned with these principles.

---

## Prerequisites

- PHP 8.4+
Expand Down
115 changes: 114 additions & 1 deletion app/Http/Controllers/AuthController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,24 @@

namespace App\Http\Controllers;

use App\Http\Requests\PasswordResetRequest;
use App\Http\Requests\PasswordResetRequestRequest;
use App\Http\Requests\TokenRequest;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;

class AuthController extends Controller
{
/**
* Password reset token expiry time in minutes.
*/
private const PASSWORD_RESET_TOKEN_EXPIRY_MINUTES = 60;

/**
* Generate a new API token for the user.
*
Expand Down Expand Up @@ -52,7 +61,10 @@ public function logout(Request $request): JsonResponse
{
/** @var User $user */
$user = $request->user();
$user->currentAccessToken()->delete();

/** @var \Laravel\Sanctum\PersonalAccessToken $token */
$token = $user->currentAccessToken();
$token->delete();

return response()->json([
'message' => 'Token revoked successfully.',
Expand Down Expand Up @@ -88,4 +100,105 @@ public function me(Request $request): JsonResponse
'email' => $user->email,
]);
}

/**
* Request a password reset email.
*
* Security: Always returns 200 to prevent email enumeration.
*/
public function passwordResetRequest(PasswordResetRequestRequest $request): JsonResponse
{
$validated = $request->validated();

$user = User::where('email', $validated['email'])->first();

if ($user) {
// Delete any existing tokens for this email
DB::table('password_reset_tokens')
->where('email', $user->email)
->delete();

// Generate secure token
$token = Str::random(64);

// Store hashed token
DB::table('password_reset_tokens')->insert([
'email' => $user->email,
'token' => Hash::make($token),
'created_at' => now(),
]);

// TODO: Send email notification with $token
// For now, we just store the token
}

// Always return same response to prevent email enumeration
return response()->json([
'message' => 'Password reset email sent if account exists',
]);
}

/**
* Reset password using token.
*/
public function passwordReset(PasswordResetRequest $request): JsonResponse
{
/** @var array{token: string, email: string, password: string} $validated */
$validated = $request->validated();

// Find user
$user = User::where('email', $validated['email'])->first();

if (! $user) {
return response()->json([
'message' => 'Invalid or expired reset token',
], 400);
}

// Get stored token record
/** @var object{email: string, token: string, created_at: string}|null $tokenRecord */
$tokenRecord = DB::table('password_reset_tokens')
->where('email', $validated['email'])
->first();

if (! $tokenRecord) {
return response()->json([
'message' => 'Invalid or expired reset token',
], 400);
}

// Check if token is expired
$createdAt = \Carbon\Carbon::parse($tokenRecord->created_at);
$minutesAgo = $createdAt->diffInMinutes(now());

if ($minutesAgo > self::PASSWORD_RESET_TOKEN_EXPIRY_MINUTES) {
DB::table('password_reset_tokens')
->where('email', $validated['email'])
->delete();

return response()->json([
'message' => 'Invalid or expired reset token',
], 400);
}

// Verify token
if (! Hash::check($validated['token'], $tokenRecord->token)) {
return response()->json([
'message' => 'Invalid or expired reset token',
], 400);
}

// Update password
$user->password = Hash::make($validated['password']);
$user->save();

// Delete used token (one-time use)
DB::table('password_reset_tokens')
->where('email', $validated['email'])
->delete();

return response()->json([
'message' => 'Password has been reset successfully',
]);
}
}
50 changes: 50 additions & 0 deletions app/Http/Requests/PasswordResetRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

// SPDX-FileCopyrightText: 2025 SecPal Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class PasswordResetRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}

/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'token' => ['required', 'string'],
'email' => ['required', 'email'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
];
}

/**
* Get custom validation error messages.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'token.required' => 'Reset token is required.',
'email.required' => 'Email address is required.',
'email.email' => 'Please provide a valid email address.',
'password.required' => 'Password is required.',
'password.min' => 'Password must be at least 8 characters.',
'password.confirmed' => 'Password confirmation does not match.',
];
}
}
44 changes: 44 additions & 0 deletions app/Http/Requests/PasswordResetRequestRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

// SPDX-FileCopyrightText: 2025 SecPal Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class PasswordResetRequestRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}

/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'email'],
];
}

/**
* Get custom validation error messages.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'email.required' => 'Email address is required.',
'email.email' => 'Please provide a valid email address.',
];
}
}
6 changes: 6 additions & 0 deletions app/Models/Person.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

/**
Expand All @@ -29,9 +30,14 @@
* @property \Illuminate\Support\Carbon $updated_at
* @property-write string|null $email_plain Transient plaintext email
* @property-write string|null $phone_plain Transient plaintext phone
*
* @method static \Database\Factories\PersonFactory factory($count = null, $state = [])
*/
class Person extends Model
{
/** @use HasFactory<\Database\Factories\PersonFactory> */
use HasFactory;

/**
* The table associated with the model.
*
Expand Down
49 changes: 3 additions & 46 deletions boost.json
Original file line number Diff line number Diff line change
@@ -1,48 +1,5 @@
{
"agents": ["copilot"],
"editors": ["vscode"],
"guidelines": [
{
"title": "SecPal Project Guidelines",
"rules": [
"This is the SecPal API - a security-focused personal data management system",
"All PRs must be < 400 lines for effective review (see DEVELOPMENT.md)",
"Use incremental development: Foundation β†’ Business Logic β†’ API β†’ Security",
"Always run ddev exec php artisan boost:update after major changes",
"Security is paramount - all PII must be encrypted at rest",
"Follow REUSE SPDX license compliance for all files",
"Use conventional commits format for all commit messages",
"English only for code, comments, and GitHub communication",
"All database operations must go through DDEV (PostgreSQL 15+)",
"PHPStan level max with baseline - no new errors allowed",
"Use Laravel Pint for code formatting (automatic via pre-commit)",
"Prefer feature tests over unit tests unless testing isolated logic",
"Use PEST test framework, not PHPUnit directly"
]
},
{
"title": "Architecture Decisions",
"rules": [
"Repository pattern for all database access",
"API-only application (no Blade views)",
"RESTful API design with Laravel Sanctum authentication",
"Use Spatie Laravel Permission for role-based access control",
"Tenant isolation enforced via middleware",
"Use UUIDs for all public-facing identifiers",
"All migrations must be idempotent and reversible"
]
},
{
"title": "Development Workflow",
"rules": [
"Always create feature branches from main",
"Run tests before pushing: ddev exec ./vendor/bin/pest",
"Pre-commit hooks run automatically (Pint, PHPStan, REUSE)",
"Update Boost after structural changes",
"Keep PRs focused - one feature/fix per PR",
"Request review before merging to main",
"If overwhelmed, reset and start fresh rather than accumulating complexity"
]
}
]
"agents": ["copilot"],
"editors": ["vscode"],
"guidelines": []
}
Loading