@skillmark/webapp 0.1.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.
Files changed (24) hide show
  1. package/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/cd45cc5264daa1c125545b5b4c0756df95d8b6ac5900ecf52323d90f61a47f2d.sqlite +0 -0
  2. package/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/fc50b649db51ed0c303ff2c4b7c0eca2da269cc3dfc7ce40615fc37a7b53366c.sqlite +0 -0
  3. package/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/fc50b649db51ed0c303ff2c4b7c0eca2da269cc3dfc7ce40615fc37a7b53366c.sqlite-shm +0 -0
  4. package/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/fc50b649db51ed0c303ff2c4b7c0eca2da269cc3dfc7ce40615fc37a7b53366c.sqlite-wal +0 -0
  5. package/.wrangler/tmp/bundle-lfa2r7/checked-fetch.js +30 -0
  6. package/.wrangler/tmp/bundle-lfa2r7/middleware-insertion-facade.js +11 -0
  7. package/.wrangler/tmp/bundle-lfa2r7/middleware-loader.entry.ts +134 -0
  8. package/.wrangler/tmp/bundle-lfa2r7/strip-cf-connecting-ip-header.js +13 -0
  9. package/.wrangler/tmp/dev-IDqSK4/worker-entry-point.js +4918 -0
  10. package/.wrangler/tmp/dev-IDqSK4/worker-entry-point.js.map +8 -0
  11. package/package.json +22 -0
  12. package/src/assets/favicon.png +0 -0
  13. package/src/assets/skillmark-thumb.png +0 -0
  14. package/src/db/d1-database-schema.sql +69 -0
  15. package/src/db/migrations/001-add-github-oauth-and-user-session-tables.sql +40 -0
  16. package/src/db/migrations/002-add-security-benchmark-columns.sql +30 -0
  17. package/src/db/migrations/003-add-repo-url-and-update-composite-formula.sql +27 -0
  18. package/src/routes/api-endpoints-handler.ts +380 -0
  19. package/src/routes/github-oauth-authentication-handler.ts +427 -0
  20. package/src/routes/html-pages-renderer.ts +2263 -0
  21. package/src/routes/static-assets-handler.ts +58 -0
  22. package/src/worker-entry-point.ts +143 -0
  23. package/tsconfig.json +19 -0
  24. package/wrangler.toml +19 -0
