# AllGifted Math API — Code Audit

**Audited:** 2026-05-08
**Repo:** `C:\allgifted\mathapi11v2` (branch `master`)
**Scope:** Read-only static review. No code modified, no migrations or tests run.

Severity scheme: **[CRITICAL]** = exploit / data loss possible now · **[HIGH]** = serious functional or security defect · **[MEDIUM]** = correctness or maintainability problem · **[LOW]** = housekeeping.

---

## 1. Architecture

| | |
|---|---|
| PHP | 8.2.28 |
| Laravel | 11.46.0 (latest 11.x; 12.x available) |
| Auth | Sanctum 4.2 (personal access tokens) |
| Frontend in repo | Livewire 3.6 + Blade admin UI |
| Stripe SDK | stripe/stripe-php 18.1 (current 20.x) |
| AI | `openai-php/laravel` 0.16 — but recent commits say "change to anthropic generation". Both `OPENAI_API_KEY` and `ANTHROPIC_API_KEY` are in `.env`; no `anthropic-php/sdk` package installed |
| SMS | twilio/sdk 6.0 (current 8.x) |
| DB | MySQL (`api` database) |
| Queue | `database` driver |
| Cache | `file` driver |

Layout largely follows Laravel convention: `app/Http/Controllers`, `app/Models`, `app/Services`, `app/Jobs`, `app/Http/Middleware`, `routes/api.php`, `routes/web.php`. Deviations:

- **[HIGH]** `resources/views/admin/questions/mathapi11v2/` — a copy of the entire project (`artisan`, `composer.json`, SQL dumps, `auth0.exe`, **`.env.dev`, `.env.save`, `.env.save.1`, `.env.example`** with real DB and mail passwords) is checked into git under `resources/views`. This bloats the repo and exposes credentials. Six `.sql` files (`20201006.sql` … `20251002.sql`) are full DB backups.
- **[MEDIUM]** Root contains stray artefacts: `auth0.exe` (50 MB CLI binary), `api-backup-2025-09-30.sql` (13 MB), `Host`, `origin)`, `files('favicons')`, `allFiles(favicons))'`, `er@DESKTOP-PNL3EFM.(none)> Date:   Sun Aug 24 18:55:03 2025 +1000      update all to server` — the latter looks like a shell paste accident saved as a 16 KB file.
- **[LOW]** Both `app/Models/Track_User.php` and `TrackUser.php`, both `Course_Track.php` and `Track_Track.php`, both `House_Track.php` and `HouseTrack…` — naming is inconsistent (PascalCase vs Snake_Case). 69 model files total, several look like duplicates/leftovers.
- **[LOW]** `auth.php.save`, `apServiceProviderp.php` in `config/` — typo-named/leftover files.
- **[LOW]** `gulpfile.js` alongside Vite — legacy.
- **[LOW]** `app/Http/routes.php` is a zero-byte stub.

---

## 2. Routes & controllers

`php artisan route:list` totals: **239 routes** (web + api).

**API routes (27):**
- `api.php` declares 3 unauthenticated and 24 inside `auth:sanctum`.
- The route list shows only **3 truly public** API endpoints: `api/auth/request-otp`, `api/auth/verify-otp`, `api/stripe/webhook`. The "public" `api/diagnostic/hint` declared at `routes/api.php:35` is in fact protected because `DiagnosticController::__construct()` (line 37) applies `auth:sanctum` itself. **[LOW]** Comment lies; rely on real middleware.
- **[HIGH]** **No throttle middleware on any API route.** OTP endpoints (`/api/auth/request-otp`, `/api/auth/verify-otp`) have no Laravel `throttle` and rely solely on `OTPService::throttle()` (a 30-second per-user gate). With Twilio billing per SMS, an attacker can iterate through phone numbers and burn the SMS budget; OTP can also be brute-forced on `verify-otp` since 6-digit code only requires 1M attempts and there is no rate limit.
- **[HIGH]** The closures in `routes/web.php`:
  - `/setup-storage` — public, creates directories and runs `app('files')->link(...)`. Anyone on the internet can call it. Idempotent today but unnecessary attack surface.
  - `/media/{path}` — public proxy for `Storage::disk('public')`. Already public via the `public/storage` symlink, so duplicative; sets `Access-Control-Allow-Origin: *` unconditionally. **[MEDIUM]**
- **[MEDIUM]** The legacy block in `routes/api.php:108-167` is commented out, but the controllers (`UserController`, `CourseController`, `QuizController`, `LogController`, etc.) referenced are still present, fully functional, and not feature-flagged. ~30 "legacy" route handlers are orphaned dead code.
- **[LOW]** Inline closures in routes (`/me`, `/setup-storage`, `/media/...`) bypass controller testability.
- **[LOW]** `apiResource('users', UserController::class)` in legacy block exposes destroy/update without policy guard, were it re-enabled.

