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.
- package/LICENSE +21 -0
- package/README.md +235 -0
- package/bin/cli.js +59 -0
- package/package.json +35 -0
- package/src/display.js +119 -0
- package/src/init.js +87 -0
- package/src/stats.js +48 -0
- package/templates/agents/security.md +307 -0
- package/templates/security/knowledge.md +75 -0
- package/templates/security/xp.json +63 -0
- package/templates/skills/commit/SKILL.md +54 -0
- package/templates/skills/memories/SKILL.md +69 -0
- package/templates/skills/mobile/SKILL.md +348 -0
- package/templates/skills/security/SKILL.md +424 -0
- package/templates/skills/webapp/SKILL.md +606 -0
|
@@ -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
|