@@ -0,0 +1,427 @@
1
+ /**
2
+ * GitHub OAuth authentication handler for Skillmark
3
+ * Handles login flow, session management, and API key generation
4
+ */
5
+ import { Hono } from 'hono';
6
+ import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
7
+
8
+ type Bindings = {
9
+ DB: D1Database;
10
+ GITHUB_CLIENT_ID: string;
11
+ GITHUB_CLIENT_SECRET: string;
12
+ SESSION_SECRET: string;
13
+ ENVIRONMENT: string;
14
+ };
15
+
16
+ type Variables = {
17
+ user?: UserSession;
18
+ };
19
+
20
+ interface UserSession {
21
+ id: string;
22
+ githubId: number;
23
+ githubUsername: string;
24
+ githubAvatar: string | null;
25
+ }
26
+
27
+ interface GitHubUserResponse {
28
+ id: number;
29
+ login: string;
30
+ avatar_url: string;
31
+ email: string | null;
32
+ }
33
+
34
+ interface GitHubTokenResponse {
35
+ access_token: string;
36
+ token_type: string;
37
+ scope: string;
38
+ }
39
+
40
+ export const authRouter = new Hono<{ Bindings: Bindings; Variables: Variables }>();
41
+
42
+ const GITHUB_OAUTH_URL = 'https://github.com/login/oauth/authorize';
43
+ const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token';
44
+ const GITHUB_USER_URL = 'https://api.github.com/user';
45
+ const SESSION_COOKIE = 'skillmark_session';
46
+ const SESSION_DURATION_DAYS = 30;
47
+
48
+ /**
49
+ * GET /auth/github - Redirect to GitHub OAuth
50
+ */
51
+ authRouter.get('/github', (c) => {
52
+ const clientId = c.env.GITHUB_CLIENT_ID;
53
+ const baseUrl = getBaseUrl(c.req.url, c.env.ENVIRONMENT);
54
+ const redirectUri = `${baseUrl}/auth/github/callback`;
55
+
56
+ const params = new URLSearchParams({
57
+ client_id: clientId,
58
+ redirect_uri: redirectUri,
59
+ scope: 'read:user user:email',
60
+ state: generateState(),
61
+ });
62
+
63
+ return c.redirect(`${GITHUB_OAUTH_URL}?${params.toString()}`);
64
+ });
65
+
66
+ /**
67
+ * GET /auth/github/callback - Handle OAuth callback
68
+ */
69
+ authRouter.get('/github/callback', async (c) => {
70
+ const code = c.req.query('code');
71
+ const error = c.req.query('error');
72
+
73
+ if (error || !code) {
74
+ return c.redirect('/login?error=oauth_failed');
75
+ }
76
+
77
+ try {
78
+ const baseUrl = getBaseUrl(c.req.url, c.env.ENVIRONMENT);
79
+
80
+ // Exchange code for access token
81
+ const tokenResponse = await fetch(GITHUB_TOKEN_URL, {
82
+ method: 'POST',
83
+ headers: {
84
+ 'Content-Type': 'application/json',
85
+ 'Accept': 'application/json',
86
+ },
87
+ body: JSON.stringify({
88
+ client_id: c.env.GITHUB_CLIENT_ID,
89
+ client_secret: c.env.GITHUB_CLIENT_SECRET,
90
+ code,
91
+ redirect_uri: `${baseUrl}/auth/github/callback`,
92
+ }),
93
+ });
94
+
95
+ const tokenData = await tokenResponse.json() as GitHubTokenResponse;
96
+
97
+ if (!tokenData.access_token) {
98
+ console.error('Failed to get access token:', tokenData);
99
+ return c.redirect('/login?error=token_failed');
100
+ }
101
+
102
+ // Fetch user profile
103
+ const userResponse = await fetch(GITHUB_USER_URL, {
104
+ headers: {
105
+ 'Authorization': `Bearer ${tokenData.access_token}`,
106
+ 'Accept': 'application/vnd.github.v3+json',
107
+ 'User-Agent': 'Skillmark-OAuth',
108
+ },
109
+ });
110
+
111
+ const githubUser = await userResponse.json() as GitHubUserResponse;
112
+
113
+ // Create or update user in database
114
+ const userId = await upsertUser(c.env.DB, githubUser);
115
+
116
+ // Create session
117
+ const sessionId = await createSession(c.env.DB, userId);
118
+
119
+ // Set session cookie
120
+ setCookie(c, SESSION_COOKIE, sessionId, {
121
+ httpOnly: true,
122
+ secure: c.env.ENVIRONMENT === 'production',
123
+ sameSite: 'Lax',
124
+ maxAge: SESSION_DURATION_DAYS * 24 * 60 * 60,
125
+ path: '/',
126
+ });
127
+
128
+ return c.redirect('/dashboard');
129
+ } catch (err) {
130
+ console.error('OAuth callback error:', err);
131
+ return c.redirect('/login?error=oauth_failed');
132
+ }
133
+ });
134
+
135
+ /**
136
+ * GET /auth/logout - Clear session and redirect
137
+ */
138
+ authRouter.get('/logout', async (c) => {
139
+ const sessionId = getCookie(c, SESSION_COOKIE);
140
+
141
+ if (sessionId) {
142
+ await c.env.DB.prepare('DELETE FROM sessions WHERE id = ?').bind(sessionId).run();
143
+ }
144
+
145
+ deleteCookie(c, SESSION_COOKIE, { path: '/' });
146
+ return c.redirect('/');
147
+ });
148
+
149
+ /**
150
+ * GET /api/me - Get current user info
151
+ */
152
+ authRouter.get('/me', async (c) => {
153
+ const user = await getCurrentUser(c);
154
+
155
+ if (!user) {
156
+ return c.json({ error: 'Not authenticated' }, 401);
157
+ }
158
+
159
+ return c.json({
160
+ id: user.id,
161
+ githubUsername: user.githubUsername,
162
+ githubAvatar: user.githubAvatar,
163
+ });
164
+ });
165
+
166
+ /**
167
+ * POST /api/keys - Generate new API key
168
+ */
169
+ authRouter.post('/keys', async (c) => {
170
+ const user = await getCurrentUser(c);
171
+
172
+ if (!user) {
173
+ return c.json({ error: 'Not authenticated' }, 401);
174
+ }
175
+
176
+ // Generate new API key
177
+ const apiKey = generateApiKey();
178
+ const keyHash = await hashApiKey(apiKey);
179
+ const keyId = crypto.randomUUID();
180
+
181
+ // Store key with user info
182
+ await c.env.DB.prepare(`
183
+ INSERT INTO api_keys (id, key_hash, user_name, github_username, github_avatar, github_id)
184
+ VALUES (?, ?, ?, ?, ?, ?)
185
+ `).bind(
186
+ keyId,
187
+ keyHash,
188
+ user.githubUsername,
189
+ user.githubUsername,
190
+ user.githubAvatar,
191
+ user.githubId
192
+ ).run();
193
+
194
+ return c.json({
195
+ apiKey,
196
+ keyId,
197
+ message: 'API key generated. Store it securely - it cannot be retrieved again.',
198
+ });
199
+ });
200
+
201
+ /**
202
+ * GET /api/keys - List user's API keys (without revealing the key)
203
+ */
204
+ authRouter.get('/keys', async (c) => {
205
+ const user = await getCurrentUser(c);
206
+
207
+ if (!user) {
208
+ return c.json({ error: 'Not authenticated' }, 401);
209
+ }
210
+
211
+ const keys = await c.env.DB.prepare(`
212
+ SELECT id, created_at, last_used_at
213
+ FROM api_keys
214
+ WHERE github_id = ?
215
+ ORDER BY created_at DESC
216
+ `).bind(user.githubId).all();
217
+
218
+ const formattedKeys = keys.results?.map((key: Record<string, unknown>) => ({
219
+ id: key.id,
220
+ createdAt: key.created_at ? new Date((key.created_at as number) * 1000).toISOString() : null,
221
+ lastUsedAt: key.last_used_at ? new Date((key.last_used_at as number) * 1000).toISOString() : null,
222
+ })) || [];
223
+
224
+ return c.json({ keys: formattedKeys });
225
+ });
226
+
227
+ /**
228
+ * DELETE /api/keys/:id - Revoke an API key
229
+ */
230
+ authRouter.delete('/keys/:id', async (c) => {
231
+ const user = await getCurrentUser(c);
232
+
233
+ if (!user) {
234
+ return c.json({ error: 'Not authenticated' }, 401);
235
+ }
236
+
237
+ const keyId = c.req.param('id');
238
+
239
+ // Verify key belongs to user
240
+ const key = await c.env.DB.prepare(`
241
+ SELECT id FROM api_keys WHERE id = ? AND github_id = ?
242
+ `).bind(keyId, user.githubId).first();
243
+
244
+ if (!key) {
245
+ return c.json({ error: 'Key not found' }, 404);
246
+ }
247
+
248
+ await c.env.DB.prepare('DELETE FROM api_keys WHERE id = ?').bind(keyId).run();
249
+
250
+ return c.json({ success: true });
251
+ });
252
+
253
+ /**
254
+ * POST /api/verify - Verify API key and return user info (for CLI)
255
+ */
256
+ authRouter.post('/verify-key', async (c) => {
257
+ const authHeader = c.req.header('Authorization');
258
+ if (!authHeader?.startsWith('Bearer ')) {
259
+ return c.json({ valid: false }, 401);
260
+ }
261
+
262
+ const apiKey = authHeader.slice(7);
263
+ const keyHash = await hashApiKey(apiKey);
264
+
265
+ const keyRecord = await c.env.DB.prepare(`
266
+ SELECT github_username, github_avatar, github_id
267
+ FROM api_keys
268
+ WHERE key_hash = ?
269
+ `).bind(keyHash).first();
270
+
271
+ if (!keyRecord) {
272
+ return c.json({ valid: false }, 401);
273
+ }
274
+
275
+ // Update last used
276
+ await c.env.DB.prepare(`
277
+ UPDATE api_keys SET last_used_at = unixepoch() WHERE key_hash = ?
278
+ `).bind(keyHash).run();
279
+
280
+ return c.json({
281
+ valid: true,
282
+ user: {
283
+ githubUsername: keyRecord.github_username,
284
+ githubAvatar: keyRecord.github_avatar,
285
+ },
286
+ });
287
+ });
288
+
289
+ /**
290
+ * Helper: Get current user from session cookie
291
+ */
292
+ async function getCurrentUser(c: { req: { header: (name: string) => string | undefined }; env: Bindings }): Promise<UserSession | null> {
293
+ // Get session ID from cookie using raw header
294
+ const cookieHeader = c.req.header('Cookie') || '';
295
+ const cookies = parseCookies(cookieHeader);
296
+ const sessionId = cookies[SESSION_COOKIE];
297
+
298
+ if (!sessionId) {
299
+ return null;
300
+ }
301
+
302
+ const session = await c.env.DB.prepare(`
303
+ SELECT u.id, u.github_id, u.github_username, u.github_avatar
304
+ FROM sessions s
305
+ JOIN users u ON u.id = s.user_id
306
+ WHERE s.id = ? AND s.expires_at > unixepoch()
307
+ `).bind(sessionId).first();
308
+
309
+ if (!session) {
310
+ return null;
311
+ }
312
+
313
+ return {
314
+ id: session.id as string,
315
+ githubId: session.github_id as number,
316
+ githubUsername: session.github_username as string,
317
+ githubAvatar: session.github_avatar as string | null,
318
+ };
319
+ }
320
+
321
+ /**
322
+ * Helper: Parse cookies from header
323
+ */
324
+ function parseCookies(cookieHeader: string): Record<string, string> {
325
+ const cookies: Record<string, string> = {};
326
+ cookieHeader.split(';').forEach(cookie => {
327
+ const [name, ...rest] = cookie.trim().split('=');
328
+ if (name) {
329
+ cookies[name] = rest.join('=');
330
+ }
331
+ });
332
+ return cookies;
333
+ }
334
+
335
+ /**
336
+ * Helper: Create or update user
337
+ */
338
+ async function upsertUser(db: D1Database, githubUser: GitHubUserResponse): Promise<string> {
339
+ const existingUser = await db.prepare(`
340
+ SELECT id FROM users WHERE github_id = ?
341
+ `).bind(githubUser.id).first();
342
+
343
+ if (existingUser) {
344
+ // Update existing user
345
+ await db.prepare(`
346
+ UPDATE users
347
+ SET github_username = ?, github_avatar = ?, github_email = ?, updated_at = unixepoch()
348
+ WHERE github_id = ?
349
+ `).bind(
350
+ githubUser.login,
351
+ githubUser.avatar_url,
352
+ githubUser.email,
353
+ githubUser.id
354
+ ).run();
355
+ return existingUser.id as string;
356
+ }
357
+
358
+ // Create new user
359
+ const userId = crypto.randomUUID();
360
+ await db.prepare(`
361
+ INSERT INTO users (id, github_id, github_username, github_avatar, github_email)
362
+ VALUES (?, ?, ?, ?, ?)
363
+ `).bind(
364
+ userId,
365
+ githubUser.id,
366
+ githubUser.login,
367
+ githubUser.avatar_url,
368
+ githubUser.email
369
+ ).run();
370
+
371
+ return userId;
372
+ }
373
+
374
+ /**
375
+ * Helper: Create session
376
+ */
377
+ async function createSession(db: D1Database, userId: string): Promise<string> {
378
+ const sessionId = crypto.randomUUID();
379
+ const expiresAt = Math.floor(Date.now() / 1000) + SESSION_DURATION_DAYS * 24 * 60 * 60;
380
+
381
+ await db.prepare(`
382
+ INSERT INTO sessions (id, user_id, expires_at)
383
+ VALUES (?, ?, ?)
384
+ `).bind(sessionId, userId, expiresAt).run();
385
+
386
+ return sessionId;
387
+ }
388
+
389
+ /**
390
+ * Helper: Generate random API key
391
+ */
392
+ function generateApiKey(): string {
393
+ const array = new Uint8Array(32);
394
+ crypto.getRandomValues(array);
395
+ return 'sk_' + Array.from(array, b => b.toString(16).padStart(2, '0')).join('');
396
+ }
397
+
398
+ /**
399
+ * Helper: Hash API key
400
+ */
401
+ async function hashApiKey(apiKey: string): Promise<string> {
402
+ const encoder = new TextEncoder();
403
+ const data = encoder.encode(apiKey);
404
+ const hashBuffer = await crypto.subtle.digest('SHA-256', data);
405
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
406
+ return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
407
+ }
408
+
409
+ /**
410
+ * Helper: Generate state for CSRF protection
411
+ */
412
+ function generateState(): string {
413
+ const array = new Uint8Array(16);
414
+ crypto.getRandomValues(array);
415
+ return Array.from(array, b => b.toString(16).padStart(2, '0')).join('');
416
+ }
417
+
418
+ /**
419
+ * Helper: Get base URL based on environment
420
+ */
421
+ function getBaseUrl(requestUrl: string, environment: string): string {
422
+ const url = new URL(requestUrl);
423
+ if (environment === 'production') {
424
+ return 'https://skillmark.sh';
425
+ }
426
+ return `${url.protocol}//${url.host}`;
427
+ }