**Web routes (212):** all `/admin/*` correctly behind `auth` + `admin` middleware; QA group nests further `qa` and per-permission middleware (`qa:qa_approve_p2p3` etc.). Reasonable.

---

## 3. Models & DB

- **Migration files:** 87 (oldest 2014, newest 2025-12-23). 68 `Schema::create` statements → ~68 distinct tables. Recent ones add diagnostic/lives/Stripe surface (`assessment_sessions`, `attempt_ledger`, `lives_transactions`, `user_lives`, `subscription_plans`, `feature_limits`, `partners`, `diagnostic_sessions`, `user_field_levels`, `user_track_levels`, `user_skill_levels`).
- **FK declarations:** 120 `->foreign(...)` + 3 `->constrained(...)`.
- **Indexes:** 58 explicit `->index(...)` calls. 66 `onDelete`/`cascadeOnDelete`.
- **[CRITICAL]** **Zero soft deletes anywhere in the model layer** (`SoftDeletes` trait absent). Hard deletes on a payment/learning system mean cancellations and life transactions cannot be reconstructed; FK cascades will silently propagate. Combined with no audit log, cancellation disputes cannot be defended.
- **[HIGH]** **Mass-assignment exposure on `User`:** `app/Models/User.php:37-77` lists `is_admin`, `role_id`, `subscription_plan_id`, `stripe_customer_id`, `stripe_subscription_id`, `subscription_start_date`, `subscription_end_date`, `lives`, `access_type`, `partner_verified`, `email_verified` all as `$fillable`. Any controller doing `User::update($request->all())` or `->fill($request->validated())` with naive validation can let a user privilege-escalate or extend their subscription. Spot-checked: `UserController::inlineUpdate` and admin `update` passes through validated arrays — auditing them all is required.
- **[HIGH]** `Quiz` model uses `$guarded = []` (`app/Models/Quiz.php`) — fully open to mass assignment.
- **[MEDIUM]** **N+1 risk and missing indexes:** filterable columns repeatedly used in queries that I could not confirm have indexes from migration text alone:
  - `users.stripe_customer_id` (looked up in every webhook); `users.phone_number`, `users.role_id`, `users.partner_id`, `users.subscription_plan_id`.
  - `house_role_user.user_id`, `house_role_user.house_id`, `house_role_user.role_id`, `house_role_user.payment_status` — used as composite key in OTPController.
  - `assessment_sessions.user_id` + `status` + `completed_at` (`DiagnosticController::start` does `where user_id, status='completed', latest('completed_at')`).
  - `lives_transactions.user_id`.
  - `subscription_plans.stripe_price_id`, `subscription_plans.plan_code`.
  - `attempt_ledger` is hit on every answer submission.
- **[MEDIUM]** Old migrations (2014-era) likely use the long string defaults; recent ones add columns piecemeal. `update_users_table` migrations stack ad-hoc fields (`add_otp_fields`, `add_lives_to_users_table`, `add_kiasu_path_tracking_to_users`, `add_partner_fields_to_users_table`, `add_stripe_fields_to_users_table`, `add_role_id_to_users_table`, `add_email_verified_at_to_users_table`). The `users` table now has 30+ unrelated columns, complicating indexing strategy.
- **[LOW]** Suspicious nullables: `users.maxile_level`, `users.lives`, `users.subscription_plan_id`, `users.access_type` — booleans-by-string (`'free'`, `'premium'`, `'active'`, `'inactive'`, `'pending'`) without an enum; status checks done via plain strings everywhere (e.g. `User::isPremiumPlan()` matches `plan_code === 'premium'`).
- **[LOW]** `User::sendHighMaxileNotification()` (`User.php:803`) hardcodes `pam@allgifted.com`/`japher@allgifted.com`/`kang@allgifted.com` and uses `$message->setBody(...)` (deprecated in Symfony Mailer / Laravel 11; the model still expects SwiftMailer behaviour).

---

## 4. Auth & security

**Driver:** `laravel/sanctum` 4.2; `auth.php` defines the `api` guard as `sanctum` (`config/auth.php:28-31`). The repo also still has `config/auth0.php`, `config/laravel-auth0.php`, `.auth0.api.json`, `.auth0.app.json`, `auth0.exe`, and `app/Http/Middleware/Auth0JWTMiddleware.php`/`CheckJWT.php` — Auth0 wiring is dead but not removed. **[LOW]**

