Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
Your mission is to implement an entire shop system based on the specifications im specs/*.
Distribute work to sub-agents. Keep the context of the main agent clean.
You must do in one go without stopping.
You must use team mode!
You must test everything via Pest (unit, and functional tests).
You must also additional simulate user behaviour using the Playwright MPC and confirm that all acceptance criterias are met.
If you find bugs, you must fix them.
The result is a perfect shop system. All requirements are perfectly implemented. All acceptance criterias are met, tested and confirmed by you.

Continuously keep track of the progress in specs/progress.md
Commit your progress after every relevant iteration with a meaningful message.

When implementation is fully done, then make a full review meeting with Playwright in Chrome and showcase all features (customer- and admin-side) to me. In case bugs appear, you must fix them all and restart the review meeting. Shop is running at http://shop.test/.

Don't re-use any existing implementation in another branch. Build it from scratch.

You are writing production PHP (Laravel) code.

STRICT CONSTRAINTS:

1. PHPStan Compliance
- Must pass PHPStan at max level.
- No mixed.
- Explicit return types everywhere.
- Fully typed properties.
- No dynamic properties.
- No suppressed errors.
- No relying on docblocks to hide real type problems.

2. Deptrac Compliance
- Respect architectural layers.
- No cross-layer violations.
- No circular dependencies.
- If a dependency is required, introduce an interface in the correct layer.
- Do not modify architecture unless explicitly instructed.

3. Pest Testing (Mandatory)
- Every feature must include automated tests.
- Include both unit and integration tests when appropriate.
- Cover success path and failure paths.
- Cover edge cases.
- Tests must be deterministic.
- Tests must validate behavior, not implementation details.

4. QA Self-Verification (Mandatory)
Before finalizing:
- List each acceptance criterion.
- Explicitly confirm how it is implemented.
- Explicitly confirm which test covers it.
- Validate edge cases.
- Validate negative paths.
- Ensure no undefined behavior exists.

5. Fresh Agent Review (Mandatory)

After implementation:

Step 1: A NEW agent instance must review the code.
The reviewer must:
- Ignore prior reasoning.
- Re-evaluate architecture.
- Re-evaluate PHPStan compliance.
- Re-evaluate Deptrac boundaries.
- Re-evaluate test coverage.
- Act as a strict senior reviewer.

Step 2: The reviewer must:
- Identify weaknesses.
- Identify overengineering.
- Identify missing edge cases.
- Identify architectural drift.
- Suggest concrete improvements.

Step 3:
If issues are found:
- Fix them.
- Run review again with another fresh agent.
- Repeat until no critical issues remain.

No feature is complete without independent review.
169 changes: 169 additions & 0 deletions app/Auth/CustomerUserProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
<?php

namespace App\Auth;

use App\Support\Tenant\CurrentStore;
use Closure;
use Illuminate\Auth\DatabaseUserProvider;
use Illuminate\Auth\GenericUser;
use Illuminate\Contracts\Auth\Authenticatable as UserContract;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Database\Query\Builder;

class CustomerUserProvider extends DatabaseUserProvider
{
public function __construct(
\Illuminate\Database\ConnectionInterface $connection,
\Illuminate\Contracts\Hashing\Hasher $hasher,
string $table,
private readonly Application $app,
) {
parent::__construct($connection, $hasher, $table);
}

public function retrieveById($identifier)
{
$user = $this->newScopedQuery()
->where('id', $identifier)
->first();

return $this->getGenericUser($user);
}

public function retrieveByToken($identifier, $token)
{
$user = $this->getGenericUser(
$this->newScopedQuery()->where('id', $identifier)->first(),
);

return $user && $user->getRememberToken() && hash_equals($user->getRememberToken(), (string) $token)
? $user
: null;
}

public function updateRememberToken(UserContract $user, $token): void
{
try {
$this->newScopedQuery()
->where($user->getAuthIdentifierName(), $user->getAuthIdentifier())
->update([$user->getRememberTokenName() => $token]);
} catch (\Throwable) {
// Customer remember tokens are optional in early schema iterations.
}
}

/**
* @param array<string, mixed> $credentials
*/
public function retrieveByCredentials(array $credentials)
{
$credentials = array_filter(
$credentials,
static fn (string $key): bool => ! str_contains($key, 'password'),
ARRAY_FILTER_USE_KEY,
);

if ($credentials === []) {
return null;
}

$query = $this->newScopedQuery();

foreach ($credentials as $key => $value) {
if (! is_string($key)) {
continue;
}

if (is_array($value) || $value instanceof Arrayable) {
$query->whereIn($key, $value);
} elseif ($value instanceof Closure) {
$value($query);
} else {
$query->where($key, $value);
}
}

return $this->getGenericUser($query->first());
}

