start-vibing-stacks 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ui.js CHANGED
@@ -2,7 +2,7 @@
2
2
  * Start Vibing Stacks — Terminal UI
3
3
  */
4
4
  import chalk from 'chalk';
5
- const VERSION = '2.1.0';
5
+ const VERSION = '2.2.0';
6
6
  const gradient = (text) => {
7
7
  const colors = [chalk.hex('#FF6B6B'), chalk.hex('#FF8E53'), chalk.hex('#FFBD2E'), chalk.hex('#48BB78'), chalk.hex('#4299E1'), chalk.hex('#9F7AEA')];
8
8
  return text.split('').map((c, i) => colors[i % colors.length](c)).join('');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "start-vibing-stacks",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "AI-powered multi-stack dev workflow for Claude Code. Supports PHP, Node.js, Python and more.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,431 @@
1
+ # API Security — NSA-Level Hardening for Laravel + Octane
2
+
3
+ **ALWAYS invoke when building APIs, auth endpoints, or handling user input.**
4
+
5
+ ## Security Layers
6
+
7
+ ```
8
+ Internet → Cloudflare WAF → Rate Limiting → CORS → Auth Middleware
9
+ → Input Validation → Business Logic → Output Sanitization → Response
10
+
11
+ Every layer is a wall. Assume the previous one failed.
12
+ ```
13
+
14
+ ## 1. Authentication (Sanctum + Octane)
15
+
16
+ ### Token-Based (API)
17
+
18
+ ```php
19
+ // config/sanctum.php
20
+ 'expiration' => 60 * 24, // 24h token expiry (MANDATORY)
21
+ 'token_prefix' => 'flk_', // Prefix for easy log scanning
22
+
23
+ // Issue token with abilities (least privilege)
24
+ $token = $user->createToken('api-client', [
25
+ 'read:leads',
26
+ 'write:leads',
27
+ // NOT '*' — never wildcard abilities
28
+ ])->plainTextToken;
29
+
30
+ // Middleware: check specific ability
31
+ Route::middleware(['auth:sanctum', 'ability:read:leads'])->group(function () {
32
+ Route::get('/api/v1/leads', [LeadController::class, 'index']);
33
+ });
34
+ ```
35
+
36
+ ### Token Rotation
37
+
38
+ ```php
39
+ // Rotate on every sensitive action
40
+ public function changePassword(Request $request): JsonResponse
41
+ {
42
+ // ... change password logic
43
+
44
+ // Revoke ALL tokens (force re-login everywhere)
45
+ $request->user()->tokens()->delete();
46
+
47
+ // Issue fresh token
48
+ $newToken = $request->user()->createToken('session', ['*'])->plainTextToken;
49
+
50
+ return $this->success(['token' => $newToken]);
51
+ }
52
+ ```
53
+
54
+ ### Octane Session Safety
55
+
56
+ ```php
57
+ // app/Providers/AppServiceProvider.php
58
+ use Laravel\Octane\Facades\Octane;
59
+
60
+ public function boot(): void
61
+ {
62
+ // MANDATORY: flush auth state between requests
63
+ Octane::prepare(function ($sandbox) {
64
+ $sandbox->forgetScopedInstances();
65
+ $sandbox->flushDatabaseConnections();
66
+ });
67
+ }
68
+ ```
69
+
70
+ ## 2. Input Validation (Trust NOTHING)
71
+
72
+ ### FormRequest (Always)
73
+
74
+ ```php
75
+ // app/Http/Requests/StoreLeadRequest.php
76
+ class StoreLeadRequest extends FormRequest
77
+ {
78
+ public function authorize(): bool
79
+ {
80
+ return $this->user()->tokenCan('write:leads');
81
+ }
82
+
83
+ public function rules(): array
84
+ {
85
+ return [
86
+ 'name' => ['required', 'string', 'min:2', 'max:255'],
87
+ 'email' => ['required', 'email:rfc,dns', 'max:320'], // RFC + DNS check
88
+ 'phone' => ['nullable', 'string', 'regex:/^\+?[1-9]\d{1,14}$/'], // E.164
89
+ 'domain_id' => ['required', 'uuid', 'exists:domains,id'],
90
+ 'metadata' => ['nullable', 'json', 'max:10000'], // Size limit on JSON
91
+ 'tags' => ['nullable', 'array', 'max:10'],
92
+ 'tags.*' => ['string', 'max:50', 'alpha_dash'],
93
+ ];
94
+ }
95
+
96
+ // Sanitize AFTER validation
97
+ public function validated($key = null, $default = null): array
98
+ {
99
+ $data = parent::validated($key, $default);
100
+ $data['email'] = strtolower(trim($data['email']));
101
+ $data['name'] = strip_tags($data['name']);
102
+ return $data;
103
+ }
104
+ }
105
+ ```
106
+
107
+ ### SQL Injection Prevention
108
+
109
+ ```php
110
+ // ✅ ALWAYS Eloquent or parameterized
111
+ Lead::where('email', $request->validated('email'))->first();
112
+
113
+ // ✅ Raw with bindings
114
+ DB::select('SELECT * FROM leads WHERE email = ?', [$email]);
115
+
116
+ // ❌ NEVER interpolate user input
117
+ DB::select("SELECT * FROM leads WHERE email = '{$email}'"); // ❌ SQL INJECTION
118
+ ```
119
+
120
+ ### Mass Assignment Protection
121
+
122
+ ```php
123
+ class Lead extends Model
124
+ {
125
+ // Explicit fillable (whitelist approach)
126
+ protected $fillable = [
127
+ 'name', 'email', 'phone', 'domain_id', 'metadata',
128
+ ];
129
+
130
+ // NEVER: protected $guarded = []; ← allows EVERYTHING
131
+ }
132
+ ```
133
+
134
+ ## 3. Rate Limiting (Multi-Layer)
135
+
136
+ ```php
137
+ // app/Providers/AppServiceProvider.php
138
+ use Illuminate\Cache\RateLimiting\Limit;
139
+ use Illuminate\Support\Facades\RateLimiter;
140
+
141
+ public function boot(): void
142
+ {
143
+ // Global API limit
144
+ RateLimiter::for('api', function ($request) {
145
+ return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
146
+ });
147
+
148
+ // Strict limit on auth endpoints
149
+ RateLimiter::for('auth', function ($request) {
150
+ return [
151
+ Limit::perMinute(5)->by($request->ip()), // 5/min per IP
152
+ Limit::perHour(20)->by($request->ip()), // 20/hour per IP
153
+ Limit::perDay(50)->by($request->ip()), // 50/day per IP
154
+ ];
155
+ });
156
+
157
+ // Strict limit on sensitive actions
158
+ RateLimiter::for('sensitive', function ($request) {
159
+ return Limit::perMinute(3)->by($request->user()->id); // 3/min per user
160
+ });
161
+
162
+ // Webhook endpoints
163
+ RateLimiter::for('webhooks', function ($request) {
164
+ return Limit::perMinute(100)->by($request->ip());
165
+ });
166
+ }
167
+
168
+ // Routes
169
+ Route::middleware('throttle:auth')->group(function () {
170
+ Route::post('/login', [AuthController::class, 'login']);
171
+ Route::post('/register', [AuthController::class, 'register']);
172
+ Route::post('/forgot-password', [AuthController::class, 'forgotPassword']);
173
+ });
174
+
175
+ Route::middleware('throttle:sensitive')->group(function () {
176
+ Route::post('/change-password', [AuthController::class, 'changePassword']);
177
+ Route::post('/change-email', [AuthController::class, 'changeEmail']);
178
+ Route::delete('/account', [AuthController::class, 'deleteAccount']);
179
+ });
180
+ ```
181
+
182
+ ## 4. CORS (Restrictive)
183
+
184
+ ```php
185
+ // config/cors.php
186
+ return [
187
+ 'paths' => ['api/*'],
188
+ 'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
189
+ 'allowed_origins' => [
190
+ env('APP_URL'), // Only your domain
191
+ // NOT '*' — NEVER wildcard in production
192
+ ],
193
+ 'allowed_headers' => [
194
+ 'Content-Type', 'Authorization', 'X-Requested-With',
195
+ 'X-Timezone', 'Accept', 'X-CSRF-TOKEN',
196
+ ],
197
+ 'exposed_headers' => ['X-Request-ID', 'Retry-After'],
198
+ 'max_age' => 86400, // 24h preflight cache
199
+ 'supports_credentials' => true,
200
+ ];
201
+ ```
202
+
203
+ ## 5. Security Headers Middleware
204
+
205
+ ```php
206
+ // app/Http/Middleware/SecurityHeaders.php
207
+ class SecurityHeaders
208
+ {
209
+ public function handle($request, Closure $next)
210
+ {
211
+ $response = $next($request);
212
+
213
+ return $response
214
+ ->header('X-Content-Type-Options', 'nosniff')
215
+ ->header('X-Frame-Options', 'DENY')
216
+ ->header('X-XSS-Protection', '0') // Modern browsers use CSP instead
217
+ ->header('Referrer-Policy', 'strict-origin-when-cross-origin')
218
+ ->header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
219
+ ->header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload')
220
+ ->header('X-Request-ID', $request->attributes->get('request_id', (string) Str::uuid()))
221
+ ->header('Content-Security-Policy', implode('; ', [
222
+ "default-src 'self'",
223
+ "script-src 'self' 'unsafe-inline' https://js.stripe.com",
224
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
225
+ "img-src 'self' data: https:",
226
+ "font-src 'self' https://fonts.gstatic.com",
227
+ "connect-src 'self' https://api.stripe.com",
228
+ "frame-src https://js.stripe.com",
229
+ "object-src 'none'",
230
+ "base-uri 'self'",
231
+ ]));
232
+ }
233
+ }
234
+ ```
235
+
236
+ ## 6. Output Sanitization
237
+
238
+ ```php
239
+ // app/Http/Resources/LeadResource.php
240
+ class LeadResource extends JsonResource
241
+ {
242
+ use FormatsDatesForApi;
243
+
244
+ public function toArray(Request $request): array
245
+ {
246
+ return [
247
+ 'id' => $this->id,
248
+ 'name' => e($this->name), // HTML entity encode
249
+ 'email' => $this->email,
250
+ 'status' => $this->status->value,
251
+ 'created_at' => $this->formatDateTime($this->created_at, $request),
252
+ // NEVER expose:
253
+ // 'password' → NEVER
254
+ // 'api_token' → NEVER
255
+ // 'ip_address' → only if admin
256
+ // 'remember_token' → NEVER
257
+ ];
258
+ }
259
+ }
260
+
261
+ // Model hidden attributes (defense in depth)
262
+ class User extends Authenticatable
263
+ {
264
+ protected $hidden = [
265
+ 'password',
266
+ 'remember_token',
267
+ 'two_factor_secret',
268
+ 'two_factor_recovery_codes',
269
+ ];
270
+ }
271
+ ```
272
+
273
+ ## 7. Encryption at Rest
274
+
275
+ ```php
276
+ // Encrypt sensitive data in database
277
+ use Illuminate\Database\Eloquent\Casts\Attribute;
278
+
279
+ class ApiCredential extends Model
280
+ {
281
+ // Laravel encrypted cast (AES-256-CBC)
282
+ protected $casts = [
283
+ 'api_key' => 'encrypted',
284
+ 'api_secret' => 'encrypted',
285
+ 'webhook_secret' => 'encrypted',
286
+ ];
287
+ }
288
+
289
+ // For searchable encrypted fields
290
+ use Illuminate\Support\Facades\Crypt;
291
+
292
+ class Lead extends Model
293
+ {
294
+ // Store hash for lookup, encrypted value for display
295
+ protected static function booted(): void
296
+ {
297
+ static::creating(function ($lead) {
298
+ $lead->email_hash = hash('sha256', strtolower($lead->email));
299
+ $lead->email_encrypted = Crypt::encryptString($lead->email);
300
+ });
301
+ }
302
+ }
303
+ ```
304
+
305
+ ## 8. Audit Logging
306
+
307
+ ```php
308
+ // app/Traits/Auditable.php
309
+ trait Auditable
310
+ {
311
+ protected static function bootAuditable(): void
312
+ {
313
+ foreach (['created', 'updated', 'deleted'] as $event) {
314
+ static::$event(function ($model) use ($event) {
315
+ AuditLog::create([
316
+ 'auditable_type' => $model->getMorphClass(),
317
+ 'auditable_id' => $model->getKey(),
318
+ 'event' => $event,
319
+ 'old_values' => $event === 'updated' ? $model->getOriginal() : null,
320
+ 'new_values' => $event !== 'deleted' ? $model->getAttributes() : null,
321
+ 'user_id' => auth()->id(),
322
+ 'ip_address' => request()->ip(),
323
+ 'user_agent' => request()->userAgent(),
324
+ ]);
325
+ });
326
+ }
327
+ }
328
+ }
329
+
330
+ // Usage on sensitive models
331
+ class Lead extends Model
332
+ {
333
+ use HasUuids, Auditable;
334
+ }
335
+ ```
336
+
337
+ ## 9. Brute Force Protection
338
+
339
+ ```php
340
+ // app/Http/Controllers/Auth/LoginController.php
341
+ public function login(LoginRequest $request): JsonResponse
342
+ {
343
+ $key = 'login_attempts:' . $request->ip() . ':' . Str::lower($request->email);
344
+
345
+ // Check lockout (progressive delay)
346
+ $attempts = (int) Cache::get($key, 0);
347
+ if ($attempts >= 5) {
348
+ $lockoutMinutes = min(pow(2, $attempts - 5), 60); // 1, 2, 4, 8, 16, 32, 60 min
349
+ $ttl = Cache::get("{$key}:lockout");
350
+ if ($ttl && now()->lt($ttl)) {
351
+ return $this->error('Too many attempts. Try again later.', 429);
352
+ }
353
+ }
354
+
355
+ if (!Auth::attempt($request->validated())) {
356
+ // Increment with exponential TTL
357
+ Cache::put($key, $attempts + 1, now()->addHours(1));
358
+ if ($attempts + 1 >= 5) {
359
+ $lockoutMinutes = min(pow(2, $attempts - 4), 60);
360
+ Cache::put("{$key}:lockout", now()->addMinutes($lockoutMinutes), now()->addMinutes($lockoutMinutes));
361
+ }
362
+
363
+ // Generic message (don't reveal if user exists)
364
+ return $this->error('Invalid credentials', 401);
365
+ }
366
+
367
+ // Reset on success
368
+ Cache::forget($key);
369
+ Cache::forget("{$key}:lockout");
370
+
371
+ $token = $request->user()->createToken('session')->plainTextToken;
372
+ return $this->success(['token' => $token]);
373
+ }
374
+ ```
375
+
376
+ ## 10. Request ID Tracking
377
+
378
+ ```php
379
+ // app/Http/Middleware/RequestId.php
380
+ class RequestId
381
+ {
382
+ public function handle($request, Closure $next)
383
+ {
384
+ $requestId = $request->header('X-Request-ID', (string) Str::uuid());
385
+ $request->attributes->set('request_id', $requestId);
386
+
387
+ // Add to all log entries in this request
388
+ Log::shareContext(['request_id' => $requestId]);
389
+
390
+ $response = $next($request);
391
+ $response->header('X-Request-ID', $requestId);
392
+
393
+ return $response;
394
+ }
395
+ }
396
+ ```
397
+
398
+ ## Security Checklist — Before Deploy
399
+
400
+ - [ ] All endpoints have auth middleware
401
+ - [ ] All input validated via FormRequest
402
+ - [ ] Rate limiting on auth + sensitive endpoints
403
+ - [ ] CORS restricted to your domain only
404
+ - [ ] Security headers middleware active
405
+ - [ ] Sensitive fields `$hidden` on models
406
+ - [ ] API keys encrypted at rest
407
+ - [ ] Audit logging on sensitive models
408
+ - [ ] Brute force protection on login
409
+ - [ ] Request ID tracking in all logs
410
+ - [ ] No `env()` in code (only `config()`)
411
+ - [ ] No `*` in token abilities
412
+ - [ ] No `$guarded = []` on models
413
+ - [ ] CSP headers configured
414
+ - [ ] HSTS enabled
415
+ - [ ] Webhook signatures verified
416
+ - [ ] Error responses don't expose internals
417
+
418
+ ## FORBIDDEN
419
+
420
+ | ❌ Don't | ✅ Do |
421
+ |---|---|
422
+ | `$guarded = []` | Explicit `$fillable` |
423
+ | `'allowed_origins' => ['*']` | Your domain only |
424
+ | `createToken('x', ['*'])` | Specific abilities |
425
+ | `"WHERE email = '$email'"` | Parameterized queries |
426
+ | `dd($user)` in production | `Log::info()` structured |
427
+ | Generic error messages with stack traces | Clean error + request_id for debugging |
428
+ | Store API keys in plaintext | `'encrypted'` cast |
429
+ | Same rate limit for all endpoints | Progressive: auth(5/min) < api(60/min) |
430
+ | Trust `X-Forwarded-For` directly | Use trusted proxies config |
431
+ | No token expiry | 24h max, rotate on sensitive actions |
@@ -0,0 +1,513 @@
1
+ # External API Patterns — HTTP Client for Laravel + Octane
2
+
3
+ **ALWAYS invoke when consuming external APIs, webhooks, or third-party services.**
4
+
5
+ ## Architecture
6
+
7
+ ```
8
+ Controller/Job
9
+ → ApiService (business logic)
10
+ → Http::withOptions() (Laravel HTTP Client)
11
+ → Response handling (DTO)
12
+ → Error handling (typed exceptions)
13
+ → Logging + monitoring
14
+
15
+ NEVER call Http:: directly in controllers. Always through a Service.
16
+ ```
17
+
18
+ ## Service Pattern
19
+
20
+ ```php
21
+ // app/Services/External/OpenAiService.php
22
+ namespace App\Services\External;
23
+
24
+ use App\DTOs\Api\ChatCompletionRequest;
25
+ use App\DTOs\Api\ChatCompletionResponse;
26
+ use App\Exceptions\Api\ApiConnectionException;
27
+ use App\Exceptions\Api\ApiRateLimitException;
28
+ use App\Exceptions\Api\ApiValidationException;
29
+ use Illuminate\Http\Client\PendingRequest;
30
+ use Illuminate\Http\Client\RequestException;
31
+ use Illuminate\Http\Client\ConnectionException;
32
+ use Illuminate\Support\Facades\Http;
33
+ use Illuminate\Support\Facades\Log;
34
+
35
+ class OpenAiService
36
+ {
37
+ private PendingRequest $client;
38
+
39
+ public function __construct()
40
+ {
41
+ $this->client = Http::baseUrl(config('services.openai.base_url', 'https://api.openai.com/v1'))
42
+ ->withToken(config('services.openai.key'))
43
+ ->timeout(30)
44
+ ->connectTimeout(5)
45
+ ->withHeaders([
46
+ 'Accept' => 'application/json',
47
+ 'Content-Type' => 'application/json',
48
+ ])
49
+ ->retry(
50
+ times: 3,
51
+ sleepMilliseconds: fn (int $attempt) => $attempt * 500, // 500ms, 1s, 1.5s
52
+ when: fn ($exception) => $this->shouldRetry($exception),
53
+ throw: true,
54
+ );
55
+ }
56
+
57
+ public function chatCompletion(ChatCompletionRequest $request): ChatCompletionResponse
58
+ {
59
+ try {
60
+ $response = $this->client
61
+ ->post('/chat/completions', $request->toArray())
62
+ ->throw();
63
+
64
+ return ChatCompletionResponse::fromArray($response->json());
65
+
66
+ } catch (ConnectionException $e) {
67
+ Log::error('[OpenAI] Connection failed', [
68
+ 'error' => $e->getMessage(),
69
+ ]);
70
+ throw new ApiConnectionException('OpenAI', $e);
71
+
72
+ } catch (RequestException $e) {
73
+ $this->handleRequestException($e, 'chatCompletion');
74
+ }
75
+ }
76
+
77
+ private function shouldRetry(\Exception $exception): bool
78
+ {
79
+ if ($exception instanceof ConnectionException) return true;
80
+
81
+ if ($exception instanceof RequestException) {
82
+ $status = $exception->response->status();
83
+ // Retry on: 408 timeout, 429 rate limit, 500+ server errors
84
+ return in_array($status, [408, 429, 500, 502, 503, 504]);
85
+ }
86
+
87
+ return false;
88
+ }
89
+
90
+ private function handleRequestException(RequestException $e, string $method): never
91
+ {
92
+ $status = $e->response->status();
93
+ $body = $e->response->json();
94
+
95
+ Log::error("[OpenAI] {$method} failed", [
96
+ 'status' => $status,
97
+ 'error' => $body['error']['message'] ?? $e->getMessage(),
98
+ 'type' => $body['error']['type'] ?? 'unknown',
99
+ ]);
100
+
101
+ match (true) {
102
+ $status === 429 => throw new ApiRateLimitException('OpenAI', $body, $e),
103
+ $status === 422 => throw new ApiValidationException('OpenAI', $body, $e),
104
+ $status >= 500 => throw new ApiConnectionException('OpenAI', $e),
105
+ default => throw new ApiConnectionException('OpenAI', $e),
106
+ };
107
+ }
108
+ }
109
+ ```
110
+
111
+ ## DTOs (Data Transfer Objects)
112
+
113
+ ```php
114
+ // app/DTOs/Api/ChatCompletionRequest.php
115
+ namespace App\DTOs\Api;
116
+
117
+ readonly class ChatCompletionRequest
118
+ {
119
+ public function __construct(
120
+ public string $model,
121
+ public array $messages,
122
+ public float $temperature = 0.7,
123
+ public int $maxTokens = 4096,
124
+ ) {}
125
+
126
+ public function toArray(): array
127
+ {
128
+ return [
129
+ 'model' => $this->model,
130
+ 'messages' => $this->messages,
131
+ 'temperature' => $this->temperature,
132
+ 'max_tokens' => $this->maxTokens,
133
+ ];
134
+ }
135
+ }
136
+
137
+ // app/DTOs/Api/ChatCompletionResponse.php
138
+ namespace App\DTOs\Api;
139
+
140
+ readonly class ChatCompletionResponse
141
+ {
142
+ public function __construct(
143
+ public string $id,
144
+ public string $content,
145
+ public int $promptTokens,
146
+ public int $completionTokens,
147
+ public string $model,
148
+ public string $finishReason,
149
+ ) {}
150
+
151
+ public static function fromArray(array $data): self
152
+ {
153
+ return new self(
154
+ id: $data['id'],
155
+ content: $data['choices'][0]['message']['content'] ?? '',
156
+ promptTokens: $data['usage']['prompt_tokens'] ?? 0,
157
+ completionTokens: $data['usage']['completion_tokens'] ?? 0,
158
+ model: $data['model'],
159
+ finishReason: $data['choices'][0]['finish_reason'] ?? 'unknown',
160
+ );
161
+ }
162
+ }
163
+ ```
164
+
165
+ ## Typed Exceptions
166
+
167
+ ```php
168
+ // app/Exceptions/Api/ApiConnectionException.php
169
+ namespace App\Exceptions\Api;
170
+
171
+ class ApiConnectionException extends \RuntimeException
172
+ {
173
+ public function __construct(
174
+ public readonly string $service,
175
+ ?\Throwable $previous = null,
176
+ ) {
177
+ parent::__construct("Connection to {$service} API failed", 503, $previous);
178
+ }
179
+ }
180
+
181
+ // app/Exceptions/Api/ApiRateLimitException.php
182
+ class ApiRateLimitException extends \RuntimeException
183
+ {
184
+ public function __construct(
185
+ public readonly string $service,
186
+ public readonly array $body = [],
187
+ ?\Throwable $previous = null,
188
+ ) {
189
+ $retryAfter = $body['error']['retry_after'] ?? 'unknown';
190
+ parent::__construct("{$service} rate limit exceeded. Retry after: {$retryAfter}s", 429, $previous);
191
+ }
192
+ }
193
+
194
+ // app/Exceptions/Api/ApiValidationException.php
195
+ class ApiValidationException extends \RuntimeException
196
+ {
197
+ public function __construct(
198
+ public readonly string $service,
199
+ public readonly array $body = [],
200
+ ?\Throwable $previous = null,
201
+ ) {
202
+ $message = $body['error']['message'] ?? 'Validation failed';
203
+ parent::__construct("{$service}: {$message}", 422, $previous);
204
+ }
205
+ }
206
+ ```
207
+
208
+ ## API Response Standard (Your API → Frontend)
209
+
210
+ ```php
211
+ // app/Traits/ApiResponse.php
212
+ namespace App\Traits;
213
+
214
+ use Illuminate\Http\JsonResponse;
215
+
216
+ trait ApiResponse
217
+ {
218
+ protected function success(mixed $data = null, string $message = 'OK', int $status = 200): JsonResponse
219
+ {
220
+ return response()->json([
221
+ 'success' => true,
222
+ 'message' => $message,
223
+ 'data' => $data,
224
+ ], $status);
225
+ }
226
+
227
+ protected function created(mixed $data = null, string $message = 'Created'): JsonResponse
228
+ {
229
+ return $this->success($data, $message, 201);
230
+ }
231
+
232
+ protected function error(string $message, int $status = 400, array $errors = []): JsonResponse
233
+ {
234
+ $response = [
235
+ 'success' => false,
236
+ 'message' => $message,
237
+ ];
238
+
239
+ if (!empty($errors)) {
240
+ $response['errors'] = $errors;
241
+ }
242
+
243
+ return response()->json($response, $status);
244
+ }
245
+
246
+ protected function notFound(string $resource = 'Resource'): JsonResponse
247
+ {
248
+ return $this->error("{$resource} not found", 404);
249
+ }
250
+
251
+ protected function unauthorized(string $message = 'Unauthorized'): JsonResponse
252
+ {
253
+ return $this->error($message, 401);
254
+ }
255
+
256
+ protected function rateLimited(int $retryAfter = 60): JsonResponse
257
+ {
258
+ return response()->json([
259
+ 'success' => false,
260
+ 'message' => 'Too many requests',
261
+ 'retry_after' => $retryAfter,
262
+ ], 429)->header('Retry-After', $retryAfter);
263
+ }
264
+ }
265
+ ```
266
+
267
+ ### Controller Usage
268
+
269
+ ```php
270
+ class AiModelController extends Controller
271
+ {
272
+ use ApiResponse;
273
+
274
+ public function __construct(
275
+ private readonly OpenAiService $openAi,
276
+ ) {}
277
+
278
+ public function generate(GenerateRequest $request): JsonResponse
279
+ {
280
+ try {
281
+ $dto = new ChatCompletionRequest(
282
+ model: $request->validated('model'),
283
+ messages: $request->validated('messages'),
284
+ temperature: $request->validated('temperature', 0.7),
285
+ );
286
+
287
+ $result = $this->openAi->chatCompletion($dto);
288
+
289
+ return $this->success([
290
+ 'content' => $result->content,
291
+ 'tokens' => $result->promptTokens + $result->completionTokens,
292
+ 'model' => $result->model,
293
+ ]);
294
+
295
+ } catch (ApiRateLimitException $e) {
296
+ return $this->rateLimited(60);
297
+ } catch (ApiValidationException $e) {
298
+ return $this->error($e->getMessage(), 422);
299
+ } catch (ApiConnectionException $e) {
300
+ return $this->error('Service temporarily unavailable', 503);
301
+ }
302
+ }
303
+ }
304
+ ```
305
+
306
+ ## Frontend Consumption (React + Inertia)
307
+
308
+ ```tsx
309
+ // resources/js/services/api.ts
310
+ import axios, { AxiosError } from 'axios';
311
+
312
+ interface ApiResponse<T> {
313
+ success: boolean;
314
+ message: string;
315
+ data: T;
316
+ errors?: Record<string, string[]>;
317
+ }
318
+
319
+ interface ApiErrorResponse {
320
+ success: false;
321
+ message: string;
322
+ errors?: Record<string, string[]>;
323
+ retry_after?: number;
324
+ }
325
+
326
+ export async function apiCall<T>(
327
+ method: 'get' | 'post' | 'put' | 'delete',
328
+ url: string,
329
+ data?: unknown,
330
+ ): Promise<T> {
331
+ try {
332
+ const response = await axios({ method, url, data });
333
+ const body = response.data as ApiResponse<T>;
334
+ if (!body.success) throw new ApiError(body.message, response.status);
335
+ return body.data;
336
+ } catch (error) {
337
+ if (error instanceof AxiosError) {
338
+ const body = error.response?.data as ApiErrorResponse;
339
+ const status = error.response?.status ?? 500;
340
+
341
+ if (status === 429) {
342
+ throw new RateLimitError(body?.retry_after ?? 60);
343
+ }
344
+ if (status === 422 && body?.errors) {
345
+ throw new ValidationError(body.message, body.errors);
346
+ }
347
+ throw new ApiError(body?.message ?? 'Connection failed', status);
348
+ }
349
+ throw error;
350
+ }
351
+ }
352
+
353
+ // Typed errors
354
+ export class ApiError extends Error {
355
+ constructor(message: string, public status: number) { super(message); }
356
+ }
357
+ export class ValidationError extends ApiError {
358
+ constructor(message: string, public errors: Record<string, string[]>) { super(message, 422); }
359
+ }
360
+ export class RateLimitError extends ApiError {
361
+ constructor(public retryAfter: number) { super('Too many requests', 429); }
362
+ }
363
+ ```
364
+
365
+ ### React Component Usage
366
+
367
+ ```tsx
368
+ const LABELS = {
369
+ generating: __('messages.ai.generating'),
370
+ error: __('messages.errors.error'),
371
+ rateLimited: __('messages.errors.rate_limited'),
372
+ } as const;
373
+
374
+ export default function AiChat() {
375
+ const [loading, setLoading] = useState(false);
376
+
377
+ const generate = async () => {
378
+ setLoading(true);
379
+ try {
380
+ const result = await apiCall<{ content: string }>('post', route('api.v1.ai.generate'), {
381
+ model: 'gpt-4',
382
+ messages: [{ role: 'user', content: prompt }],
383
+ });
384
+ setResponse(result.content);
385
+ } catch (error) {
386
+ if (error instanceof RateLimitError) {
387
+ toast.error(`${LABELS.rateLimited} (${error.retryAfter}s)`);
388
+ } else if (error instanceof ValidationError) {
389
+ Object.values(error.errors).flat().forEach(msg => toast.error(msg));
390
+ } else {
391
+ toast.error(error instanceof ApiError ? error.message : LABELS.error);
392
+ }
393
+ } finally {
394
+ setLoading(false);
395
+ }
396
+ };
397
+ }
398
+ ```
399
+
400
+ ## Octane Safety
401
+
402
+ ```php
403
+ // ✅ New client instance per request (no stale state)
404
+ public function __construct()
405
+ {
406
+ // Http::baseUrl() creates a NEW PendingRequest each time
407
+ // Safe in Octane — no shared state between requests
408
+ $this->client = Http::baseUrl(config('services.openai.base_url'))
409
+ ->withToken(config('services.openai.key'));
410
+ }
411
+
412
+ // ❌ NEVER static client (leaks between Octane requests)
413
+ private static PendingRequest $client; // ❌ Shared across ALL requests!
414
+ ```
415
+
416
+ ## Config Pattern
417
+
418
+ ```php
419
+ // config/services.php
420
+ 'openai' => [
421
+ 'key' => env('OPENAI_API_KEY'),
422
+ 'base_url' => env('OPENAI_BASE_URL', 'https://api.openai.com/v1'),
423
+ 'timeout' => env('OPENAI_TIMEOUT', 30),
424
+ 'max_retries' => env('OPENAI_MAX_RETRIES', 3),
425
+ ],
426
+
427
+ 'stripe' => [
428
+ 'key' => env('STRIPE_KEY'),
429
+ 'secret' => env('STRIPE_SECRET'),
430
+ 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
431
+ ],
432
+ ```
433
+
434
+ **Rule:** All API keys in `.env` → `config/services.php` → `config('services.x.key')`. NEVER `env()` directly in code (cached in Octane).
435
+
436
+ ## Webhook Receiving
437
+
438
+ ```php
439
+ // app/Http/Controllers/Webhooks/StripeWebhookController.php
440
+ class StripeWebhookController extends Controller
441
+ {
442
+ public function handle(Request $request): JsonResponse
443
+ {
444
+ // 1. Verify signature
445
+ $payload = $request->getContent();
446
+ $signature = $request->header('Stripe-Signature');
447
+
448
+ try {
449
+ $event = \Stripe\Webhook::constructEvent(
450
+ $payload,
451
+ $signature,
452
+ config('services.stripe.webhook_secret')
453
+ );
454
+ } catch (\Exception $e) {
455
+ Log::warning('[Stripe Webhook] Invalid signature', ['error' => $e->getMessage()]);
456
+ return response()->json(['error' => 'Invalid signature'], 400);
457
+ }
458
+
459
+ // 2. Process idempotently (check if already processed)
460
+ if (WebhookEvent::where('event_id', $event->id)->exists()) {
461
+ return response()->json(['status' => 'already_processed']);
462
+ }
463
+
464
+ // 3. Store event
465
+ WebhookEvent::create([
466
+ 'event_id' => $event->id,
467
+ 'type' => $event->type,
468
+ 'payload' => $payload,
469
+ ]);
470
+
471
+ // 4. Dispatch job (async processing)
472
+ ProcessStripeEvent::dispatch($event->type, $event->data->object);
473
+
474
+ return response()->json(['status' => 'received']);
475
+ }
476
+ }
477
+ ```
478
+
479
+ ## Logging Standard
480
+
481
+ ```php
482
+ // Structured logging for all API calls
483
+ Log::info('[ServiceName] API call', [
484
+ 'method' => 'POST',
485
+ 'endpoint' => '/chat/completions',
486
+ 'status' => 200,
487
+ 'duration_ms' => $duration,
488
+ 'tokens' => $response->promptTokens + $response->completionTokens,
489
+ ]);
490
+
491
+ Log::error('[ServiceName] API failed', [
492
+ 'method' => 'POST',
493
+ 'endpoint' => '/chat/completions',
494
+ 'status' => $e->response->status(),
495
+ 'error' => $e->response->json('error.message'),
496
+ 'duration_ms' => $duration,
497
+ ]);
498
+ ```
499
+
500
+ ## FORBIDDEN
501
+
502
+ | ❌ Don't | ✅ Do |
503
+ |---|---|
504
+ | `Http::get()` in controller | Service class with typed DTOs |
505
+ | `env('API_KEY')` in code | `config('services.x.key')` |
506
+ | Raw arrays for API data | `readonly class` DTOs |
507
+ | Catch generic `Exception` | Typed exceptions per error type |
508
+ | `static $client` in Octane | Instance `$this->client` per request |
509
+ | No timeout on HTTP calls | `->timeout(30)->connectTimeout(5)` |
510
+ | No retry logic | `->retry(3, backoff, when)` |
511
+ | Webhook without signature check | ALWAYS verify signatures |
512
+ | Webhook sync processing | Dispatch job for async |
513
+ | `dd()` / `dump()` API responses | Structured `Log::info/error` |