**Token expiry:** `config/sanctum.php:20` — `'expiration' => null`. **[HIGH]** Sanctum personal access tokens never expire. Once issued (via `User::createToken('login')` in `OTPController`) the token is valid forever; lost tokens cannot be invalidated short of manual revocation.

**Password rules:** Authentication is OTP-only (`OTPController::sendOtp` → `OTPService::issue`). No password set/rotation flow is exposed to end users. The `User` model still includes `password` in `$fillable` and `$hidden`; the `password_reset_tokens` table is migrated but unused. **[MEDIUM]** Auth surface is single-factor (an OTP delivered to one of two channels); attackers who SIM-swap or phish either can take over fully.

**OTP code is 6 digits** (`OTPController::verifyOtp` rule `size:6`). **[HIGH]** With **no rate limiter on `/api/auth/verify-otp`**, brute force is feasible — 1M codes, no lockout. The user-side `OTPService::throttle()` only gates *sending*. `OTPService` should also lock the account after N bad attempts; I did not find such a counter.

**CORS:** `config/cors.php`:
```php
'paths' => ['api/*', 'sanctum/csrf-cookie', 'images/*', 'storage/*'],
'allowed_methods' => ['*'],
'allowed_origins' => ['*'],
'allowed_headers' => ['*'],
'supports_credentials' => true,
```
**[CRITICAL]** `allowed_origins = ['*']` with `supports_credentials = true` is a misconfiguration. Browsers refuse the literal `*` with credentials, but Laravel's `HandleCors` reflects the request's `Origin` when this combo is set (Symfony nelmio behaviour) — meaning **any origin can issue authenticated cross-site requests** with cookies/Authorization. `media/{path}` and `setup-storage` also send `Access-Control-Allow-Origin: *` directly via headers.

**Sanctum stateful domains** (`.env`): `localhost,localhost:3000,localhost:4200,127.0.0.1,127.0.0.1:8000` — only dev origins. Production overrides are not visible.

**Secrets:**
- **[CRITICAL]** `.env` (gitignored, fine) but **`.env.production` was committed in commit `ff91d0a` (2024-07-14)** and **`.env.dev`/`.env.save`/`.env.save.1` are still tracked** under `resources/views/admin/questions/mathapi11v2/`. Visible secrets in git history include:
  - DB password `<redacted; rotated 2026-05-08; see Phase 0 rotation list>`
  - Auth0 client secret `<redacted; rotated 2026-05-08; see Phase 0 rotation list>`
  - Stripe `<redacted; rotated 2026-05-08; see Phase 0 rotation list>`
  - Mail app password `<redacted; rotated 2026-05-08; see Phase 0 rotation list>`
  - Older committed `<redacted; rotated 2026-05-08; see Phase 0 rotation list>` and `<redacted; rotated 2026-05-08; see Phase 0 rotation list>` mail/DB passwords.
  These are now public history. **All of them must be rotated** even after deletion; `git filter-repo`/BFG cannot undo what's already been pulled.
- **[CRITICAL]** **Hardcoded Stripe secret key inside `app/Services/LivesPurchaseService.php:20`:**
  ```php
  $this->stripe = new StripeClient("<redacted; rotated 2026-05-08; see Phase 0 rotation list>");
  ```
  Bypasses `config('services.stripe.secret')` and ships the key in source. (Same key as the leaked `.env.production`.)
- **[HIGH]** Live secrets currently in `.env` (test mode keys, but Twilio SID `AC2b7bcbdaaa…` and Twilio auth token are *real billable credentials*; OpenAI and Anthropic API keys are also real and full-access). With `APP_DEBUG=true` and `APP_ENV=local`, Whoops will leak environment variables on any unhandled exception in production if this `.env` shipped.

**`.env.example` completeness:** **[MEDIUM]** No `.env.example` exists at the repo root. A new contributor cannot bootstrap. The only `.env.example` in the repo is the stale one buried under `resources/views/admin/questions/mathapi11v2/.env.example`.

**Other:**
- `APP_DEBUG=true` and `APP_ENV=local` in the only checked-in env. **[HIGH]** if this gets deployed verbatim. Whoops is a `require-dev` package, but Laravel's debug pages still leak DB credentials, stack traces, and request data when `APP_DEBUG=true`.
- `SESSION_SECURE_COOKIE=false` is OK locally, must be `true` in prod.
- `validateCsrfTokens(except: ['api/*'])` in `bootstrap/app.php` is correct for token APIs.
- **[MEDIUM]** `Authenticate` middleware (`app/Http/Middleware/Authenticate.php`) is a hand-rolled clone of Laravel's stock middleware that bypasses `redirectTo()` callbacks — fine but unnecessary.