/**
* @param array<string, mixed> $credentials
*/
public function rehashPasswordIfRequired(UserContract $user, array $credentials, bool $force = false): void
{
if (! is_string($credentials['password'] ?? null)) {
return;
}

$hashedPassword = $user->getAuthPassword();

if (! $this->hasher->needsRehash($hashedPassword) && ! $force) {
return;
}

$this->newScopedQuery()
->where($user->getAuthIdentifierName(), $user->getAuthIdentifier())
->update(['password_hash' => $this->hasher->make($credentials['password'])]);
}

protected function getGenericUser($user)
{
if ($user === null) {
return null;
}

$attributes = (array) $user;
$attributes['password'] = $attributes['password_hash'] ?? null;
$attributes['remember_token'] = $attributes['remember_token'] ?? null;

return new GenericUser($attributes);
}

private function newScopedQuery(): Builder
{
$query = $this->connection->table($this->table);
$storeId = $this->resolveStoreId();

if ($storeId === null) {
return $query->whereRaw('1 = 0');
}

return $query->where('store_id', $storeId);
}

private function resolveStoreId(): ?int
{
if ($this->app->bound(CurrentStore::class)) {
$store = $this->app->make(CurrentStore::class);

if ($store instanceof CurrentStore) {
return $store->id;
}
}

if (! $this->app->bound('current_store')) {
return null;
}

$store = $this->app->make('current_store');

if ($store instanceof CurrentStore) {
return $store->id;
}

if (is_array($store) && array_key_exists('id', $store)) {
$id = $store['id'];

if (is_int($id) || is_float($id) || is_string($id)) {
return (int) $id;
}
}

if (is_object($store) && isset($store->id) && (is_int($store->id) || is_float($store->id) || is_string($store->id))) {
return (int) $store->id;
}

return null;
}
}
130 changes: 130 additions & 0 deletions app/Auth/Passwords/StoreScopedTokenRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<?php

declare(strict_types=1);

namespace App\Auth\Passwords;

use Illuminate\Auth\Passwords\DatabaseTokenRepository;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use Illuminate\Contracts\Hashing\Hasher as HasherContract;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\Carbon;
use RuntimeException;

final class StoreScopedTokenRepository extends DatabaseTokenRepository
{
public function __construct(
ConnectionInterface $connection,
HasherContract $hasher,
string $table,
string $hashKey,
int $expires = 3600,
int $throttle = 60,
) {
parent::__construct(
$connection,
$hasher,
$table,
$hashKey,
$expires,
$throttle,
);
}

public function create(CanResetPasswordContract $user): string
{
$email = $user->getEmailForPasswordReset();

$this->deleteExisting($user);

$token = $this->createNewToken();

$this->getTable()->insert($this->buildPayload($email, $token, $this->resolveStoreId($user)));

return $token;
}

protected function deleteExisting(CanResetPasswordContract $user): int
{
return $this->getTable()
->where('email', $user->getEmailForPasswordReset())
->where('store_id', $this->resolveStoreId($user))
->delete();
}

/**
* @return array{email: string, store_id: int, token: string, created_at: Carbon}
*/
protected function buildPayload(string $email, #[\SensitiveParameter] string $token, int $storeId): array
{
return [
'email' => $email,
'store_id' => $storeId,
'token' => $this->hasher->make($token),
'created_at' => new Carbon,
];
}

public function exists(CanResetPasswordContract $user, #[\SensitiveParameter] $token): bool
{
if (! is_string($token) || $token === '') {
return false;
}

$record = $this->recordForUser($user);

if ($record === null) {
return false;
}

return ! $this->tokenExpired($record['created_at'])
&& $this->hasher->check($token, $record['token']);
}

public function recentlyCreatedToken(CanResetPasswordContract $user): bool
{
$record = $this->recordForUser($user);

return $record !== null
&& $this->tokenRecentlyCreated($record['created_at']);
}

/**
* @return array{created_at: string, token: string}|null
*/
private function recordForUser(CanResetPasswordContract $user): ?array
{
$record = $this->getTable()
->where('email', $user->getEmailForPasswordReset())
->where('store_id', $this->resolveStoreId($user))
->first();

if ($record === null) {
return null;
}

$data = (array) $record;
$createdAt = $data['created_at'] ?? null;
$hashedToken = $data['token'] ?? null;

if (! is_string($createdAt) || ! is_string($hashedToken)) {
return null;
}

return [
'created_at' => $createdAt,
'token' => $hashedToken,
];
}

private function resolveStoreId(CanResetPasswordContract $user): int
{
$storeId = $user->store_id ?? null;

if (! is_int($storeId) && ! is_float($storeId) && ! is_string($storeId)) {
throw new RuntimeException('Unable to determine store id for customer password reset token.');
}

return (int) $storeId;
}
}
Loading
Loading