thevoidforge 21.0.11 → 21.0.13
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/.claude/commands/ai.md +69 -0
- package/dist/.claude/commands/architect.md +121 -0
- package/dist/.claude/commands/assemble.md +201 -0
- package/dist/.claude/commands/assess.md +75 -0
- package/dist/.claude/commands/blueprint.md +135 -0
- package/dist/.claude/commands/build.md +116 -0
- package/dist/.claude/commands/campaign.md +201 -0
- package/dist/.claude/commands/cultivation.md +166 -0
- package/dist/.claude/commands/current.md +128 -0
- package/dist/.claude/commands/dangerroom.md +74 -0
- package/dist/.claude/commands/debrief.md +178 -0
- package/dist/.claude/commands/deploy.md +99 -0
- package/dist/.claude/commands/devops.md +143 -0
- package/dist/.claude/commands/gauntlet.md +140 -0
- package/dist/.claude/commands/git.md +104 -0
- package/dist/.claude/commands/grow.md +146 -0
- package/dist/.claude/commands/imagine.md +126 -0
- package/dist/.claude/commands/portfolio.md +50 -0
- package/dist/.claude/commands/prd.md +113 -0
- package/dist/.claude/commands/qa.md +107 -0
- package/dist/.claude/commands/review.md +151 -0
- package/dist/.claude/commands/security.md +100 -0
- package/dist/.claude/commands/test.md +96 -0
- package/dist/.claude/commands/thumper.md +116 -0
- package/dist/.claude/commands/treasury.md +100 -0
- package/dist/.claude/commands/ux.md +118 -0
- package/dist/.claude/commands/vault.md +189 -0
- package/dist/.claude/commands/void.md +108 -0
- package/dist/CHANGELOG.md +1918 -0
- package/dist/CLAUDE.md +250 -0
- package/dist/HOLOCRON.md +856 -0
- package/dist/VERSION.md +123 -0
- package/dist/docs/NAMING_REGISTRY.md +478 -0
- package/dist/docs/methods/AI_INTELLIGENCE.md +276 -0
- package/dist/docs/methods/ASSEMBLER.md +142 -0
- package/dist/docs/methods/BACKEND_ENGINEER.md +165 -0
- package/dist/docs/methods/BUILD_JOURNAL.md +185 -0
- package/dist/docs/methods/BUILD_PROTOCOL.md +426 -0
- package/dist/docs/methods/CAMPAIGN.md +568 -0
- package/dist/docs/methods/CONTEXT_MANAGEMENT.md +189 -0
- package/dist/docs/methods/DEEP_CURRENT.md +184 -0
- package/dist/docs/methods/DEVOPS_ENGINEER.md +295 -0
- package/dist/docs/methods/FIELD_MEDIC.md +261 -0
- package/dist/docs/methods/FORGE_ARTIST.md +108 -0
- package/dist/docs/methods/FORGE_KEEPER.md +268 -0
- package/dist/docs/methods/GAUNTLET.md +344 -0
- package/dist/docs/methods/GROWTH_STRATEGIST.md +466 -0
- package/dist/docs/methods/HEARTBEAT.md +168 -0
- package/dist/docs/methods/MCP_INTEGRATION.md +139 -0
- package/dist/docs/methods/MUSTER.md +148 -0
- package/dist/docs/methods/PRD_GENERATOR.md +186 -0
- package/dist/docs/methods/PRODUCT_DESIGN_FRONTEND.md +250 -0
- package/dist/docs/methods/QA_ENGINEER.md +337 -0
- package/dist/docs/methods/RELEASE_MANAGER.md +145 -0
- package/dist/docs/methods/SECURITY_AUDITOR.md +320 -0
- package/dist/docs/methods/SUB_AGENTS.md +335 -0
- package/dist/docs/methods/SYSTEMS_ARCHITECT.md +171 -0
- package/dist/docs/methods/TESTING.md +359 -0
- package/dist/docs/methods/THUMPER.md +175 -0
- package/dist/docs/methods/TIME_VAULT.md +120 -0
- package/dist/docs/methods/TREASURY.md +184 -0
- package/dist/docs/methods/TROUBLESHOOTING.md +265 -0
- package/dist/docs/patterns/README.md +52 -0
- package/dist/docs/patterns/ad-billing-adapter.ts +537 -0
- package/dist/docs/patterns/ad-platform-adapter.ts +421 -0
- package/dist/docs/patterns/ai-classifier.ts +195 -0
- package/dist/docs/patterns/ai-eval.ts +272 -0
- package/dist/docs/patterns/ai-orchestrator.ts +341 -0
- package/dist/docs/patterns/ai-router.ts +194 -0
- package/dist/docs/patterns/ai-tool-schema.ts +237 -0
- package/dist/docs/patterns/api-route.ts +241 -0
- package/dist/docs/patterns/backtest-engine.ts +499 -0
- package/dist/docs/patterns/browser-review.ts +292 -0
- package/dist/docs/patterns/combobox.tsx +300 -0
- package/dist/docs/patterns/component.tsx +262 -0
- package/dist/docs/patterns/daemon-process.ts +338 -0
- package/dist/docs/patterns/data-pipeline.ts +297 -0
- package/dist/docs/patterns/database-migration.ts +466 -0
- package/dist/docs/patterns/e2e-test.ts +629 -0
- package/dist/docs/patterns/error-handling.ts +312 -0
- package/dist/docs/patterns/execution-safety.ts +601 -0
- package/dist/docs/patterns/financial-transaction.ts +342 -0
- package/dist/docs/patterns/funding-plan.ts +462 -0
- package/dist/docs/patterns/game-entity.ts +137 -0
- package/dist/docs/patterns/game-loop.ts +113 -0
- package/dist/docs/patterns/game-state.ts +143 -0
- package/dist/docs/patterns/job-queue.ts +225 -0
- package/dist/docs/patterns/kongo-integration.ts +164 -0
- package/dist/docs/patterns/middleware.ts +363 -0
- package/dist/docs/patterns/mobile-screen.tsx +139 -0
- package/dist/docs/patterns/mobile-service.ts +167 -0
- package/dist/docs/patterns/multi-tenant.ts +382 -0
- package/dist/docs/patterns/oauth-token-lifecycle.ts +223 -0
- package/dist/docs/patterns/outbound-rate-limiter.ts +260 -0
- package/dist/docs/patterns/prompt-template.ts +195 -0
- package/dist/docs/patterns/revenue-source-adapter.ts +311 -0
- package/dist/docs/patterns/service.ts +224 -0
- package/dist/docs/patterns/sse-endpoint.ts +118 -0
- package/dist/docs/patterns/stablecoin-adapter.ts +511 -0
- package/dist/docs/patterns/third-party-script.ts +68 -0
- package/dist/scripts/thumper/gom-jabbar.sh +241 -0
- package/dist/scripts/thumper/relay.sh +610 -0
- package/dist/scripts/thumper/scan.sh +359 -0
- package/dist/scripts/thumper/thumper.sh +190 -0
- package/dist/scripts/thumper/water-rings.sh +76 -0
- package/dist/wizard/ui/index.html +1 -1
- package/package.json +1 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern: Middleware (Auth + Request Logging)
|
|
3
|
+
*
|
|
4
|
+
* Key principles:
|
|
5
|
+
* - Auth middleware runs before route handlers
|
|
6
|
+
* - Request logging captures method, path, status, duration
|
|
7
|
+
* - Structured JSON logs with requestId for tracing
|
|
8
|
+
* - Never log sensitive data (passwords, tokens, PII)
|
|
9
|
+
* - Fail closed — if auth check errors, deny access
|
|
10
|
+
*
|
|
11
|
+
* Agents: Kenobi (security), Barton (error handling), L (monitoring)
|
|
12
|
+
*
|
|
13
|
+
* Framework adaptations:
|
|
14
|
+
* Next.js: This file (middleware.ts in root, NextRequest/NextResponse)
|
|
15
|
+
* Express: app.use(authMiddleware), app.use(requestLogger) — (err, req, res, next) for errors
|
|
16
|
+
* Django: MIDDLEWARE list in settings.py, or decorators (@login_required, @permission_required)
|
|
17
|
+
* Rails: before_action in controllers, Rack middleware for logging
|
|
18
|
+
*
|
|
19
|
+
* === Django Deep Dive ===
|
|
20
|
+
*
|
|
21
|
+
* # app/middleware.py
|
|
22
|
+
* import time, json, logging, uuid
|
|
23
|
+
*
|
|
24
|
+
* class RequestLoggingMiddleware:
|
|
25
|
+
* def __init__(self, get_response):
|
|
26
|
+
* self.get_response = get_response
|
|
27
|
+
* def __call__(self, request):
|
|
28
|
+
* request.request_id = str(uuid.uuid4())
|
|
29
|
+
* start = time.monotonic()
|
|
30
|
+
* response = self.get_response(request)
|
|
31
|
+
* duration = time.monotonic() - start
|
|
32
|
+
* logging.info(json.dumps({
|
|
33
|
+
* "request_id": request.request_id, "method": request.method,
|
|
34
|
+
* "path": request.path, "status": response.status_code,
|
|
35
|
+
* "duration_ms": round(duration * 1000),
|
|
36
|
+
* "user_id": getattr(request.user, "id", None),
|
|
37
|
+
* }))
|
|
38
|
+
* return response
|
|
39
|
+
*
|
|
40
|
+
* # settings.py: MIDDLEWARE = ['app.middleware.RequestLoggingMiddleware', ...]
|
|
41
|
+
* # Order matters: logging before auth, auth before rate limiting
|
|
42
|
+
* # @login_required is per-view — use for fine-grained control, not global middleware
|
|
43
|
+
*
|
|
44
|
+
* === FastAPI Deep Dive ===
|
|
45
|
+
*
|
|
46
|
+
* from fastapi import Request
|
|
47
|
+
* from starlette.middleware.base import BaseHTTPMiddleware
|
|
48
|
+
* import time, uuid
|
|
49
|
+
*
|
|
50
|
+
* class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
|
51
|
+
* async def dispatch(self, request: Request, call_next):
|
|
52
|
+
* request.state.request_id = str(uuid.uuid4())
|
|
53
|
+
* start = time.monotonic()
|
|
54
|
+
* response = await call_next(request)
|
|
55
|
+
* duration = time.monotonic() - start
|
|
56
|
+
* # structured log here
|
|
57
|
+
* return response
|
|
58
|
+
*
|
|
59
|
+
* # app.add_middleware(RequestLoggingMiddleware)
|
|
60
|
+
* # For auth: prefer Depends() over middleware — dependency injection is more testable
|
|
61
|
+
* # For rate limiting: slowapi or custom Depends() with Redis
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
65
|
+
import { verifySessionToken } from '@/lib/auth'
|
|
66
|
+
import { nanoid } from 'nanoid'
|
|
67
|
+
|
|
68
|
+
// --- Auth middleware ---
|
|
69
|
+
export async function authMiddleware(req: NextRequest) {
|
|
70
|
+
const token = req.cookies.get('session')?.value
|
|
71
|
+
|
|
72
|
+
if (!token) {
|
|
73
|
+
return NextResponse.json(
|
|
74
|
+
{ error: { code: 'UNAUTHORIZED', message: 'Authentication required' } },
|
|
75
|
+
{ status: 401 }
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
// Fail closed — if verification throws, deny access (Kenobi)
|
|
81
|
+
const session = await verifySessionToken(token)
|
|
82
|
+
|
|
83
|
+
if (!session) {
|
|
84
|
+
return NextResponse.json(
|
|
85
|
+
{ error: { code: 'UNAUTHORIZED', message: 'Invalid session' } },
|
|
86
|
+
{ status: 401 }
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Attach session to request headers for downstream use
|
|
91
|
+
const response = NextResponse.next()
|
|
92
|
+
response.headers.set('x-user-id', session.userId)
|
|
93
|
+
response.headers.set('x-request-id', nanoid())
|
|
94
|
+
return response
|
|
95
|
+
} catch {
|
|
96
|
+
// Auth errors = deny, don't expose details
|
|
97
|
+
return NextResponse.json(
|
|
98
|
+
{ error: { code: 'UNAUTHORIZED', message: 'Authentication required' } },
|
|
99
|
+
{ status: 401 }
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- Request logging middleware ---
|
|
105
|
+
export function withRequestLogging(
|
|
106
|
+
handler: (req: NextRequest) => Promise<NextResponse>
|
|
107
|
+
) {
|
|
108
|
+
return async (req: NextRequest): Promise<NextResponse> => {
|
|
109
|
+
const requestId = nanoid()
|
|
110
|
+
const start = Date.now()
|
|
111
|
+
|
|
112
|
+
// Add request ID for tracing
|
|
113
|
+
const url = new URL(req.url)
|
|
114
|
+
|
|
115
|
+
let response: NextResponse
|
|
116
|
+
let error: unknown = null
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
response = await handler(req)
|
|
120
|
+
} catch (err) {
|
|
121
|
+
error = err
|
|
122
|
+
response = NextResponse.json(
|
|
123
|
+
{ error: { code: 'INTERNAL_ERROR', message: 'Something went wrong' } },
|
|
124
|
+
{ status: 500 }
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const duration = Date.now() - start
|
|
129
|
+
|
|
130
|
+
// Structured log — JSON, parseable, includes tracing context (L — monitoring)
|
|
131
|
+
const logEntry = {
|
|
132
|
+
requestId,
|
|
133
|
+
method: req.method,
|
|
134
|
+
path: url.pathname,
|
|
135
|
+
status: response.status,
|
|
136
|
+
duration,
|
|
137
|
+
userId: req.headers.get('x-user-id') || undefined,
|
|
138
|
+
// Never log: request body, auth tokens, cookies, PII (Padme — data protection)
|
|
139
|
+
...(error instanceof Error && { error: error.message }),
|
|
140
|
+
...(duration > 1000 && { slow: true }), // Flag slow requests
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (response.status >= 500) {
|
|
144
|
+
console.error(JSON.stringify(logEntry))
|
|
145
|
+
} else if (response.status >= 400) {
|
|
146
|
+
console.warn(JSON.stringify(logEntry))
|
|
147
|
+
} else {
|
|
148
|
+
console.log(JSON.stringify(logEntry))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Attach request ID to response for client-side debugging
|
|
152
|
+
response.headers.set('x-request-id', requestId)
|
|
153
|
+
return response
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- Rate limiting middleware ---
|
|
158
|
+
// Simple in-memory rate limiter. Replace with Redis for multi-instance.
|
|
159
|
+
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
|
|
160
|
+
|
|
161
|
+
export function rateLimit(
|
|
162
|
+
req: NextRequest,
|
|
163
|
+
{ limit = 60, windowMs = 60_000 }: { limit?: number; windowMs?: number } = {}
|
|
164
|
+
): NextResponse | null {
|
|
165
|
+
// Rate limit by IP or user ID (Ahsoka — access control)
|
|
166
|
+
// IP extraction priority: cf-connecting-ip (Cloudflare, set at edge, cannot be spoofed by client)
|
|
167
|
+
// > x-real-ip (nginx) > x-forwarded-for first entry (client-spoofable) > fallback
|
|
168
|
+
const key =
|
|
169
|
+
req.headers.get('x-user-id') ||
|
|
170
|
+
req.headers.get('cf-connecting-ip') ||
|
|
171
|
+
req.headers.get('x-real-ip') ||
|
|
172
|
+
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
|
173
|
+
'unknown'
|
|
174
|
+
const now = Date.now()
|
|
175
|
+
|
|
176
|
+
const entry = rateLimitMap.get(key)
|
|
177
|
+
|
|
178
|
+
if (!entry || now > entry.resetAt) {
|
|
179
|
+
rateLimitMap.set(key, { count: 1, resetAt: now + windowMs })
|
|
180
|
+
return null // Allowed
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
entry.count++
|
|
184
|
+
|
|
185
|
+
if (entry.count > limit) {
|
|
186
|
+
return NextResponse.json(
|
|
187
|
+
{ error: { code: 'RATE_LIMITED', message: 'Too many requests' } },
|
|
188
|
+
{
|
|
189
|
+
status: 429,
|
|
190
|
+
headers: {
|
|
191
|
+
'Retry-After': String(Math.ceil((entry.resetAt - now) / 1000)),
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return null // Allowed
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// =============================================================================
|
|
201
|
+
// Cookie Authentication
|
|
202
|
+
// =============================================================================
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 1. Setting HttpOnly Cookies on Login
|
|
206
|
+
*
|
|
207
|
+
* After successful authentication, set the JWT in an HttpOnly cookie.
|
|
208
|
+
* HttpOnly prevents XSS from reading the token. Secure ensures HTTPS only.
|
|
209
|
+
* SameSite=Lax blocks cross-site POST requests while allowing top-level navigation.
|
|
210
|
+
*
|
|
211
|
+
* === Express / Next.js API Route ===
|
|
212
|
+
*
|
|
213
|
+
* // POST /api/auth/login
|
|
214
|
+
* const token = await createSessionToken(user.id)
|
|
215
|
+
* res.setHeader('Set-Cookie', [
|
|
216
|
+
* `session=${token}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${7 * 24 * 60 * 60}`,
|
|
217
|
+
* ])
|
|
218
|
+
* // Next.js App Router: use cookies() from 'next/headers'
|
|
219
|
+
* // cookies().set('session', token, {
|
|
220
|
+
* // httpOnly: true, secure: true, sameSite: 'lax',
|
|
221
|
+
* // path: '/', maxAge: 7 * 24 * 60 * 60,
|
|
222
|
+
* // })
|
|
223
|
+
* res.json({ user: { id: user.id, email: user.email } })
|
|
224
|
+
*
|
|
225
|
+
* === Django / FastAPI ===
|
|
226
|
+
*
|
|
227
|
+
* # Django view
|
|
228
|
+
* response = JsonResponse({"user": {"id": user.id}})
|
|
229
|
+
* response.set_cookie("session", token, httponly=True, secure=True,
|
|
230
|
+
* samesite="Lax", max_age=7 * 24 * 60 * 60)
|
|
231
|
+
*
|
|
232
|
+
* # FastAPI endpoint
|
|
233
|
+
* response = JSONResponse({"user": {"id": user.id}})
|
|
234
|
+
* response.set_cookie("session", token, httponly=True, secure=True,
|
|
235
|
+
* samesite="lax", max_age=7 * 24 * 60 * 60)
|
|
236
|
+
*/
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 2. Reading Cookies with Bearer Fallback
|
|
240
|
+
*
|
|
241
|
+
* Check cookie first (browser clients), then Authorization header (API clients).
|
|
242
|
+
* This lets the same endpoint serve both contexts without separate auth flows.
|
|
243
|
+
*/
|
|
244
|
+
export async function authMiddlewareWithCookieFallback(req: NextRequest) {
|
|
245
|
+
// Cookie takes priority — browser clients send this automatically
|
|
246
|
+
let token = req.cookies.get('session')?.value
|
|
247
|
+
|
|
248
|
+
// Bearer fallback — API clients, mobile apps, server-to-server
|
|
249
|
+
if (!token) {
|
|
250
|
+
const authHeader = req.headers.get('authorization')
|
|
251
|
+
if (authHeader?.startsWith('Bearer ')) {
|
|
252
|
+
token = authHeader.slice(7)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (!token) {
|
|
257
|
+
return NextResponse.json(
|
|
258
|
+
{ error: { code: 'UNAUTHORIZED', message: 'Authentication required' } },
|
|
259
|
+
{ status: 401 }
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
// Fail closed — same principle as authMiddleware (Kenobi)
|
|
265
|
+
const session = await verifySessionToken(token)
|
|
266
|
+
if (!session) {
|
|
267
|
+
return NextResponse.json(
|
|
268
|
+
{ error: { code: 'UNAUTHORIZED', message: 'Invalid session' } },
|
|
269
|
+
{ status: 401 }
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const response = NextResponse.next()
|
|
274
|
+
response.headers.set('x-user-id', session.userId)
|
|
275
|
+
response.headers.set('x-request-id', nanoid())
|
|
276
|
+
return response
|
|
277
|
+
} catch {
|
|
278
|
+
return NextResponse.json(
|
|
279
|
+
{ error: { code: 'UNAUTHORIZED', message: 'Authentication required' } },
|
|
280
|
+
{ status: 401 }
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* 3. CSRF Protection via Custom Header
|
|
287
|
+
*
|
|
288
|
+
* SameSite=Lax already blocks cross-site POSTs, but defense-in-depth matters (Kenobi).
|
|
289
|
+
* Require X-Requested-With on all mutation endpoints. Browsers enforce CORS preflight
|
|
290
|
+
* on custom headers, so a cross-origin attacker cannot set this without server consent.
|
|
291
|
+
*
|
|
292
|
+
* === Django / FastAPI ===
|
|
293
|
+
*
|
|
294
|
+
* # Django: use built-in CsrfViewMiddleware OR check custom header
|
|
295
|
+
* class CSRFCustomHeaderMiddleware:
|
|
296
|
+
* def __call__(self, request):
|
|
297
|
+
* if request.method in ("POST", "PUT", "DELETE", "PATCH"):
|
|
298
|
+
* if request.headers.get("X-Requested-With") != "XMLHttpRequest":
|
|
299
|
+
* return JsonResponse({"error": "CSRF check failed"}, status=403)
|
|
300
|
+
* return self.get_response(request)
|
|
301
|
+
*
|
|
302
|
+
* # FastAPI: use Depends() for the same check
|
|
303
|
+
* async def require_csrf_header(request: Request):
|
|
304
|
+
* if request.method in ("POST", "PUT", "DELETE", "PATCH"):
|
|
305
|
+
* if request.headers.get("x-requested-with") != "XMLHttpRequest":
|
|
306
|
+
* raise HTTPException(403, "CSRF check failed")
|
|
307
|
+
*/
|
|
308
|
+
const MUTATION_METHODS = new Set(['POST', 'PUT', 'DELETE', 'PATCH'])
|
|
309
|
+
|
|
310
|
+
export function csrfProtection(req: NextRequest): NextResponse | null {
|
|
311
|
+
if (!MUTATION_METHODS.has(req.method)) {
|
|
312
|
+
return null // GET/HEAD/OPTIONS — no CSRF risk
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Cookie-based auth requires the custom header; Bearer tokens are not vulnerable
|
|
316
|
+
const hasCookie = req.cookies.has('session')
|
|
317
|
+
const hasCustomHeader = req.headers.get('x-requested-with') === 'XMLHttpRequest'
|
|
318
|
+
|
|
319
|
+
if (hasCookie && !hasCustomHeader) {
|
|
320
|
+
return NextResponse.json(
|
|
321
|
+
{ error: { code: 'CSRF_FAILED', message: 'Missing X-Requested-With header' } },
|
|
322
|
+
{ status: 403 }
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return null // Allowed
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* 4. Frontend Fetch Configuration
|
|
331
|
+
*
|
|
332
|
+
* When using cookie auth, every fetch() call must include credentials: 'include'.
|
|
333
|
+
* Without it, the browser won't send or accept cookies on cross-origin requests.
|
|
334
|
+
* This typed wrapper enforces that and sets the CSRF header automatically.
|
|
335
|
+
*/
|
|
336
|
+
type ApiResponse<T> = { data: T } | { error: { code: string; message: string } }
|
|
337
|
+
|
|
338
|
+
async function apiFetch<T>(
|
|
339
|
+
path: string,
|
|
340
|
+
options: RequestInit = {}
|
|
341
|
+
): Promise<ApiResponse<T>> {
|
|
342
|
+
const response = await fetch(path, {
|
|
343
|
+
...options,
|
|
344
|
+
credentials: 'include', // Required — sends cookies with every request
|
|
345
|
+
headers: {
|
|
346
|
+
'Content-Type': 'application/json',
|
|
347
|
+
'X-Requested-With': 'XMLHttpRequest', // CSRF protection for cookie auth
|
|
348
|
+
...options.headers,
|
|
349
|
+
},
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
if (!response.ok) {
|
|
353
|
+
const body = await response.json().catch(() => ({}))
|
|
354
|
+
return { error: body.error ?? { code: 'UNKNOWN', message: response.statusText } }
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return { data: await response.json() }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Usage:
|
|
361
|
+
// const result = await apiFetch<{ user: User }>('/api/users/me')
|
|
362
|
+
// if ('error' in result) { handleError(result.error) }
|
|
363
|
+
// else { renderUser(result.data.user) }
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern: React Native Screen with All States
|
|
3
|
+
*
|
|
4
|
+
* Key principles:
|
|
5
|
+
* - Every screen handles: loading, empty, error, and success states (same as web component.tsx)
|
|
6
|
+
* - Safe area insets respected — content never under the notch or home indicator
|
|
7
|
+
* - Navigation follows platform conventions (back swipe iOS, hardware back Android)
|
|
8
|
+
* - Touch targets minimum 44pt (iOS) / 48dp (Android)
|
|
9
|
+
* - Keyboard avoidance on all forms
|
|
10
|
+
* - Supports Dynamic Type / system font scaling
|
|
11
|
+
* - Reduced motion respected via useReducedMotion()
|
|
12
|
+
*
|
|
13
|
+
* Agents: Legolas (code), Samwise-Mobile (a11y), Bilbo (copy), Gimli (performance)
|
|
14
|
+
*
|
|
15
|
+
* Framework adaptations:
|
|
16
|
+
* React Native: This file
|
|
17
|
+
* Flutter: Same 4-state pattern with StatefulWidget, SafeArea, MediaQuery
|
|
18
|
+
* SwiftUI: NavigationStack, .safeAreaInset, @Environment(\.dynamicTypeSize)
|
|
19
|
+
*
|
|
20
|
+
* The framework changes, the principle doesn't:
|
|
21
|
+
* EVERY screen handles loading, empty, error, and success. Safe area is non-negotiable.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import React from 'react'
|
|
25
|
+
import {
|
|
26
|
+
View,
|
|
27
|
+
Text,
|
|
28
|
+
FlatList,
|
|
29
|
+
TouchableOpacity,
|
|
30
|
+
ActivityIndicator,
|
|
31
|
+
RefreshControl,
|
|
32
|
+
StyleSheet,
|
|
33
|
+
Platform,
|
|
34
|
+
AccessibilityInfo,
|
|
35
|
+
} from 'react-native'
|
|
36
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
37
|
+
import { useNavigation } from '@react-navigation/native'
|
|
38
|
+
|
|
39
|
+
// --- Types ---
|
|
40
|
+
interface Project {
|
|
41
|
+
id: string
|
|
42
|
+
name: string
|
|
43
|
+
description: string | null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface ProjectListScreenProps {
|
|
47
|
+
projects: Project[]
|
|
48
|
+
isLoading: boolean
|
|
49
|
+
error: string | null
|
|
50
|
+
onRefresh: () => void
|
|
51
|
+
onDelete: (id: string) => void
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function ProjectListScreen({
|
|
55
|
+
projects,
|
|
56
|
+
isLoading,
|
|
57
|
+
error,
|
|
58
|
+
onRefresh,
|
|
59
|
+
onDelete,
|
|
60
|
+
}: ProjectListScreenProps) {
|
|
61
|
+
const insets = useSafeAreaInsets()
|
|
62
|
+
const navigation = useNavigation()
|
|
63
|
+
|
|
64
|
+
// Loading state — full-screen spinner with a11y announcement
|
|
65
|
+
if (isLoading && projects.length === 0) {
|
|
66
|
+
return (
|
|
67
|
+
<View style={[styles.center, { paddingTop: insets.top }]} accessibilityRole="progressbar">
|
|
68
|
+
<ActivityIndicator size="large" />
|
|
69
|
+
<Text style={styles.loadingText} accessibilityLiveRegion="polite">
|
|
70
|
+
Loading projects...
|
|
71
|
+
</Text>
|
|
72
|
+
</View>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Error state — actionable, not just "something went wrong"
|
|
77
|
+
if (error) {
|
|
78
|
+
return (
|
|
79
|
+
<View style={[styles.center, { paddingTop: insets.top }]} accessibilityRole="alert">
|
|
80
|
+
<Text style={styles.errorTitle}>Couldn't load your projects</Text>
|
|
81
|
+
<Text style={styles.errorMessage}>{error}</Text>
|
|
82
|
+
<TouchableOpacity
|
|
83
|
+
onPress={onRefresh}
|
|
84
|
+
style={styles.retryButton}
|
|
85
|
+
accessibilityRole="button"
|
|
86
|
+
accessibilityLabel="Retry loading projects"
|
|
87
|
+
>
|
|
88
|
+
<Text style={styles.retryText}>Try Again</Text>
|
|
89
|
+
</TouchableOpacity>
|
|
90
|
+
</View>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Empty state — guide the user
|
|
95
|
+
if (projects.length === 0) {
|
|
96
|
+
return (
|
|
97
|
+
<View style={[styles.center, { paddingTop: insets.top }]}>
|
|
98
|
+
<Text style={styles.emptyTitle}>No projects yet</Text>
|
|
99
|
+
<Text style={styles.emptyMessage}>Create your first project to get started.</Text>
|
|
100
|
+
</View>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Success state — pull-to-refresh list
|
|
105
|
+
return (
|
|
106
|
+
<FlatList
|
|
107
|
+
data={projects}
|
|
108
|
+
keyExtractor={(item) => item.id}
|
|
109
|
+
contentContainerStyle={{ paddingTop: insets.top, paddingBottom: insets.bottom }}
|
|
110
|
+
refreshControl={<RefreshControl refreshing={isLoading} onRefresh={onRefresh} />}
|
|
111
|
+
renderItem={({ item }) => (
|
|
112
|
+
<TouchableOpacity
|
|
113
|
+
style={styles.card}
|
|
114
|
+
onPress={() => navigation.navigate('ProjectDetail', { id: item.id })}
|
|
115
|
+
accessibilityRole="button"
|
|
116
|
+
accessibilityLabel={`Open ${item.name}`}
|
|
117
|
+
// Touch target: minimum 44pt height enforced by card padding
|
|
118
|
+
>
|
|
119
|
+
<Text style={styles.cardTitle}>{item.name}</Text>
|
|
120
|
+
{item.description && <Text style={styles.cardDescription}>{item.description}</Text>}
|
|
121
|
+
</TouchableOpacity>
|
|
122
|
+
)}
|
|
123
|
+
/>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const styles = StyleSheet.create({
|
|
128
|
+
center: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
|
|
129
|
+
loadingText: { marginTop: 12, fontSize: 16, color: '#666' },
|
|
130
|
+
errorTitle: { fontSize: 18, fontWeight: '600', color: '#dc2626' },
|
|
131
|
+
errorMessage: { marginTop: 4, fontSize: 14, color: '#dc2626', textAlign: 'center' },
|
|
132
|
+
retryButton: { marginTop: 16, paddingHorizontal: 20, paddingVertical: 12, backgroundColor: '#dc2626', borderRadius: 8 },
|
|
133
|
+
retryText: { color: '#fff', fontWeight: '600' },
|
|
134
|
+
emptyTitle: { fontSize: 18, fontWeight: '600', color: '#111' },
|
|
135
|
+
emptyMessage: { marginTop: 4, fontSize: 14, color: '#666' },
|
|
136
|
+
card: { padding: 16, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: '#e5e7eb', minHeight: 44 },
|
|
137
|
+
cardTitle: { fontSize: 16, fontWeight: '600', color: '#111' },
|
|
138
|
+
cardDescription: { marginTop: 4, fontSize: 14, color: '#666' },
|
|
139
|
+
})
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern: Offline-First Mobile Service
|
|
3
|
+
*
|
|
4
|
+
* Key principles:
|
|
5
|
+
* - Local-first: reads always hit local storage (SQLite/MMKV), writes queue for sync
|
|
6
|
+
* - Optimistic UI: user sees the change immediately, sync happens in background
|
|
7
|
+
* - Conflict resolution: last-write-wins with timestamp, or manual merge for critical data
|
|
8
|
+
* - Network-aware: queue syncs when connection is available, pauses when offline
|
|
9
|
+
* - Retry with backoff: failed syncs retry 3x with exponential backoff, then queue for later
|
|
10
|
+
*
|
|
11
|
+
* Agents: Stark (service logic), Thor (sync queue), Barton (error handling)
|
|
12
|
+
*
|
|
13
|
+
* Framework adaptations:
|
|
14
|
+
* React Native: This file (SQLite via expo-sqlite or react-native-sqlite-storage, NetInfo)
|
|
15
|
+
* Flutter: sqflite + connectivity_plus, same pattern with Dart async
|
|
16
|
+
* SwiftUI: SwiftData or Core Data + NWPathMonitor, same pattern with async/await
|
|
17
|
+
*
|
|
18
|
+
* The framework changes, the principle doesn't:
|
|
19
|
+
* EVERY offline-capable service has: local store, sync queue, conflict resolution, retry.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// --- Types ---
|
|
23
|
+
interface SyncQueueItem {
|
|
24
|
+
id: string
|
|
25
|
+
action: 'create' | 'update' | 'delete'
|
|
26
|
+
entity: string
|
|
27
|
+
payload: Record<string, unknown>
|
|
28
|
+
timestamp: number
|
|
29
|
+
retries: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface SyncResult {
|
|
33
|
+
success: boolean
|
|
34
|
+
serverVersion?: Record<string, unknown>
|
|
35
|
+
conflict?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// --- Local Storage Layer ---
|
|
39
|
+
// In a real implementation, this would use expo-sqlite, WatermelonDB, or MMKV
|
|
40
|
+
|
|
41
|
+
class LocalStore {
|
|
42
|
+
/** Read from local SQLite — always available, even offline */
|
|
43
|
+
async get<T>(entity: string, id: string): Promise<T | null> {
|
|
44
|
+
// SELECT * FROM {entity} WHERE id = ? — local DB
|
|
45
|
+
return null as T | null // placeholder
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Write to local SQLite + queue for sync */
|
|
49
|
+
async set<T extends { id: string }>(entity: string, item: T): Promise<void> {
|
|
50
|
+
// INSERT OR REPLACE INTO {entity} — local DB
|
|
51
|
+
// Then queue the sync
|
|
52
|
+
await SyncQueue.enqueue({
|
|
53
|
+
id: item.id,
|
|
54
|
+
action: 'update',
|
|
55
|
+
entity,
|
|
56
|
+
payload: item as Record<string, unknown>,
|
|
57
|
+
timestamp: Date.now(),
|
|
58
|
+
retries: 0,
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Delete locally + queue for sync */
|
|
63
|
+
async delete(entity: string, id: string): Promise<void> {
|
|
64
|
+
// DELETE FROM {entity} WHERE id = ? — local DB
|
|
65
|
+
await SyncQueue.enqueue({
|
|
66
|
+
id,
|
|
67
|
+
action: 'delete',
|
|
68
|
+
entity,
|
|
69
|
+
payload: {},
|
|
70
|
+
timestamp: Date.now(),
|
|
71
|
+
retries: 0,
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** List all items of an entity — local only, instant */
|
|
76
|
+
async list<T>(entity: string): Promise<T[]> {
|
|
77
|
+
// SELECT * FROM {entity} — local DB
|
|
78
|
+
return [] as T[]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- Sync Queue ---
|
|
83
|
+
// Persisted to SQLite so it survives app restarts
|
|
84
|
+
|
|
85
|
+
class SyncQueue {
|
|
86
|
+
static async enqueue(item: SyncQueueItem): Promise<void> {
|
|
87
|
+
// INSERT INTO sync_queue — persisted locally
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
static async processQueue(apiClient: ApiClient): Promise<void> {
|
|
91
|
+
// 1. Read all pending items from sync_queue
|
|
92
|
+
// 2. For each item:
|
|
93
|
+
// a. Send to server via apiClient
|
|
94
|
+
// b. If success: remove from queue
|
|
95
|
+
// c. If conflict: resolve (last-write-wins or flag for user)
|
|
96
|
+
// d. If network error: increment retries, backoff
|
|
97
|
+
// e. If retries > 3: move to dead letter (show user "sync failed" badge)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
static async getPendingCount(): Promise<number> {
|
|
101
|
+
// SELECT COUNT(*) FROM sync_queue — for UI badge
|
|
102
|
+
return 0
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- Network-Aware Sync Manager ---
|
|
107
|
+
|
|
108
|
+
class SyncManager {
|
|
109
|
+
private intervalId: ReturnType<typeof setInterval> | null = null
|
|
110
|
+
|
|
111
|
+
/** Start periodic sync — call on app launch */
|
|
112
|
+
start(apiClient: ApiClient): void {
|
|
113
|
+
// Check connectivity, process queue if online
|
|
114
|
+
// Re-check every 30 seconds
|
|
115
|
+
this.intervalId = setInterval(() => {
|
|
116
|
+
// if (NetInfo.isConnected) SyncQueue.processQueue(apiClient)
|
|
117
|
+
}, 30_000)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Stop sync — call on app background or logout */
|
|
121
|
+
stop(): void {
|
|
122
|
+
if (this.intervalId) clearInterval(this.intervalId)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Force sync — call on pull-to-refresh */
|
|
126
|
+
async forceSync(apiClient: ApiClient): Promise<void> {
|
|
127
|
+
await SyncQueue.processQueue(apiClient)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- Conflict Resolution ---
|
|
132
|
+
|
|
133
|
+
function resolveConflict(local: SyncQueueItem, server: Record<string, unknown>): 'keep-local' | 'keep-server' {
|
|
134
|
+
// Last-write-wins: compare timestamps
|
|
135
|
+
const serverTimestamp = (server as { updatedAt?: number }).updatedAt ?? 0
|
|
136
|
+
return local.timestamp > serverTimestamp ? 'keep-local' : 'keep-server'
|
|
137
|
+
|
|
138
|
+
// For critical data (financial, medical), prefer 'flag-for-user' instead of auto-resolve
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// --- API Client (placeholder) ---
|
|
142
|
+
|
|
143
|
+
interface ApiClient {
|
|
144
|
+
sync(item: SyncQueueItem): Promise<SyncResult>
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// --- Usage ---
|
|
148
|
+
// const store = new LocalStore()
|
|
149
|
+
// const syncManager = new SyncManager()
|
|
150
|
+
//
|
|
151
|
+
// // On app launch:
|
|
152
|
+
// syncManager.start(apiClient)
|
|
153
|
+
//
|
|
154
|
+
// // Read (always local — instant, works offline):
|
|
155
|
+
// const projects = await store.list<Project>('projects')
|
|
156
|
+
//
|
|
157
|
+
// // Write (local + queue sync — optimistic, works offline):
|
|
158
|
+
// await store.set('projects', { id: '1', name: 'New Project' })
|
|
159
|
+
//
|
|
160
|
+
// // Pull to refresh (force sync):
|
|
161
|
+
// await syncManager.forceSync(apiClient)
|
|
162
|
+
//
|
|
163
|
+
// // Show sync badge:
|
|
164
|
+
// const pending = await SyncQueue.getPendingCount()
|
|
165
|
+
// if (pending > 0) showBadge(`${pending} changes pending sync`)
|
|
166
|
+
|
|
167
|
+
export { LocalStore, SyncQueue, SyncManager, resolveConflict }
|