---

## 5. Stripe integration

`app/Http/Controllers/StripeWebhookController.php` is the entry point at `POST /api/stripe/webhook`.

- **[CRITICAL]** **Webhook signature verification is disabled in code:**
  ```php
  // Temporarily disabled for testing - re-enable later
  // $event = Webhook::constructEvent($payload, $sigHeader, ...);
  $event = json_decode($payload);
  ```
  (`StripeWebhookController.php:33-42`). Anyone on the internet can `POST` a forged `payment_intent.succeeded` event with `metadata.type = "lives_purchase"` and any `user_id`/`lives` to grant themselves unlimited lives, or `metadata.type = "initial_subscription_payment"` to flip premium subscription state via `SubscriptionService::handleSubscriptionUpdate`. **This is a remote-attacker-grants-self-paid-features exploit.**
- **[CRITICAL]** **No idempotency tracking.** Stripe retries webhooks on 5xx and on partial deliveries. `processSuccessfulPurchase` does `$user->increment('lives', $packageData['lives'])` and `LivesPurchase::create(...)` (the `LivesPurchase` model **does not exist** — only `LivesTransaction` and `UserLives` are in `app/Models/`, so this code path raises `Class not found`). For lives, lack of idempotency means a retried webhook double-credits. For `payment_intent.succeeded → initial_subscription_payment` it can create duplicate Stripe subscriptions.
- **[HIGH]** Webhook controller's inline `case 'payment_intent.succeeded':` references **`User::find(...)`** without importing `App\Models\User` (line 52). This will fatal at runtime as `App\Http\Controllers\User` (class not found).
- **[HIGH]** Webhook returns `400` with the raw exception message (`return response()->json(['error' => $e->getMessage()], 400)`) — useful for attacker reconnaissance, plus 4xx tells Stripe **not** to retry. So genuine transient failures (DB connection blip during `subscription.updated`) silently drop subscription state.
- **[MEDIUM]** Currency is hardcoded `'sgd'` in `LivesPurchaseService` and `SubscriptionService` (correct for Singapore, but no validation against plan currency). Lives package amounts are stored in cents (`'price' => 99`) but `SubscriptionService::createPremiumSubscription` does `'amount' => $plan->price * 100` — i.e. the `subscription_plans.price` column is in dollars while lives prices are cents. Inconsistent unit-of-account is a known footgun.
- **[MEDIUM]** Refund / chargeback handling: `handleSubscriptionCancelled` only sets `cancelled_at`; `handlePaymentFailed` logs and TODOs an email (`SubscriptionService.php:186`). No `charge.refunded`, no `charge.dispute.created`, no `customer.subscription.trial_will_end` handler.
- **[MEDIUM]** `handleSubscriptionUpdate` treats `'incomplete'` as active (`in_array($subscription->status, ['active','trialing','incomplete'])`). Per Stripe, `incomplete` means the first invoice failed and the customer hasn't paid. Granting access on `incomplete` is exploitable: a customer can put a card that 3DS-fails and still flip to premium.
- **[LOW]** Two separate `StripeClient` constructions: `StripeService`, `SubscriptionService`, `LivesPurchaseService`, and the webhook controller each `new StripeClient(...)`. No shared singleton.
- **[LOW]** `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` both present, but the codebase only has `openai-php/laravel`. Comment in commit log says "change to anthropic generation instead of openai" — code is mid-migration.

---

## 6. Adaptive learning logic (IRT / Maxile)

Lives in:
- `app/Services/AdaptiveLevelService.php` (562 lines) — narrative IRT prose in the docblock, but the implementation is rule-based: `getNextLevelUp` / `getNextLevelDown` are SQL `min`/`max` lookups, not a probabilistic update. There is no `theta`, no logistic 1PL/2PL/3PL function, no information-function calculation. The docblock claims IRT; the code does not implement it.
- `app/Services/MaxileService.php` (847 lines) — averages and rounding. `calculateSkillMaxile` averages `levels.end_maxile_level` over all public tracks for a skill; `calculateUserMaxile` averages all field maxiles. Pure arithmetic.
- `app/Services/DiagnosticService.php` (360 lines).
- `app/Services/KiasuPathService.php` (357 lines).
- `app/Services/UserProgressService.php` (686 lines).

**Findings:**

