ocs-stats 1.0.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.
@@ -0,0 +1,424 @@
1
+ ---
2
+ name: security
3
+ description: Security patterns, auth approach, and what NOT to do in this codebase
4
+ ---
5
+
6
+ # Security Patterns
7
+
8
+ ## 1. Authentication
9
+
10
+ ### Current State
11
+ - Frontend: Logto (OAuth/OIDC) handles user sessions
12
+ - Backend: `protectedProcedure` exists but NEVER used
13
+ - All 15 routers use `publicProcedure` (INSECURE for user data)
14
+
15
+ ### Code: protectedProcedure vs publicProcedure
16
+
17
+ ```typescript
18
+ // CURRENT (INSECURE) - used everywhere in this codebase
19
+ export const userRouter = router({
20
+ getProfile: publicProcedure
21
+ .input(z.object({ userId: z.string() }))
22
+ .query(({ input }) => {
23
+ // ❌ Anyone can access any user's profile!
24
+ return prisma.user.findUnique({ where: { id: input.userId } });
25
+ }),
26
+ });
27
+
28
+ // SHOULD BE - using protectedProcedure
29
+ export const userRouter = router({
30
+ getMyProfile: protectedProcedure
31
+ .query(({ ctx }) => {
32
+ // ✅ Only authenticated users can access
33
+ // ctx.user is guaranteed to be populated
34
+ return prisma.user.findUnique({ where: { id: ctx.user.id } });
35
+ }),
36
+ });
37
+ ```
38
+
39
+ ### Auth Context (trpc.ts)
40
+ ```typescript
41
+ // Current state - ctx.user is ALWAYS null!
42
+ export const createContext = async ({ req, res }) => {
43
+ return {
44
+ req,
45
+ res,
46
+ prisma,
47
+ user: null, // Will be populated when token verification is implemented
48
+ };
49
+ };
50
+
51
+ // Protected procedure always throws UNAUTHORIZED
52
+ export const protectedProcedure = t.procedure.use(async function isAuthed(opts) {
53
+ const { ctx } = opts;
54
+ if (!ctx.user) {
55
+ throw new TRPCError({ code: 'UNAUTHORIZED' });
56
+ }
57
+ return opts.next({ ctx: { user: ctx.user } });
58
+ });
59
+ ```
60
+
61
+ ### Rules
62
+ - ✅ Use `protectedProcedure` for any user-specific data
63
+ - ✅ Use `publicProcedure` only for truly public data (game info, item catalogs)
64
+ - ❌ NEVER use `publicProcedure` for: user profiles, settings, private data, mutations
65
+
66
+ ---
67
+
68
+ ## 2. Input Validation
69
+
70
+ ### Current Approach
71
+ - Zod on ALL inputs (this is good)
72
+ - Example patterns from codebase:
73
+
74
+ ```typescript
75
+ // String with length limits
76
+ z.string().min(3).max(80)
77
+ z.string().email()
78
+ z.string().uuid()
79
+ z.string().optional()
80
+
81
+ // Numbers with bounds
82
+ z.number().min(1).max(100)
83
+ z.number().min(1).max(50).default(20)
84
+
85
+ // Booleans with defaults
86
+ z.boolean().default(false)
87
+
88
+ // Enums for fixed values
89
+ z.enum(['OPEN', 'CLOSED', 'IN_PROGRESS'])
90
+ z.enum(['RANKED', 'CASUAL', 'CUSTOM', 'TOURNAMENT'])
91
+
92
+ // Arrays
93
+ z.array(z.string())
94
+ z.array(z.string()).min(1)
95
+
96
+ // Objects with nested validation
97
+ z.object({
98
+ user: z.object({
99
+ id: z.string(),
100
+ username: z.string().optional(),
101
+ avatarUrl: z.string().optional(),
102
+ avatarConfig: z.record(z.string(), z.any()).optional(),
103
+ isGuest: z.boolean().default(false),
104
+ })
105
+ })
106
+
107
+ // Nullable and optional
108
+ z.string().nullable()
109
+ z.string().optional()
110
+ z.string().optional().nullable()
111
+ ```
112
+
113
+ ### Real Examples from Codebase
114
+
115
+ ```typescript
116
+ // From lobby.ts - GOOD validation
117
+ .input(z.object({
118
+ logtoId: z.string(),
119
+ title: z.string().min(3).max(80),
120
+ gameSlug: z.string().min(1),
121
+ roomCode: z.string().max(50).optional(),
122
+ description: z.string().max(500).optional(),
123
+ maxPlayers: z.number().min(2).max(100).default(5),
124
+ isPrivate: z.boolean().default(false),
125
+ region: z.string().max(10).optional(),
126
+ requiresMic: z.boolean().default(false),
127
+ minRank: z.string().max(30).optional(),
128
+ ttlHours: z.number().min(1).max(24).default(DEFAULT_TTL_HOURS),
129
+ }))
130
+
131
+ // From users.ts - GOOD email validation
132
+ .input(z.object({
133
+ logtoId: z.string(),
134
+ email: z.string().email(),
135
+ username: z.string().nullable().optional(),
136
+ displayName: z.string().nullable().optional(),
137
+ avatarUrl: z.string().nullable().optional(),
138
+ }))
139
+ ```
140
+
141
+ ### Rules
142
+ - ✅ ALWAYS use `.input(z.object({...}))`
143
+ - ✅ Add `.min()` and `.max()` for strings
144
+ - ✅ Use `.email()` for email fields
145
+ - ✅ Use `.enum()` for limited choices
146
+ - ✅ Use `.default()` for optional fields
147
+ - ❌ NEVER skip input validation
148
+ - ❌ NEVER trust client-side validation only
149
+
150
+ ---
151
+
152
+ ## 3. Error Handling
153
+
154
+ ### Current Patterns - Good
155
+
156
+ ```typescript
157
+ // Using TRPCError (GOOD)
158
+ throw new TRPCError({
159
+ code: 'NOT_FOUND',
160
+ message: 'User not found'
161
+ });
162
+
163
+ throw new TRPCError({
164
+ code: 'FORBIDDEN',
165
+ message: 'Only the host can close this room.'
166
+ });
167
+
168
+ throw new TRPCError({
169
+ code: 'BAD_REQUEST',
170
+ message: 'Room is full.'
171
+ });
172
+
173
+ throw new TRPCError({
174
+ code: 'CONFLICT',
175
+ message: 'You are already in this room.'
176
+ });
177
+
178
+ // Using plain Error (AVOID for tRPC)
179
+ throw new Error('User not found');
180
+ ```
181
+
182
+ ### Best Practices
183
+ - Use `TRPCError` with appropriate codes:
184
+ - `NOT_FOUND` - Resource doesn't exist
185
+ - `UNAUTHORIZED` - Not logged in
186
+ - `FORBIDDEN` - Logged in but no permission
187
+ - `BAD_REQUEST` - Invalid input
188
+ - `CONFLICT` - State conflict (e.g., already joined)
189
+ - `INTERNAL_SERVER_ERROR` - Unexpected errors
190
+ - Never log sensitive data (passwords, tokens, secrets)
191
+ - Use generic messages for auth failures: "Invalid credentials" (not "User not found")
192
+
193
+ ### Anti-Patterns
194
+ ```typescript
195
+ // ❌ NEVER log passwords or secrets
196
+ console.log('Login attempt:', { username, password });
197
+
198
+ // ✅ CORRECT - log without sensitive data
199
+ console.log('Login attempt:', { username, expectedUsername: ADMIN_USERNAME });
200
+
201
+ // ❌ NEVER expose internal details in error messages
202
+ throw new TRPCError({
203
+ code: 'INTERNAL_SERVER_ERROR',
204
+ message: 'Database connection failed: ' + error.message // ❌ Leaks internals
205
+ });
206
+
207
+ // ✅ CORRECT - generic error message
208
+ throw new TRPCError({
209
+ code: 'INTERNAL_SERVER_ERROR',
210
+ message: 'An unexpected error occurred'
211
+ });
212
+ ```
213
+
214
+ ---
215
+
216
+ ## 4. Database Security
217
+
218
+ ### Prisma Patterns - Good
219
+
220
+ ```typescript
221
+ // Use select/include to limit exposure
222
+ prisma.user.findUnique({
223
+ where: { id },
224
+ select: {
225
+ id: true,
226
+ username: true,
227
+ email: true,
228
+ displayName: true,
229
+ avatarUrl: true
230
+ // ✅ Don't expose: walletAddress, internal IDs
231
+ }
232
+ });
233
+
234
+ // Unique constraints prevent enumeration
235
+ @unique logtoId // Prevent duplicate accounts
236
+ @unique email // Prevent duplicate emails
237
+ @unique walletAddress
238
+
239
+ // Cascade delete for relations - prevents orphaned data
240
+ user User @relation(..., onDelete: Cascade)
241
+
242
+ // Indexes for security (prevent slow queries that could be exploited)
243
+ @@index([userId])
244
+ @@index([status])
245
+ ```
246
+
247
+ ### Anti-Patterns
248
+ ```typescript
249
+ // ❌ DON'T: expose all fields
250
+ return prisma.user.findUnique({ where: { id } });
251
+
252
+ // ✅ CORRECT: select only needed fields
253
+ return prisma.user.findUnique({
254
+ where: { id },
255
+ select: { id: true, username: true, ... }
256
+ });
257
+
258
+ // ❌ DON'T: trust raw IDs without ownership check
259
+ .input(z.object({ userId: z.string() }))
260
+ .query(({ input }) => {
261
+ return prisma.user.findUnique({ where: { id: input.userId } });
262
+ // ❌ Anyone can query any user!
263
+ });
264
+
265
+ // ✅ CORRECT: only allow accessing own data
266
+ .input(z.object({}))
267
+ .query(({ ctx }) => {
268
+ return prisma.user.findUnique({ where: { id: ctx.user.id } });
269
+ // ✅ Only own profile
270
+ });
271
+ ```
272
+
273
+ ---
274
+
275
+ ## 5. API Key & Secrets
276
+
277
+ ### Environment Variables
278
+ Required in `.env`:
279
+ - `PANDASCORE_ACCESS_TOKEN` - Pandascore API
280
+ - `REDIS_URL` - Cache layer
281
+ - `DATABASE_URL` - PostgreSQL
282
+ - `LOGTO_ENDPOINT`, `LOGTO_APP_ID`, `LOGTO_APP_SECRET` - Auth
283
+ - `ADMIN_USERNAME`, `ADMIN_PASSWORD` - Admin panel (insecure!)
284
+
285
+ ### Rules
286
+ - ✅ Never commit `.env` files
287
+ - ✅ Use `.env.example` for template
288
+ - ✅ Validate env vars at startup
289
+ - ✅ Use environment variable syntax in configs: `{env:VARIABLE_NAME}`
290
+
291
+ ---
292
+
293
+ ## 6. Security Anti-Patterns (What NOT to Do)
294
+
295
+ | Anti-Pattern | Example | Why Bad |
296
+ |-------------|---------|---------|
297
+ | Skip auth | `publicProcedure` for user data | Anyone can access |
298
+ | Log secrets | `console.log({ password })` | Secrets in logs |
299
+ | Skip validation | No `.input()` | Invalid data enters DB |
300
+ | Expose internal IDs | Return raw `id` | Enumeration attacks |
301
+ | Trust client | `input.userId` without check | IDOR vulnerability |
302
+ | Weak admin auth | Plain username/password | Easy to crack |
303
+ | Expose internals | Error messages with stack traces | Information leakage |
304
+
305
+ ---
306
+
307
+ ## 7. Wallet & Signature Security
308
+
309
+ ### Current Implementation (wallet.ts)
310
+ ```typescript
311
+ // ✅ GOOD: Validates signature
312
+ .input(z.object({
313
+ logtoId: z.string(),
314
+ walletAddress: z.string(),
315
+ message: z.string(),
316
+ signature: z.string(),
317
+ }))
318
+ .mutation(({ input }) => {
319
+ // Validate message format
320
+ const parsed = JSON.parse(input.message);
321
+ if (parsed.address !== input.walletAddress) {
322
+ throw new Error('Wallet address in message does not match provided address.');
323
+ }
324
+
325
+ // Verify signature
326
+ const isValid = verifySignature(input.message, input.signature, input.walletAddress);
327
+ if (!isValid) {
328
+ throw new Error('Invalid signature.');
329
+ }
330
+ });
331
+ ```
332
+
333
+ ### Improvements to Consider
334
+ - Add nonce to prevent replay attacks
335
+ - Add expiration timestamp to messages
336
+ - Use EIP-712 typed data signing
337
+
338
+ ---
339
+
340
+ ## 8. Frontend Security (Next.js)
341
+
342
+ ### Auth Handling
343
+ ```typescript
344
+ // Using Logto server actions (GOOD)
345
+ import { signIn, signOut, handleSignIn, getLogtoContext } from '@logto/next/server-actions';
346
+
347
+ // Check auth in server components
348
+ const context = await getLogtoContext(logtoConfig, { fetchUserInfo: true });
349
+ if (!context.isAuthenticated) {
350
+ redirect('/');
351
+ }
352
+ ```
353
+
354
+ ### Environment Variables
355
+ - `LOGTO_ENDPOINT` - Auth server
356
+ - `LOGTO_APP_ID` - Public (safe to expose)
357
+ - `LOGTO_APP_SECRET` - SECRET (never expose)
358
+ - `LOGTO_COOKIE_SECRET` - SECRET
359
+
360
+ ### Rules
361
+ - ✅ Use server-side auth checks
362
+ - ✅ Pass auth tokens via headers when calling backend
363
+ - ❌ Never expose secrets in client-side code
364
+ - ❌ Never store tokens in localStorage (use httpOnly cookies)
365
+
366
+ ### LocalStorage/SessionStorage Safety
367
+
368
+ Never trust data from browser storage - users can edit it or it can become corrupted.
369
+
370
+ ```typescript
371
+ // ❌ BAD - Crashes on malformed JSON
372
+ const data = JSON.parse(localStorage.getItem('key'));
373
+
374
+ // ✅ GOOD - Try-catch with validation
375
+ try {
376
+ const raw = localStorage.getItem('key');
377
+ if (raw) {
378
+ const data = JSON.parse(raw);
379
+ if (data && typeof data === 'object') {
380
+ setState(data);
381
+ }
382
+ }
383
+ } catch {
384
+ // Invalid JSON - use defaults
385
+ }
386
+ ```
387
+
388
+ ---
389
+
390
+ ## 9. Fix-First Security Process
391
+
392
+ ### Process
393
+
394
+ 1. Audit code and identify vulnerabilities
395
+ 2. Document findings in `.opencode/security/knowledge.md` (NO XP awarded)
396
+ 3. Present findings to user with severity and file locations
397
+ 4. User selects which issues to fix
398
+ 5. Apply fixes and verify they work
399
+ 6. Award XP only after successful fix
400
+
401
+ ### Rules
402
+
403
+ - Never auto-fix without user request
404
+ - XP is earned through remediation, not discovery
405
+ - Always verify fixes don't introduce new issues
406
+ - Update knowledge.md to mark issues as fixed
407
+
408
+ ---
409
+
410
+ ## Summary
411
+
412
+ ### DO:
413
+ - Use `protectedProcedure` for user-specific operations
414
+ - Always validate inputs with Zod
415
+ - Use TRPCError with appropriate codes
416
+ - Limit exposed fields with `.select()`
417
+ - Log without sensitive data
418
+
419
+ ### DON'T:
420
+ - Use `publicProcedure` for private data
421
+ - Skip input validation
422
+ - Log passwords, tokens, or secrets
423
+ - Expose internal IDs or error details
424
+ - Trust client-provided data without validation