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.
|
|
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
|
@@ -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` |
|