- **[HIGH]** **No tests exist for any of the math.** `tests/Feature/ExampleTest.php` and `tests/Unit/ExampleTest.php` are the default Laravel scaffolds. Maxile is the product's core IP and is entirely unvalidated.
- **[HIGH]** Edge cases I could spot statically:
  - `MaxileService::calculateTrackMaxile` returns `0` if the user hasn't completed any skill in the track (line 73). `calculateUserMaxile` returns `0` for users with no `user_field_levels` rows (line 156). A new user therefore reads "User Maxile = 0" instead of a starting level — frontend has to special-case this.
  - `AdaptiveLevelService::getDefaultMaxileLevel()` throws if no public `levels` exist (`status_id = 3`). A misconfigured admin (e.g. all levels set to draft) takes down diagnostics.
  - `User::calculateUserMaxile($test)` (`User.php:760`) divides by `max($totalHighest, 1)` — guards against `/0` — but uses `\App\Track::whereLevelId(...)` (the legacy non-namespaced model) which no longer exists at `App\Track`. **Runtime fatal** if reached.
  - `accuracy()` (`User.php:740`) does `sum('correct') / $totalAnswered * 100`, with `$totalAnswered = sum('question_answered')` — `question_answered` is a boolean, summing it gives count of "any non-null answered", not unique attempts. Numerator/denominator are inconsistent definitions of "answered".
  - 30-day diagnostic cooldown (`DiagnosticController::start:70`) uses `diffInDays(now())` of a DateTime that may be a string (`is_string($completedAt)`); the absolute-value behaviour of `diffInDays` (depending on Carbon version) means a user whose `completed_at` is *in the future* (clock skew) gets `< 30` and is locked out indefinitely.
  - Floor on Maxile: `User::calculateUserMaxile` does `max($maxile, $this->maxile_level)` so the user's recorded Maxile is monotonically non-decreasing — meaning a user who regresses cannot have their Maxile reduced. This may be intentional (anti-frustration), but it's not documented and conflicts with the IRT premise.

---

## 7. Lives / hearts system

`app/Services/LiveService.php` (385 lines) and `app/Services/LivesPurchaseService.php` (81 lines), `app/Http/Controllers/LivesController.php`.

- Hearts regen: `LiveService::HOURS_PER_LIFE = 5`. `regenerateLives()` reads `users.lives_restore_queue` (a JSON column of timestamps), pops any `<= now()`, and `$user->update([...])`.
- Premium bypass: `LiveService::hasUnlimitedLives($user)` → `!AccessControlService::hasLivesSystem($user)`. SIMBA and Premium plans return `false` (no lives system), so they bypass; free / partner / school go through it.
- Max lives: from `config/partners.php` `default.lives.max_lives = 5`. Partner overrides via `array_merge_recursive` (note: `array_merge_recursive` collapses nested arrays into `[true, false]` lists if both keys exist — `lives.enabled` from default + telco override returns `[true, true]`, not `true`. **[MEDIUM]** Should be `array_replace_recursive`).

**Findings:**

- **[HIGH]** **Race condition on concurrent requests.** `regenerateLives` does `read $user->lives_restore_queue` → in-memory mutate → `$user->update(...)`. `deductLife` does `decrement('lives')` then a separate `update($user, ['lives_restore_queue' => ...])`. Two concurrent answer submissions:
  1. Both load `$user` with `lives = 5`, queue empty.
  2. Both call `decrement('lives', 1)` → `lives = 3` (atomic at MySQL, OK).
  3. Both serialize their queue separately and write — last write wins, so the second life's restore time is lost.
  Net effect: lives are deducted but never regenerate, or regenerate at the wrong time. No `DB::transaction()` or `lockForUpdate` anywhere in `LiveService`.
- **[HIGH]** `LiveService::deductLife` checks `if ($user->lives < $amount) return false;` then `$user->decrement('lives', $amount)`. If two requests race past the check with lives=1, both decrement and lives goes to -1 (unsigned column will throw, signed will allow negative).
- **[MEDIUM]** `LivesPurchaseService::processSuccessfulPurchase` increments `$user->lives` directly with no cap against `max_lives`. A 10-pack purchased by a user already at 4 takes them to 14, even though the regen ceiling is 5. (Maybe intentional, but inconsistent with the rest of the system.)
- **[MEDIUM]** `LiveService::calculateLivesLeft` calls `regenerateLives()` then `$user->refresh()`. Every call to `getLivesInfo()` hits the DB twice. The home/profile endpoints will call this on every request → high write load if many users idle on the lives screen.
- **[CRITICAL]** **Webhook can be forged to grant unlimited lives.** See §5: signature off + `payment_intent.succeeded` `metadata.type = lives_purchase` writes directly via `$user->increment('lives', metadata.lives)` — attacker controls `lives` integer.
- **[HIGH]** `LivesPurchaseService::processSuccessfulPurchase` references `App\Models\LivesPurchase` which **does not exist**. So the webhook path that *would* grant lives via the service crashes on `LivesPurchase::create(...)`. The forged-webhook path in the controller body succeeds (it uses `$user->increment` directly). The legitimate path is broken.

