start-vibing-stacks 2.8.0 → 2.10.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/index.js +2 -1
- package/dist/setup.js +10 -2
- package/dist/types.d.ts +8 -0
- package/package.json +1 -1
- package/stacks/_shared/agents/.archive/claude-md-compactor.v1.0.0.md +160 -0
- package/stacks/_shared/agents/claude-md-compactor.md +223 -110
- package/stacks/frontend/react-api/skills/axios-laravel-api/SKILL.md +466 -0
- package/stacks/frontend/react-api/skills/react-api-standards/SKILL.md +509 -0
- package/stacks/frontend/react-inertia/skills/inertia-react/SKILL.md +11 -2
- package/stacks/frontend/react-inertia/skills/react-standards/SKILL.md +9 -2
- package/stacks/php/skills/api-design/SKILL.md +281 -47
- package/stacks/php/skills/api-security/SKILL.md +128 -49
- package/stacks/php/skills/inertia-react/SKILL.md +16 -3
- package/stacks/php/skills/laravel-api-architecture/SKILL.md +650 -0
- package/stacks/php/skills/laravel-inertia-i18n/SKILL.md +11 -3
- package/stacks/php/skills/laravel-patterns/SKILL.md +123 -61
- package/stacks/php/stack.json +19 -6
- package/templates/CLAUDE-php.md +202 -101
|
@@ -1,70 +1,279 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: api-design
|
|
3
|
-
version:
|
|
3
|
+
version: 2.0.0
|
|
4
|
+
description: PHP/Laravel REST API design contract — endpoint shape, pagination,
|
|
5
|
+
filtering, sorting, dates, idempotency, error response envelope. The contract
|
|
6
|
+
the React+Axios SPA expects on the wire. Use when designing any new API
|
|
7
|
+
surface. Pairs with `laravel-api-architecture` (the implementation pipeline)
|
|
8
|
+
and `axios-laravel-api` (the consumer).
|
|
4
9
|
---
|
|
5
10
|
|
|
6
|
-
#
|
|
11
|
+
# Laravel REST API Design Contract
|
|
12
|
+
|
|
13
|
+
**ALWAYS invoke when designing or naming endpoints, response shapes, pagination,
|
|
14
|
+
or filters. This is the contract — the implementation pipeline is in
|
|
15
|
+
`laravel-api-architecture`.**
|
|
7
16
|
|
|
8
17
|
## RESTful Principles
|
|
9
18
|
|
|
10
|
-
### Endpoint
|
|
19
|
+
### Endpoint Naming
|
|
11
20
|
|
|
12
21
|
```
|
|
13
|
-
GET /api/users
|
|
14
|
-
GET /api/users/{id}
|
|
15
|
-
POST /api/users
|
|
16
|
-
PUT /api/users/{id}
|
|
17
|
-
PATCH /api/users/{id}
|
|
18
|
-
DELETE /api/users/{id}
|
|
22
|
+
GET /api/users # List (paginated)
|
|
23
|
+
GET /api/users/{id} # Show
|
|
24
|
+
POST /api/users # Create
|
|
25
|
+
PUT /api/users/{id} # Replace (full update)
|
|
26
|
+
PATCH /api/users/{id} # Partial update
|
|
27
|
+
DELETE /api/users/{id} # Delete
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Action Endpoints (NOT generic PATCH)
|
|
19
31
|
|
|
20
|
-
|
|
32
|
+
```
|
|
21
33
|
POST /api/leads/{id}/reset-attempts
|
|
22
34
|
POST /api/domains/{id}/refresh-list
|
|
35
|
+
POST /api/orders/{id}/cancel
|
|
23
36
|
POST /api/reports/{id}/regenerate
|
|
24
37
|
```
|
|
25
38
|
|
|
26
|
-
**Rule:** Specific business actions get dedicated `POST` endpoints
|
|
39
|
+
**Rule:** Specific business actions get dedicated `POST` endpoints. Don't
|
|
40
|
+
overload `PATCH` with side-effects.
|
|
41
|
+
|
|
42
|
+
## Response Envelope
|
|
43
|
+
|
|
44
|
+
### Success (single resource)
|
|
45
|
+
|
|
46
|
+
Use Laravel's `JsonResource` directly — Laravel wraps it automatically:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"data": {
|
|
51
|
+
"id": "9b8c...",
|
|
52
|
+
"name": "Order #1234",
|
|
53
|
+
"status": "paid",
|
|
54
|
+
"created_at": "2026-05-13T12:34:56-03:00"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Success (collection / paginated)
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"data": [ { "id": "..." }, { "id": "..." } ],
|
|
64
|
+
"links": {
|
|
65
|
+
"first": "https://api.example.com/api/orders?page=1",
|
|
66
|
+
"last": "https://api.example.com/api/orders?page=4",
|
|
67
|
+
"prev": null,
|
|
68
|
+
"next": "https://api.example.com/api/orders?page=2"
|
|
69
|
+
},
|
|
70
|
+
"meta": {
|
|
71
|
+
"current_page": 1,
|
|
72
|
+
"from": 1,
|
|
73
|
+
"to": 25,
|
|
74
|
+
"last_page": 4,
|
|
75
|
+
"per_page": 25,
|
|
76
|
+
"total": 92,
|
|
77
|
+
"path": "https://api.example.com/api/orders"
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Rule:** Get this for free with Laravel's `->paginate($perPage)`. Don't
|
|
83
|
+
hand-roll pagination.
|
|
84
|
+
|
|
85
|
+
### Error envelope
|
|
86
|
+
|
|
87
|
+
Laravel returns these natively — match them:
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
// 401 Unauthenticated
|
|
91
|
+
{ "message": "Unauthenticated." }
|
|
92
|
+
|
|
93
|
+
// 403 Forbidden (Policy denied)
|
|
94
|
+
{ "message": "This action is unauthorized." }
|
|
95
|
+
|
|
96
|
+
// 404 Not Found
|
|
97
|
+
{ "message": "No query results for model [App\\Models\\Order] 123." }
|
|
98
|
+
|
|
99
|
+
// 419 CSRF Token Mismatch
|
|
100
|
+
{ "message": "CSRF token mismatch." }
|
|
101
|
+
|
|
102
|
+
// 422 Validation
|
|
103
|
+
{
|
|
104
|
+
"message": "The given data was invalid.",
|
|
105
|
+
"errors": {
|
|
106
|
+
"email": ["The email field is required."],
|
|
107
|
+
"password":["The password must be at least 8 characters."]
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 429 Too Many Requests (also returns header Retry-After)
|
|
112
|
+
{ "message": "Too Many Requests" }
|
|
113
|
+
|
|
114
|
+
// 500
|
|
115
|
+
{ "message": "Server Error" }
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**Rule:** Don't reinvent. The frontend's Axios interceptor (see
|
|
119
|
+
`axios-laravel-api`) is built around exactly these shapes. Adding a custom
|
|
120
|
+
envelope (`{success, data, errors}`) requires updating BOTH layers — keep
|
|
121
|
+
Laravel defaults unless you have a hard reason not to.
|
|
122
|
+
|
|
123
|
+
## Pagination Contract
|
|
124
|
+
|
|
125
|
+
```php
|
|
126
|
+
// Controller
|
|
127
|
+
public function index(IndexOrderRequest $request): AnonymousResourceCollection
|
|
128
|
+
{
|
|
129
|
+
$perPage = (int) ($request->validated('per_page') ?? 25);
|
|
130
|
+
$perPage = min($perPage, 100); // hard cap
|
|
131
|
+
return OrderResource::collection(
|
|
132
|
+
$this->orders->listFor($request->user(), $request->validated())->paginate($perPage)
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
```php
|
|
138
|
+
// FormRequest
|
|
139
|
+
'page' => ['nullable', 'integer', 'min:1'],
|
|
140
|
+
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**Rules:**
|
|
144
|
+
- Default `per_page = 25`. Hard cap = 100.
|
|
145
|
+
- Always validate `page` and `per_page` to prevent abuse.
|
|
146
|
+
- Use `->paginate()` for offset pagination (default), `->cursorPaginate()`
|
|
147
|
+
for high-volume cursors (>100k rows, infinite scroll).
|
|
148
|
+
|
|
149
|
+
## Filter & Sort Contract
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
GET /api/orders?status=paid&q=acme&sort=-created_at&page=2&per_page=25
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
```php
|
|
156
|
+
// FormRequest
|
|
157
|
+
'status' => ['nullable', 'string', 'in:pending,paid,shipped,cancelled'],
|
|
158
|
+
'q' => ['nullable', 'string', 'max:255'],
|
|
159
|
+
'sort' => ['nullable', 'string', 'in:created_at,-created_at,total,-total'],
|
|
160
|
+
'page' => ['nullable', 'integer', 'min:1'],
|
|
161
|
+
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
```php
|
|
165
|
+
// Service
|
|
166
|
+
if ($status = $filters['status'] ?? null) $query->where('status', $status);
|
|
167
|
+
if ($q = $filters['q'] ?? null) $query->where('reference', 'like', "%{$q}%");
|
|
168
|
+
|
|
169
|
+
if ($sort = $filters['sort'] ?? null) {
|
|
170
|
+
$direction = str_starts_with($sort, '-') ? 'desc' : 'asc';
|
|
171
|
+
$column = ltrim($sort, '-');
|
|
172
|
+
$query->orderBy($column, $direction);
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**Rules:**
|
|
177
|
+
- Sort syntax: `sort=field` (asc), `sort=-field` (desc). Allowed columns
|
|
178
|
+
whitelisted in FormRequest.
|
|
179
|
+
- Filter values whitelisted via `in:...` in FormRequest — never trust
|
|
180
|
+
enum-like params.
|
|
181
|
+
- `q` (free-text search) is `LIKE`/`ILIKE`. For real search, use scout +
|
|
182
|
+
Meilisearch / Typesense.
|
|
27
183
|
|
|
28
184
|
## Date Handling
|
|
29
185
|
|
|
30
186
|
```
|
|
31
|
-
Client → API:
|
|
32
|
-
Database:
|
|
33
|
-
API → Client:
|
|
187
|
+
Client → API: ISO 8601 (UTC or with timezone offset)
|
|
188
|
+
Database: UTC always
|
|
189
|
+
API → Client: ISO 8601, converted to caller's timezone in Resource only
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
```php
|
|
193
|
+
// app/Traits/FormatsDatesForApi.php
|
|
194
|
+
trait FormatsDatesForApi
|
|
195
|
+
{
|
|
196
|
+
protected function formatDateTime(?Carbon $date, Request $request): ?string
|
|
197
|
+
{
|
|
198
|
+
if (! $date) return null;
|
|
199
|
+
$tz = $request->header('X-Timezone', $request->user()?->timezone ?? 'UTC');
|
|
200
|
+
return $date->copy()->setTimezone($tz)->toISOString();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
34
203
|
```
|
|
35
204
|
|
|
36
205
|
**Rules:**
|
|
37
|
-
- Backend/DB stores **UTC always
|
|
38
|
-
- Timezone conversion **only** in API
|
|
39
|
-
-
|
|
40
|
-
|
|
206
|
+
- Backend / DB stores **UTC always**.
|
|
207
|
+
- Timezone conversion **only** in API Resource layer.
|
|
208
|
+
- Accept timezone via `X-Timezone` header or fall back to user profile.
|
|
209
|
+
|
|
210
|
+
## Idempotency for Unsafe Methods
|
|
211
|
+
|
|
212
|
+
For external integrations (webhooks, payment endpoints), accept an
|
|
213
|
+
`Idempotency-Key` header and de-dupe:
|
|
214
|
+
|
|
215
|
+
```php
|
|
216
|
+
public function store(StoreLeadRequest $request): JsonResource
|
|
217
|
+
{
|
|
218
|
+
$key = $request->header('Idempotency-Key');
|
|
219
|
+
if ($key && $existing = Lead::where('idempotency_key', $key)->first()) {
|
|
220
|
+
return LeadResource::make($existing);
|
|
221
|
+
}
|
|
222
|
+
$lead = $this->leads->create([...$request->validated(), 'idempotency_key' => $key]);
|
|
223
|
+
return LeadResource::make($lead);
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
**Rule:** All public webhook receivers MUST be idempotent (assume retries).
|
|
228
|
+
|
|
229
|
+
## Authentication Contract (Sanctum SPA + Optional Token)
|
|
230
|
+
|
|
231
|
+
| Caller | Auth | Header / Cookie |
|
|
232
|
+
|--------|------|------------------|
|
|
233
|
+
| Same-origin React SPA | Session cookie + CSRF | `XSRF-TOKEN` cookie → `X-XSRF-TOKEN` header |
|
|
234
|
+
| Cross-origin React SPA | Session cookie + CSRF (CORS w/ credentials) | Same |
|
|
235
|
+
| Mobile / 3rd-party | Personal access token | `Authorization: Bearer <token>` |
|
|
236
|
+
|
|
237
|
+
`auth:sanctum` accepts BOTH transparently. See `axios-laravel-api` for the
|
|
238
|
+
Axios setup and `api-security` for token issuing/scopes.
|
|
41
239
|
|
|
42
|
-
## Authorization
|
|
240
|
+
## Authorization Pattern
|
|
43
241
|
|
|
44
|
-
### User-Scoped Resources
|
|
242
|
+
### User-Scoped Resources (default)
|
|
243
|
+
|
|
244
|
+
Every collection endpoint MUST scope by user (admins may see everything):
|
|
45
245
|
|
|
46
246
|
```php
|
|
47
|
-
//
|
|
48
|
-
public function
|
|
247
|
+
// In the Service (NOT controller, NOT resource)
|
|
248
|
+
public function listFor(User $user, array $filters): Builder
|
|
49
249
|
{
|
|
50
|
-
$query =
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
$query->where('user_id', auth()->id());
|
|
250
|
+
$query = Order::query();
|
|
251
|
+
if (! $user->isAdmin()) {
|
|
252
|
+
$query->where('user_id', $user->id);
|
|
54
253
|
}
|
|
55
|
-
|
|
56
|
-
return
|
|
254
|
+
// ...
|
|
255
|
+
return $query;
|
|
57
256
|
}
|
|
58
257
|
```
|
|
59
258
|
|
|
60
|
-
|
|
259
|
+
### Per-Resource via Policy
|
|
260
|
+
|
|
261
|
+
```php
|
|
262
|
+
public function show(Order $order): OrderResource
|
|
263
|
+
{
|
|
264
|
+
$this->authorize('view', $order);
|
|
265
|
+
return OrderResource::make($order);
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
**Rule:** Never return unscoped queries from a Service. Scoping = security.
|
|
61
270
|
|
|
62
271
|
## Caching Strategy
|
|
63
272
|
|
|
64
273
|
```php
|
|
65
274
|
// User-specific cache keys
|
|
66
275
|
$key = "domains:user:{$userId}";
|
|
67
|
-
$ttl =
|
|
276
|
+
$ttl = now()->addMinutes(15);
|
|
68
277
|
|
|
69
278
|
$data = Cache::remember($key, $ttl, fn () => $query->get());
|
|
70
279
|
|
|
@@ -73,25 +282,21 @@ Cache::forget("domains:user:{$userId}");
|
|
|
73
282
|
```
|
|
74
283
|
|
|
75
284
|
**Rules:**
|
|
76
|
-
- Cache keys MUST include user ID for isolation
|
|
77
|
-
- Default TTL: 15 minutes
|
|
78
|
-
- Invalidate
|
|
79
|
-
- Use Redis for user-specific, frequently accessed data
|
|
285
|
+
- Cache keys MUST include user ID for isolation (avoid one user seeing another's data).
|
|
286
|
+
- Default TTL: 15 minutes.
|
|
287
|
+
- Invalidate on any write operation (in the Service that performed the write).
|
|
288
|
+
- Use Redis for user-specific, frequently accessed data.
|
|
289
|
+
- For collections that mutate constantly, prefer ETag/conditional GET over cache.
|
|
80
290
|
|
|
81
291
|
## Job & Queue Patterns
|
|
82
292
|
|
|
83
293
|
### Idempotency
|
|
84
294
|
|
|
85
295
|
```php
|
|
86
|
-
// Check state BEFORE processing
|
|
87
296
|
public function handle(): void
|
|
88
297
|
{
|
|
89
|
-
if ($this->record->already_processed)
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// Process...
|
|
94
|
-
|
|
298
|
+
if ($this->record->already_processed) return; // skip — idempotent
|
|
299
|
+
$this->process();
|
|
95
300
|
$this->record->update(['already_processed' => true]);
|
|
96
301
|
}
|
|
97
302
|
```
|
|
@@ -99,7 +304,6 @@ public function handle(): void
|
|
|
99
304
|
### Batch Processing
|
|
100
305
|
|
|
101
306
|
```php
|
|
102
|
-
// Process in chunks for memory control
|
|
103
307
|
Lead::query()
|
|
104
308
|
->where('status', 'pending')
|
|
105
309
|
->chunkById(100, function ($leads) {
|
|
@@ -110,10 +314,9 @@ Lead::query()
|
|
|
110
314
|
```
|
|
111
315
|
|
|
112
316
|
**Rules:**
|
|
113
|
-
- Jobs MUST be idempotent (safe to retry)
|
|
114
|
-
- Use unique keys for external API calls
|
|
115
|
-
-
|
|
116
|
-
- Batch/chunk for high-volume data
|
|
317
|
+
- Jobs MUST be idempotent (safe to retry).
|
|
318
|
+
- Use unique keys for external API calls.
|
|
319
|
+
- Batch / chunk for high-volume data.
|
|
117
320
|
|
|
118
321
|
## Data Integrity
|
|
119
322
|
|
|
@@ -136,6 +339,37 @@ protected $casts = [
|
|
|
136
339
|
|
|
137
340
|
Before sending data to external partners (Google, Meta, etc.):
|
|
138
341
|
1. Validate data status
|
|
139
|
-
2. Filter bots/invalid entries
|
|
342
|
+
2. Filter bots / invalid entries
|
|
140
343
|
3. Apply quality thresholds
|
|
141
344
|
4. Use unique keys to prevent duplicates
|
|
345
|
+
|
|
346
|
+
## Versioning (when you outgrow `/api`)
|
|
347
|
+
|
|
348
|
+
```
|
|
349
|
+
/api/v1/orders # current
|
|
350
|
+
/api/v2/orders # breaking changes
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
**Rule:** Add a version prefix only when breaking changes are imminent. Don't
|
|
354
|
+
preemptively version every endpoint.
|
|
355
|
+
|
|
356
|
+
## Checklist — Designing Any New Endpoint
|
|
357
|
+
|
|
358
|
+
- [ ] Verb + URL match REST conventions (or it's an explicit action endpoint)
|
|
359
|
+
- [ ] Behind `auth:sanctum` (and `throttle:api`) by default
|
|
360
|
+
- [ ] FormRequest with `rules()` + `authorize()` (Policy)
|
|
361
|
+
- [ ] Service does the work; Controller just orchestrates
|
|
362
|
+
- [ ] Returns `JsonResource` / `JsonResource::collection`
|
|
363
|
+
- [ ] Pagination uses `->paginate()` returning `data + meta + links`
|
|
364
|
+
- [ ] Dates formatted via `FormatsDatesForApi`
|
|
365
|
+
- [ ] Sensitive fields hidden (`$hidden` on model + whitelist in Resource)
|
|
366
|
+
- [ ] PHPUnit feature test for happy + 422 + 403 + 401 cases
|
|
367
|
+
|
|
368
|
+
## See Also
|
|
369
|
+
|
|
370
|
+
- `laravel-api-architecture` — the full Controller→Service→Resource pipeline
|
|
371
|
+
- `axios-laravel-api` — frontend client + CSRF/cookie + interceptors
|
|
372
|
+
- `react-api-standards` — page contract that consumes these endpoints
|
|
373
|
+
- `api-security` — Sanctum config, CORS, rate limiting, token abilities
|
|
374
|
+
- `external-api-patterns` — consuming OTHER APIs from your Laravel app
|
|
375
|
+
- `openapi-design` — describing the contract in OpenAPI 3.1
|
|
@@ -1,58 +1,124 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: api-security
|
|
3
|
-
version:
|
|
3
|
+
version: 2.0.0
|
|
4
|
+
description: Laravel 12 + Sanctum + Octane API hardening — cookie-based SPA auth
|
|
5
|
+
by default, token auth for mobile/3rd-party, CORS/CSRF/HSTS/CSP, rate limiting,
|
|
6
|
+
brute-force protection, audit logging, encryption at rest. Use when building
|
|
7
|
+
any auth endpoint, public API, or handling user input. Pairs with
|
|
8
|
+
`laravel-api-architecture` and `axios-laravel-api`.
|
|
4
9
|
---
|
|
5
10
|
|
|
6
|
-
# API Security — NSA-Level Hardening for Laravel + Octane
|
|
11
|
+
# API Security — NSA-Level Hardening for Laravel 12 + Octane
|
|
7
12
|
|
|
8
13
|
**ALWAYS invoke when building APIs, auth endpoints, or handling user input.**
|
|
9
14
|
|
|
10
15
|
## Security Layers
|
|
11
16
|
|
|
12
17
|
```
|
|
13
|
-
Internet →
|
|
14
|
-
→
|
|
18
|
+
Internet → CDN/WAF → Rate Limiter → CORS → Auth Middleware
|
|
19
|
+
→ CSRF (cookie clients) → FormRequest validation → Policy
|
|
20
|
+
→ Service business logic → Resource output sanitization → JSON
|
|
15
21
|
|
|
16
22
|
Every layer is a wall. Assume the previous one failed.
|
|
17
23
|
```
|
|
18
24
|
|
|
19
|
-
## 1. Authentication
|
|
25
|
+
## 1. Authentication
|
|
20
26
|
|
|
21
|
-
###
|
|
27
|
+
### Choose ONE per client type
|
|
28
|
+
|
|
29
|
+
| Client | Mode | Why |
|
|
30
|
+
|--------|------|-----|
|
|
31
|
+
| **Same / cross-origin React SPA** | **Sanctum SPA (cookie)** | XSS-safe HttpOnly cookie; no token storage in JS |
|
|
32
|
+
| Mobile app | Sanctum personal access token | No browser cookie context |
|
|
33
|
+
| 3rd-party server-to-server | Sanctum token (scoped abilities) | Long-lived; per-key access |
|
|
34
|
+
|
|
35
|
+
### A. Sanctum SPA (Cookie) — DEFAULT for React
|
|
22
36
|
|
|
23
37
|
```php
|
|
38
|
+
// bootstrap/app.php
|
|
39
|
+
->withMiddleware(function (Middleware $middleware) {
|
|
40
|
+
$middleware->statefulApi(); // accepts session cookie on /api/*
|
|
41
|
+
})
|
|
42
|
+
|
|
24
43
|
// config/sanctum.php
|
|
25
|
-
'
|
|
26
|
-
'
|
|
44
|
+
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS',
|
|
45
|
+
'localhost,localhost:5173,127.0.0.1,127.0.0.1:8000'
|
|
46
|
+
)),
|
|
47
|
+
'guard' => ['web'],
|
|
48
|
+
|
|
49
|
+
// config/session.php — production
|
|
50
|
+
'driver' => 'cookie',
|
|
51
|
+
'http_only' => true, // MANDATORY: blocks JS access
|
|
52
|
+
'secure' => true, // MANDATORY in HTTPS prod
|
|
53
|
+
'same_site' => 'lax', // 'none' only if cross-origin AND HTTPS
|
|
54
|
+
'domain' => '.example.com', // shared across subdomains
|
|
55
|
+
|
|
56
|
+
// config/cors.php
|
|
57
|
+
'paths' => ['api/*', 'sanctum/csrf-cookie', 'login', 'logout'],
|
|
58
|
+
'allowed_origins' => [env('FRONTEND_URL')],
|
|
59
|
+
'supports_credentials' => true, // MANDATORY for cookie auth
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```php
|
|
63
|
+
// routes/web.php — login on web so session middleware runs
|
|
64
|
+
Route::post('/login', [AuthController::class, 'login'])->middleware('throttle:auth');
|
|
65
|
+
Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanctum');
|
|
66
|
+
|
|
67
|
+
// routes/api.php — protected JSON endpoints
|
|
68
|
+
Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
|
|
69
|
+
Route::apiResource('orders', OrderController::class);
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```php
|
|
74
|
+
// AuthController::login (regenerate session = anti-fixation)
|
|
75
|
+
if (! Auth::attempt($request->validated(), remember: true)) {
|
|
76
|
+
return response()->json(['message' => 'Invalid credentials'], 422);
|
|
77
|
+
}
|
|
78
|
+
$request->session()->regenerate();
|
|
79
|
+
return response()->json(['data' => UserResource::make($request->user())]);
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Rules:**
|
|
83
|
+
- `http_only: true` is non-negotiable — XSS cannot read the session cookie.
|
|
84
|
+
- `secure: true` in any HTTPS environment (so dev = false, prod = true).
|
|
85
|
+
- `SameSite=Lax` for same-origin SPAs. `SameSite=None; Secure` for cross-origin.
|
|
86
|
+
- Always `regenerate()` after login.
|
|
87
|
+
- Frontend must set `withCredentials: true` and `withXSRFToken: true` (Axios).
|
|
88
|
+
See `axios-laravel-api`.
|
|
89
|
+
|
|
90
|
+
### B. Sanctum Token (Mobile / 3rd-Party)
|
|
91
|
+
|
|
92
|
+
```php
|
|
93
|
+
// config/sanctum.php
|
|
94
|
+
'expiration' => 60 * 24, // 24h token expiry (MANDATORY for tokens)
|
|
95
|
+
'token_prefix' => 'app_', // prefix for log scanning + secret-scanner tools
|
|
27
96
|
|
|
28
97
|
// Issue token with abilities (least privilege)
|
|
29
98
|
$token = $user->createToken('api-client', [
|
|
30
99
|
'read:leads',
|
|
31
100
|
'write:leads',
|
|
32
|
-
//
|
|
101
|
+
// NEVER '*' — no wildcard abilities
|
|
33
102
|
])->plainTextToken;
|
|
34
103
|
|
|
35
|
-
//
|
|
104
|
+
// Per-route ability check
|
|
36
105
|
Route::middleware(['auth:sanctum', 'ability:read:leads'])->group(function () {
|
|
37
106
|
Route::get('/api/v1/leads', [LeadController::class, 'index']);
|
|
38
107
|
});
|
|
39
108
|
```
|
|
40
109
|
|
|
41
|
-
### Token Rotation
|
|
110
|
+
### Token Rotation (sensitive actions)
|
|
42
111
|
|
|
43
112
|
```php
|
|
44
|
-
|
|
45
|
-
public function changePassword(Request $request): JsonResponse
|
|
113
|
+
public function changePassword(ChangePasswordRequest $request): JsonResponse
|
|
46
114
|
{
|
|
47
115
|
// ... change password logic
|
|
48
116
|
|
|
49
|
-
|
|
50
|
-
$request->user()->tokens()->delete();
|
|
51
|
-
|
|
52
|
-
// Issue fresh token
|
|
117
|
+
$request->user()->tokens()->delete(); // revoke all
|
|
53
118
|
$newToken = $request->user()->createToken('session', ['*'])->plainTextToken;
|
|
54
119
|
|
|
55
|
-
return
|
|
120
|
+
return response()->json(['token' => $newToken]); // token clients
|
|
121
|
+
// Cookie clients: also call $request->session()->regenerate()
|
|
56
122
|
}
|
|
57
123
|
```
|
|
58
124
|
|
|
@@ -64,7 +130,6 @@ use Laravel\Octane\Facades\Octane;
|
|
|
64
130
|
|
|
65
131
|
public function boot(): void
|
|
66
132
|
{
|
|
67
|
-
// MANDATORY: flush auth state between requests
|
|
68
133
|
Octane::prepare(function ($sandbox) {
|
|
69
134
|
$sandbox->forgetScopedInstances();
|
|
70
135
|
$sandbox->flushDatabaseConnections();
|
|
@@ -184,27 +249,33 @@ Route::middleware('throttle:sensitive')->group(function () {
|
|
|
184
249
|
});
|
|
185
250
|
```
|
|
186
251
|
|
|
187
|
-
## 4. CORS (Restrictive)
|
|
252
|
+
## 4. CORS (Restrictive — credentials-aware)
|
|
188
253
|
|
|
189
254
|
```php
|
|
190
255
|
// config/cors.php
|
|
191
256
|
return [
|
|
192
|
-
'paths' => ['api/*'],
|
|
193
|
-
'allowed_methods' => ['GET', 'POST', 'PUT', '
|
|
257
|
+
'paths' => ['api/*', 'sanctum/csrf-cookie', 'login', 'logout'],
|
|
258
|
+
'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
|
194
259
|
'allowed_origins' => [
|
|
195
|
-
env('APP_URL'),
|
|
196
|
-
|
|
260
|
+
env('FRONTEND_URL', env('APP_URL')), // SPECIFIC origin, never '*'
|
|
261
|
+
],
|
|
262
|
+
'allowed_origins_patterns' => [
|
|
263
|
+
// optional: '#^https://([a-z0-9-]+\.)?example\.com$#'
|
|
197
264
|
],
|
|
198
265
|
'allowed_headers' => [
|
|
199
266
|
'Content-Type', 'Authorization', 'X-Requested-With',
|
|
200
|
-
'X-Timezone', 'Accept', 'X-CSRF-TOKEN',
|
|
267
|
+
'X-Timezone', 'Accept', 'X-XSRF-TOKEN', 'X-CSRF-TOKEN',
|
|
201
268
|
],
|
|
202
269
|
'exposed_headers' => ['X-Request-ID', 'Retry-After'],
|
|
203
|
-
'max_age' => 86400,
|
|
204
|
-
'supports_credentials' => true,
|
|
270
|
+
'max_age' => 86400, // 24h preflight cache
|
|
271
|
+
'supports_credentials' => true, // MANDATORY for cookie auth
|
|
205
272
|
];
|
|
206
273
|
```
|
|
207
274
|
|
|
275
|
+
**Critical:** `supports_credentials: true` is INCOMPATIBLE with
|
|
276
|
+
`allowed_origins: ['*']`. Browsers will reject the response. Always list
|
|
277
|
+
specific origins. For multiple subdomains, use `allowed_origins_patterns`.
|
|
278
|
+
|
|
208
279
|
## 5. Security Headers Middleware
|
|
209
280
|
|
|
210
281
|
```php
|
|
@@ -402,35 +473,43 @@ class RequestId
|
|
|
402
473
|
|
|
403
474
|
## Security Checklist — Before Deploy
|
|
404
475
|
|
|
405
|
-
- [ ]
|
|
406
|
-
- [ ]
|
|
407
|
-
- [ ]
|
|
408
|
-
- [ ]
|
|
409
|
-
- [ ]
|
|
476
|
+
- [ ] `statefulApi()` middleware enabled in `bootstrap/app.php`
|
|
477
|
+
- [ ] `SANCTUM_STATEFUL_DOMAINS` lists exact frontend hostnames
|
|
478
|
+
- [ ] `config/session.php`: `http_only=true`, `secure=true` (prod), `same_site=lax`
|
|
479
|
+
- [ ] `config/cors.php`: `supports_credentials=true`, specific `allowed_origins`
|
|
480
|
+
- [ ] All endpoints have `auth:sanctum` middleware (or explicit public reasoning)
|
|
481
|
+
- [ ] All input validated via FormRequest with Policy in `authorize()`
|
|
482
|
+
- [ ] Rate limiting: `throttle:auth` on `/login`, `throttle:api` on protected
|
|
483
|
+
- [ ] Brute force protection on login (progressive lockout)
|
|
410
484
|
- [ ] Sensitive fields `$hidden` on models
|
|
411
|
-
- [ ] API
|
|
412
|
-
- [ ] Audit logging on sensitive models
|
|
413
|
-
- [ ]
|
|
414
|
-
- [ ]
|
|
415
|
-
- [ ] No `env()`
|
|
485
|
+
- [ ] API tokens encrypted at rest (`'encrypted'` cast on `ApiCredential`)
|
|
486
|
+
- [ ] Audit logging on sensitive models (Auditable trait)
|
|
487
|
+
- [ ] Request ID tracking in all logs (X-Request-ID middleware)
|
|
488
|
+
- [ ] Security headers middleware (CSP, HSTS, X-Frame-Options, X-Content-Type)
|
|
489
|
+
- [ ] No `env()` outside `config/*.php`
|
|
416
490
|
- [ ] No `*` in token abilities
|
|
417
491
|
- [ ] No `$guarded = []` on models
|
|
418
|
-
- [ ]
|
|
419
|
-
- [ ]
|
|
420
|
-
- [ ] Webhook signatures verified
|
|
421
|
-
- [ ] Error responses don't expose
|
|
492
|
+
- [ ] No `Inertia::render()` for new endpoints (use API + Resource)
|
|
493
|
+
- [ ] No tokens in `localStorage` (cookie auth for SPAs)
|
|
494
|
+
- [ ] Webhook signatures verified before processing
|
|
495
|
+
- [ ] Error responses don't expose stack traces in production
|
|
422
496
|
|
|
423
497
|
## FORBIDDEN
|
|
424
498
|
|
|
425
499
|
| ❌ Don't | ✅ Do |
|
|
426
500
|
|---|---|
|
|
501
|
+
| Store JWT/Sanctum token in `localStorage` | HttpOnly session cookie (Sanctum SPA) |
|
|
502
|
+
| `'allowed_origins' => ['*']` with credentials | Specific origins or patterns |
|
|
503
|
+
| `axios.defaults.withCredentials = false` | `true` is MANDATORY for cookie auth |
|
|
504
|
+
| `same_site=none` over HTTP | `none` requires `secure=true` (HTTPS) |
|
|
427
505
|
| `$guarded = []` | Explicit `$fillable` |
|
|
428
|
-
| `'
|
|
429
|
-
| `
|
|
430
|
-
| `
|
|
431
|
-
|
|
|
432
|
-
|
|
|
433
|
-
| Store API keys in plaintext | `'encrypted'` cast |
|
|
506
|
+
| `createToken('x', ['*'])` | Specific abilities (least privilege) |
|
|
507
|
+
| `"WHERE email = '$email'"` | Parameterized queries / Eloquent |
|
|
508
|
+
| `dd($user)` in production | Structured `Log::info()` |
|
|
509
|
+
| Generic error responses leaking stack trace | Clean message + `request_id` for support |
|
|
510
|
+
| Store API keys in plaintext | `'encrypted'` cast on the column |
|
|
434
511
|
| Same rate limit for all endpoints | Progressive: auth(5/min) < api(60/min) |
|
|
435
|
-
| Trust `X-Forwarded-For` directly | Use trusted proxies config |
|
|
436
|
-
| No token expiry | 24h max
|
|
512
|
+
| Trust `X-Forwarded-For` directly | Use Laravel's trusted proxies config |
|
|
513
|
+
| No token expiry | 24h max + rotate on sensitive actions |
|
|
514
|
+
| Login route on `/api/*` | Login goes on `routes/web.php` (session middleware) |
|
|
515
|
+
| `Inertia::render()` for new endpoints | API + Resource consumed by Axios |
|