---

## 8. SIMBA telco partnership hooks

- `config/partners.php` has only `default`, `telco.default`, `schools.default` blocks — no `telco.simba` config.
- `app/Models/Partner.php` and `database/migrations/2025_08_30_152709_create_partners_table.php` define a partners table with `code`, `name`, `phone_prefixes` (array cast), `api_key`, `status_sync_url`, `verification_required`. So the data model is there.
- `app/Services/PartnerService.php` has `identifyPartner($phoneNumber)` (matches phone_prefix), `verifySubscriber()` (calls `$partner->status_sync_url` with bearer `$partner->api_key`), `createPartnerUser()`. **[MEDIUM]** `PartnerService` is **not called from any controller in this codebase** (grep finds no usage). The partner table & service are scaffolding without callers.
- **[CRITICAL]** **The actual SIMBA detection and provisioning is a stub.** `OTPController::checkTelcoSubscriber` (line 151):
  ```php
  return str_starts_with($contact, '+659') ? [
      'phone'         => $contact,
      'provider'      => 'simba',
      'subscriber_id' => 'SIMBA_' . time() . '_' . rand(1000, 9999),
      'is_subscriber' => true
  ] : null;
  ```
  Any Singaporean mobile number (`+659…`) is treated as a SIMBA subscriber and given a fabricated `subscriber_id`. There is no API call to SIMBA, no signature verification, no billing reconciliation. `OTPController::createMathEnrollment` then writes a row to `house_role_user` with `transaction_id = 'TELCO_SIMBA_' . time()`, `payment_status = 'ACTIVE_TELCO'`, `amount_paid = 3.00` — fabricated billing.
- **[HIGH]** **No reconciliation worker, no scheduled job.** No `app/Console/Commands/*Simba*`, no scheduled task in `app/Console/Kernel.php` to confirm subscribers are still paying. Once provisioned, `payment_status = 'ACTIVE_TELCO'` persists; if SIMBA suspends or refunds the user, AllGifted has no way to know.
- **[MEDIUM]** `Partner::api_key` stored plaintext in DB. No encrypted cast (`'api_key' => 'encrypted'`).
- **[MEDIUM]** Identity linkage: `users.phone_number` is the only key. No `partner_subscriber_id` lookup uniqueness — two users could end up bound to the same SIMBA ID.

---

## 9. Tests

- **PHPUnit 11.1+** declared in `composer.json` (currently 11.5.38, vulnerable per §11).
- **Total test files:** 4 — `tests/ExampleTest.php`, `tests/TestCase.php`, `tests/Feature/ExampleTest.php`, `tests/Unit/ExampleTest.php`.
- **Effective coverage:** ~0%. Both example tests are stock Laravel scaffolds (`assertTrue(true)` and `$this->get('/')->assertStatus(200)`).
- **[CRITICAL]** **No tests for any business logic** — no Maxile arithmetic test, no IRT/level-up rule test, no Stripe webhook handler test, no OTP brute-force regression test, no lives race-condition test, no diagnostic cooldown test, no subscription-update state-machine test. For a payment- and learning-engine-driven product, this is the largest single area of risk.
- **[HIGH]** Critical untested paths (top priorities if writing tests from scratch):
  1. `StripeWebhookController::handle` for each event type, including idempotency.
  2. `LiveService::deductLife` / `regenerateLives` under concurrency.
  3. `MaxileService::calculate{User,Field,Track,Skill}Maxile` boundary cases (no attempts, all wrong, all right, ceiling/floor).
  4. `OTPController::sendOtp` / `verifyOtp` rate-limit and brute-force.
  5. `AccessControlService` decision matrix per plan × partner.
  6. `SubscriptionService::handleSubscriptionUpdate` for all Stripe statuses.

---

## 10. Performance

- **N+1 candidates:**
  - `MaxileService::updateMaxilesForPassedSkill` and `updateMaxilesForPassedSkills` (lines 323, 530) iterate skills; quick read suggests per-skill DB calls inside the loop. Worth checking with telescope/laravel-debugbar.
  - `User::storefieldmaxile` (`User.php:389`) does a `whereFieldId(...)->whereMonthAchieved(...)` then a `sync(...)` — fine for 1 user but if called in a loop will pile up queries.
  - `User::tracksPassed()` returns a `belongsToMany` chain and is reused multiple times in `User::calculateUserMaxile`.
  - `DashboardController`/`HomeController` rebuild profile data from many Eloquent calls without `with([...])`.
  - 26 `Cache::` references vs 151 `with(` calls in controllers — eager loading is used, but cache use is sparse.
- **Missing caching:**
  - `AdaptiveLevelService::preloadFieldLevels` is a hand-rolled per-request cache. `getDefaultMaxileLevel()`, `getMinMaxileLevel()`, `getMaxMaxileLevel()` re-query `levels` on every call without `Cache::remember`.
  - `User::hasPermission` does a `permission_role` + `permissions` join on every check. Recommend `cache()->remember("user.{$id}.perms", ...)`.
  - `subscription_plans`, `feature_limits`, `levels`, `tracks`, and `partners` are reference-data tables that change rarely and are read per-request.
- **Slow query candidates:**
  - `DashboardController` and `LoadController` are large (700+ lines each) and unaudited. The `Dashboard::index` view is admin-only so less critical.
  - `User::scopeProfile` eager-loads 6 levels of relations — likely heavy.
  - The legacy `LoadQuestions.php` / `LoadSecondary.php` controllers may execute large `whereIn`.
- **Queues:**
  - `app/Jobs/` has `GenerateQuestionImagesJob`, `ProcessQuestionAssignment`. `QUEUE_CONNECTION=database` — fine for low volume, will become bottleneck at scale; recommend Redis.
  - `app/Console/Commands/ResetUserLives.php` exists but I did not find a scheduled binding in `app/Console/Kernel.php`. **[MEDIUM]** Lives don't actually need a cron because regen is computed on read, but worth confirming this is intentional.
- **Cache driver = `file`, session driver = `cookie`.** **[MEDIUM]** A multi-server deploy with file-cache will drift; OTP throttling stored in cache won't be shared across web nodes.

---

## 11. Tech debt

- **TODO/FIXME/HACK/XXX count:** **1** (`app/Services/SubscriptionService.php:186` — `// TODO: Send email notification to user`).
- **[HIGH]** **Outdated direct dependencies (`composer outdated --direct`):**
  | Package | Installed | Latest | Notes |
  |---|---|---|---|
  | laravel/framework | 11.46.0 | 12.58.0 | major |
  | laravel/sanctum | 4.2.0 | 4.3.2 | minor |
  | laravel/pint | 1.24.0 | 1.29.1 | dev |
  | laravel/sail | 1.45.0 | 1.58.0 | dev |
  | laravel/tinker | 2.10.1 | 3.0.2 | major |
  | livewire/livewire | 3.6.4 | 4.3.0 | major |
  | nesbot/carbon | 2.73.0 | 3.11.4 | major (Laravel 11 still supports v2; required by `intervention/image`) |
  | openai-php/laravel | 0.16.0 | 0.19.1 | beta SDK |
  | phpunit/phpunit | 11.5.38 | 11.5.55 | minor |
  | stripe/stripe-php | 18.1.0 | 20.1.0 | two majors behind |
  | twilio/sdk | 6.0.0 | 8.11.6 | two majors behind |
  | symfony/css-selector | 3.4.47 | 7.4.9 | dev |
  | symfony/dom-crawler | 3.4.47 | 7.4.8 | dev |
  | intervention/image | 3.11.4 | 3.11.8 | patch |
  | nunomaduro/collision | 8.8.2 | 8.9.4 | dev |
- **[CRITICAL]** **`composer audit` reports 6 advisories across 5 packages:**
  | Package | Severity | CVE | Issue |
  |---|---|---|---|
  | symfony/http-foundation | high | CVE-2025-64500 | Incorrect parsing of `PATH_INFO` → authorization bypass |
  | phpunit/phpunit | high | CVE-2026-24765 | Unsafe deserialization in PHPT coverage handling (dev-only) |
  | league/commonmark | medium | CVE-2026-33347 | Embed extension `allowed_domains` bypass |
  | league/commonmark | medium | CVE-2026-30838 | DisallowedRawHtml extension whitespace bypass |
  | psy/psysh | medium | CVE-2026-25129 | Local privilege escalation via CWD `.psysh.php` autoload (dev-only) |
  | symfony/process | medium | CVE-2026-24739 | Incorrect arg escaping under MSYS2/Git Bash → destructive ops on Windows |
  The Symfony HTTP-Foundation advisory is the most concerning because it can bypass route auth.
- **[MEDIUM]** Deprecated calls / dead code:
  - `User::sendHighMaxileNotification` uses Symfony Mailer 5/6's `setBody()` that no longer exists in 7.
  - `User::calculateUserMaxile` references `\App\Track` and `\App\Level` (pre-Laravel 8 namespaces — `app/Models/Track.php` is the correct location); these calls fatal at runtime.
  - `app/Models/HasRoles.php` and `RecordLog.php` traits exist alongside the typical Laravel locations — auditing them is out of scope but they're worth a once-over.
  - `gulpfile.js`, `populate_videos.php`, `test.php`, `rename_*.bat`, `auth0.exe` — root-level junk.
  - `apServiceProviderp.php` and `auth.php.save` in `config/`.
- **[LOW]** Multiple controllers exceed 500 lines (`UserController`, `DashboardController`, `LoadController`, `MaxileService`, `UserProgressService`); split into smaller services for testability.

---

# Top 10 Risks Ranked

| # | Severity | Risk | Where | Why |
|---|---|---|---|---|
| 1 | **CRITICAL** | Stripe webhook signature verification disabled — anyone can grant themselves premium and unlimited lives | `app/Http/Controllers/StripeWebhookController.php:33-42` | The check is commented out with "Temporarily disabled for testing"; `json_decode($payload)` accepts forged events that `LivesPurchaseService` and `SubscriptionService` then trust. Direct path to unauthorised paid features and free in-app currency. |
| 2 | **CRITICAL** | Real production secrets committed to git history (and a Stripe secret key hardcoded in source) | `.env.production` (commit `ff91d0a`); `resources/views/admin/questions/mathapi11v2/.env.dev`, `.env.save`; `app/Services/LivesPurchaseService.php:20` | DB password, Auth0 client secret, mail password, and a Stripe `sk_test_...` key are in clone-accessible history. Rotation is the only fix; deletion alone won't help. |
| 3 | **CRITICAL** | CORS = `allowed_origins=['*']` + `supports_credentials=true` | `config/cors.php` | Misconfigured combo causes Laravel to reflect any `Origin`, allowing cross-site authenticated requests. With non-expiring Sanctum tokens and no CSRF on `api/*`, this is full session hijacking from any malicious page. |
| 4 | **CRITICAL** | No tests for any business logic (Maxile, lives, subscriptions, OTP) | `tests/` | Two scaffolded `assertTrue(true)` examples. The product's IP and money-handling are unverified; any refactor is blind. |
| 5 | **CRITICAL** | SIMBA telco integration is a literal stub — `+659` prefix grants `'ACTIVE_TELCO'` enrolment with fake `subscriber_id` and no reconciliation | `OTPController::checkTelcoSubscriber:151`, `createMathEnrollment:177` | Any Singaporean mobile gets free access; revenue cannot be reconciled with SIMBA. PartnerService exists but is unused. |
| 6 | **HIGH** | OTP brute-force possible — 6-digit code, no `throttle` middleware on `/api/auth/verify-otp` | `routes/api.php`, `OTPController` | Account takeover via 1M-guess sweep. Combined with no Sanctum token expiry, takeover is permanent. |
| 7 | **HIGH** | Mass-assignment of privileged fields on `User` (`is_admin`, `role_id`, `subscription_plan_id`, `lives`, `access_type`) | `app/Models/User.php:37-77`; `app/Models/Quiz.php` (`$guarded = []`) | Any naive `User::update($validated)` lets a user escalate. Needs a `$guarded` allowlist or DTOs. |
| 8 | **HIGH** | Lives system race conditions: no `lockForUpdate`/transaction in `deductLife`/`regenerateLives` | `app/Services/LiveService.php` | Concurrent answer submissions can drive `lives` negative or lose restore-queue entries. Real users on flaky networks hit this. |
| 9 | **HIGH** | Stripe webhook crashes on legitimate paths: missing `User` import, references nonexistent `LivesPurchase` model, treats `incomplete` Stripe state as active | `StripeWebhookController.php:52`, `LivesPurchaseService.php:73`, `SubscriptionService.php:102` | Real customers hit fatal errors; failed-3DS users are upgraded to premium. |
| 10 | **HIGH** | High-severity CVE in `symfony/http-foundation` (CVE-2025-64500, auth bypass via PATH_INFO) plus 5 other advisories from `composer audit`; Stripe SDK two majors behind | `composer.lock` | Direct attack surface. Run `composer update` and pin to fixed versions. |

**Honourable mentions (would-be #11+):** zero soft-deletes anywhere, `setup-storage` public route, `resources/views/admin/questions/mathapi11v2/` containing a duplicate of the entire repo plus committed SQL dumps, `APP_DEBUG=true` in the only checked-in env, `array_merge_recursive` in `LiveService::getConfig` collapsing booleans into arrays, no `.env.example` at repo root.
