@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
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@skillmark/webapp",
3
+ "version": "0.1.0",
4
+ "description": "Skillmark leaderboard webapp (Cloudflare Workers)",
5
+ "type": "module",
6
+ "main": "./src/worker-entry-point.ts",
7
+ "dependencies": {
8
+ "hono": "^4.0.0"
9
+ },
10
+ "devDependencies": {
11
+ "@cloudflare/workers-types": "^4.20240117.0",
12
+ "typescript": "^5.3.3",
13
+ "wrangler": "^3.22.0"
14
+ },
15
+ "scripts": {
16
+ "dev": "wrangler dev",
17
+ "deploy": "wrangler deploy",
18
+ "lint": "tsc --noEmit",
19
+ "db:create": "wrangler d1 create skillmark-db",
20
+ "db:migrate": "wrangler d1 execute skillmark-db --file=./src/db/schema.sql"
21
+ }
22
+ }
Binary file
Binary file
@@ -0,0 +1,69 @@
1
+ -- Skillmark D1 Database Schema
2
+ -- Run with: wrangler d1 execute skillmark-db --file=./src/db/d1-database-schema.sql
3
+
4
+ -- Skills table: stores skill metadata
5
+ CREATE TABLE IF NOT EXISTS skills (
6
+ id TEXT PRIMARY KEY,
7
+ name TEXT NOT NULL,
8
+ source TEXT,
9
+ description TEXT,
10
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
11
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch())
12
+ );
13
+
14
+ -- Results table: stores benchmark results
15
+ CREATE TABLE IF NOT EXISTS results (
16
+ id TEXT PRIMARY KEY,
17
+ skill_id TEXT NOT NULL REFERENCES skills(id),
18
+ model TEXT NOT NULL,
19
+ accuracy REAL NOT NULL,
20
+ tokens_total INTEGER NOT NULL,
21
+ tokens_input INTEGER,
22
+ tokens_output INTEGER,
23
+ duration_ms INTEGER NOT NULL,
24
+ cost_usd REAL NOT NULL,
25
+ tool_count INTEGER,
26
+ runs INTEGER NOT NULL,
27
+ hash TEXT NOT NULL,
28
+ raw_json TEXT,
29
+ security_score REAL,
30
+ security_json TEXT,
31
+ repo_url TEXT,
32
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
33
+ );
34
+
35
+ -- API keys table: stores user API keys for publishing
36
+ CREATE TABLE IF NOT EXISTS api_keys (
37
+ id TEXT PRIMARY KEY,
38
+ key_hash TEXT NOT NULL UNIQUE,
39
+ user_name TEXT,
40
+ email TEXT,
41
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
42
+ last_used_at INTEGER
43
+ );
44
+
45
+ -- Indexes for common queries
46
+ CREATE INDEX IF NOT EXISTS idx_results_skill_id ON results(skill_id);
47
+ CREATE INDEX IF NOT EXISTS idx_results_accuracy ON results(accuracy DESC);
48
+ CREATE INDEX IF NOT EXISTS idx_results_created_at ON results(created_at DESC);
49
+ CREATE INDEX IF NOT EXISTS idx_skills_name ON skills(name);
50
+ CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
51
+
52
+ -- View for leaderboard (best result per skill, composite scoring)
53
+ CREATE VIEW IF NOT EXISTS leaderboard AS
54
+ SELECT
55
+ s.id as skill_id,
56
+ s.name as skill_name,
57
+ s.source,
58
+ MAX(r.accuracy) as best_accuracy,
59
+ MAX(r.security_score) as best_security,
60
+ (MAX(r.accuracy) * 0.80 + COALESCE(MAX(r.security_score), 0) * 0.20) as composite_score,
61
+ (SELECT model FROM results WHERE skill_id = s.id ORDER BY accuracy DESC LIMIT 1) as best_model,
62
+ AVG(r.tokens_total) as avg_tokens,
63
+ AVG(r.cost_usd) as avg_cost,
64
+ MAX(r.created_at) as last_tested,
65
+ SUM(r.runs) as total_runs
66
+ FROM skills s
67
+ JOIN results r ON r.skill_id = s.id
68
+ GROUP BY s.id
69
+ ORDER BY composite_score DESC;
@@ -0,0 +1,40 @@
1
+ -- Migration: Add GitHub OAuth fields and enhanced result tracking
2
+ -- Run with: wrangler d1 execute skillmark-db --file=./src/db/migrations/001-github-oauth-fields.sql
3
+
4
+ -- Add GitHub profile fields to api_keys table
5
+ ALTER TABLE api_keys ADD COLUMN github_username TEXT;
6
+ ALTER TABLE api_keys ADD COLUMN github_avatar TEXT;
7
+ ALTER TABLE api_keys ADD COLUMN github_id INTEGER;
8
+
9
+ -- Add submitter and test data fields to results table
10
+ ALTER TABLE results ADD COLUMN submitter_github TEXT;
11
+ ALTER TABLE results ADD COLUMN test_files TEXT; -- JSON array of test file contents
12
+ ALTER TABLE results ADD COLUMN skillsh_link TEXT;
13
+
14
+ -- Create users table for session management
15
+ CREATE TABLE IF NOT EXISTS users (
16
+ id TEXT PRIMARY KEY,
17
+ github_id INTEGER NOT NULL UNIQUE,
18
+ github_username TEXT NOT NULL,
19
+ github_avatar TEXT,
20
+ github_email TEXT,
21
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
22
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch())
23
+ );
24
+
25
+ -- Create sessions table for OAuth session management
26
+ CREATE TABLE IF NOT EXISTS sessions (
27
+ id TEXT PRIMARY KEY,
28
+ user_id TEXT NOT NULL REFERENCES users(id),
29
+ expires_at INTEGER NOT NULL,
30
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
31
+ );
32
+
33
+ -- Index for faster session lookups
34
+ CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
35
+ CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
36
+ CREATE INDEX IF NOT EXISTS idx_users_github_id ON users(github_id);
37
+ CREATE INDEX IF NOT EXISTS idx_api_keys_github_id ON api_keys(github_id);
38
+
39
+ -- Update api_keys to reference users
40
+ -- Note: existing keys remain valid but won't have user association
@@ -0,0 +1,30 @@
1
+ -- Migration: Add security benchmark columns and composite leaderboard
2
+ -- Run: wrangler d1 execute skillmark-db --file=./src/db/migrations/002-add-security-benchmark-columns.sql
3
+
4
+ -- Add security columns to results
5
+ ALTER TABLE results ADD COLUMN security_score REAL;
6
+ ALTER TABLE results ADD COLUMN security_json TEXT;
7
+
8
+ -- Replace leaderboard view with composite scoring
9
+ DROP VIEW IF EXISTS leaderboard;
10
+
11
+ CREATE VIEW leaderboard AS
12
+ SELECT
13
+ s.id as skill_id,
14
+ s.name as skill_name,
15
+ s.source,
16
+ MAX(r.accuracy) as best_accuracy,
17
+ MAX(r.security_score) as best_security,
18
+ (MAX(r.accuracy) * 0.80 + COALESCE(MAX(r.security_score), 0) * 0.20) as composite_score,
19
+ (SELECT model FROM results WHERE skill_id = s.id ORDER BY accuracy DESC LIMIT 1) as best_model,
20
+ AVG(r.tokens_total) as avg_tokens,
21
+ AVG(r.cost_usd) as avg_cost,
22
+ MAX(r.created_at) as last_tested,
23
+ SUM(r.runs) as total_runs
24
+ FROM skills s
25
+ JOIN results r ON r.skill_id = s.id
26
+ GROUP BY s.id
27
+ ORDER BY composite_score DESC;
28
+
29
+ -- Index for security score queries
30
+ CREATE INDEX IF NOT EXISTS idx_results_security_score ON results(security_score);
@@ -0,0 +1,27 @@
1
+ -- Migration: Add repo_url column and update composite score formula (80/20)
2
+ -- Run: wrangler d1 execute skillmark-db --file=./src/db/migrations/003-add-repo-url-and-update-composite-formula.sql
3
+
4
+ -- Add repo_url column to results
5
+ ALTER TABLE results ADD COLUMN repo_url TEXT;
6
+
7
+ -- Update leaderboard view with new composite formula (80% accuracy + 20% security)
8
+ DROP VIEW IF EXISTS leaderboard;
9
+
10
+ CREATE VIEW leaderboard AS
11
+ SELECT
12
+ s.id as skill_id,
13
+ s.name as skill_name,
14
+ s.source,
15
+ MAX(r.accuracy) as best_accuracy,
16
+ MAX(r.security_score) as best_security,
17
+ (MAX(r.accuracy) * 0.80 + COALESCE(MAX(r.security_score), 0) * 0.20) as composite_score,
18
+ (SELECT model FROM results WHERE skill_id = s.id ORDER BY accuracy DESC LIMIT 1) as best_model,
19
+ (SELECT repo_url FROM results WHERE skill_id = s.id AND repo_url IS NOT NULL ORDER BY created_at DESC LIMIT 1) as repo_url,
20
+ AVG(r.tokens_total) as avg_tokens,
21
+ AVG(r.cost_usd) as avg_cost,
22
+ MAX(r.created_at) as last_tested,
23
+ SUM(r.runs) as total_runs
24
+ FROM skills s
25
+ JOIN results r ON r.skill_id = s.id
26
+ GROUP BY s.id
27
+ ORDER BY composite_score DESC;
@@ -0,0 +1,380 @@
1
+ /**
2
+ * API endpoints handler for Skillmark leaderboard
3
+ */
4
+ import { Hono } from 'hono';
5
+
6
+ type Bindings = {
7
+ DB: D1Database;
8
+ };
9
+
10
+ export const apiRouter = new Hono<{ Bindings: Bindings }>();
11
+
12
+ /** Result submission payload */
13
+ interface ResultPayload {
14
+ skillId: string;
15
+ skillName: string;
16
+ source: string;
17
+ model: string;
18
+ accuracy: number;
19
+ tokensTotal: number;
20
+ tokensInput?: number;
21
+ tokensOutput?: number;
22
+ durationMs: number;
23
+ costUsd: number;
24
+ toolCount?: number;
25
+ runs: number;
26
+ hash: string;
27
+ timestamp: string;
28
+ rawJson?: string;
29
+ // New fields for GitHub OAuth + enhanced tracking
30
+ testFiles?: Array<{ name: string; content: string }>;
31
+ skillshLink?: string;
32
+ /** Security benchmark score (0-100) */
33
+ securityScore?: number;
34
+ /** Full security breakdown JSON */
35
+ securityJson?: string;
36
+ /** Git repository URL (auto-detected from skill directory) */
37
+ repoUrl?: string;
38
+ }
39
+
40
+ /** API key info returned from verification */
41
+ interface ApiKeyInfo {
42
+ githubUsername: string | null;
43
+ githubAvatar: string | null;
44
+ }
45
+
46
+ /**
47
+ * POST /api/results - Submit benchmark results
48
+ */
49
+ apiRouter.post('/results', async (c) => {
50
+ try {
51
+ // Verify API key and get user info
52
+ const authHeader = c.req.header('Authorization');
53
+ if (!authHeader?.startsWith('Bearer ')) {
54
+ return c.json({ error: 'Missing or invalid API key' }, 401);
55
+ }
56
+
57
+ const apiKey = authHeader.slice(7);
58
+ const keyInfo = await verifyApiKeyAndGetInfo(c.env.DB, apiKey);
59
+ if (!keyInfo) {
60
+ return c.json({ error: 'Invalid API key' }, 401);
61
+ }
62
+
63
+ // Parse payload
64
+ const payload = await c.req.json<ResultPayload>();
65
+
66
+ // Validate required fields
67
+ if (!payload.skillId || !payload.skillName || !payload.model || !payload.hash) {
68
+ return c.json({ error: 'Missing required fields' }, 400);
69
+ }
70
+
71
+ // Validate model
72
+ if (!['haiku', 'sonnet', 'opus'].includes(payload.model)) {
73
+ return c.json({ error: 'Invalid model' }, 400);
74
+ }
75
+
76
+ // Validate accuracy range
77
+ if (payload.accuracy < 0 || payload.accuracy > 100) {
78
+ return c.json({ error: 'Accuracy must be between 0 and 100' }, 400);
79
+ }
80
+
81
+ // Ensure skill exists
82
+ await ensureSkillExists(c.env.DB, payload.skillId, payload.skillName, payload.source);
83
+
84
+ // Insert result with new fields
85
+ const resultId = crypto.randomUUID();
86
+ await c.env.DB.prepare(`
87
+ INSERT INTO results (
88
+ id, skill_id, model, accuracy, tokens_total, tokens_input, tokens_output,
89
+ duration_ms, cost_usd, tool_count, runs, hash, raw_json,
90
+ submitter_github, test_files, skillsh_link,
91
+ security_score, security_json, repo_url
92
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
93
+ `).bind(
94
+ resultId,
95
+ payload.skillId,
96
+ payload.model,
97
+ payload.accuracy,
98
+ payload.tokensTotal,
99
+ payload.tokensInput ?? null,
100
+ payload.tokensOutput ?? null,
101
+ payload.durationMs,
102
+ payload.costUsd,
103
+ payload.toolCount ?? null,
104
+ payload.runs,
105
+ payload.hash,
106
+ payload.rawJson || null,
107
+ keyInfo.githubUsername || null,
108
+ payload.testFiles ? JSON.stringify(payload.testFiles) : null,
109
+ payload.skillshLink || null,
110
+ payload.securityScore ?? null,
111
+ payload.securityJson || null,
112
+ payload.repoUrl || null
113
+ ).run();
114
+
115
+ // Update API key last used
116
+ await updateApiKeyLastUsed(c.env.DB, apiKey);
117
+
118
+ // Get rank
119
+ const rank = await getSkillRank(c.env.DB, payload.skillId);
120
+
121
+ return c.json({
122
+ success: true,
123
+ resultId,
124
+ leaderboardUrl: `https://skillmark.sh/?skill=${encodeURIComponent(payload.skillName)}`,
125
+ rank,
126
+ submitter: keyInfo.githubUsername ? {
127
+ github: keyInfo.githubUsername,
128
+ avatar: keyInfo.githubAvatar,
129
+ } : null,
130
+ });
131
+ } catch (error) {
132
+ console.error('Error submitting result:', error);
133
+ return c.json({ error: 'Internal server error' }, 500);
134
+ }
135
+ });
136
+
137
+ /**
138
+ * GET /api/leaderboard - Get skill rankings
139
+ */
140
+ apiRouter.get('/leaderboard', async (c) => {
141
+ try {
142
+ const limit = Math.min(parseInt(c.req.query('limit') || '20'), 100);
143
+ const offset = parseInt(c.req.query('offset') || '0');
144
+
145
+ const results = await c.env.DB.prepare(`
146
+ SELECT
147
+ skill_id as skillId,
148
+ skill_name as skillName,
149
+ source,
150
+ best_accuracy as bestAccuracy,
151
+ best_security as bestSecurity,
152
+ composite_score as compositeScore,
153
+ best_model as bestModel,
154
+ repo_url as repoUrl,
155
+ avg_tokens as avgTokens,
156
+ avg_cost as avgCost,
157
+ last_tested as lastTested,
158
+ total_runs as totalRuns
159
+ FROM leaderboard
160
+ LIMIT ? OFFSET ?
161
+ `).bind(limit, offset).all();
162
+
163
+ // Format timestamps
164
+ const entries = results.results?.map((row: Record<string, unknown>) => ({
165
+ ...row,
166
+ lastTested: row.lastTested
167
+ ? new Date((row.lastTested as number) * 1000).toISOString()
168
+ : null,
169
+ })) || [];
170
+
171
+ return c.json({ entries });
172
+ } catch (error) {
173
+ console.error('Error fetching leaderboard:', error);
174
+ return c.json({ error: 'Internal server error' }, 500);
175
+ }
176
+ });
177
+
178
+ /**
179
+ * GET /api/skill/:name - Get specific skill details
180
+ */
181
+ apiRouter.get('/skill/:name', async (c) => {
182
+ try {
183
+ const skillName = decodeURIComponent(c.req.param('name'));
184
+
185
+ // Get skill from leaderboard view
186
+ const skill = await c.env.DB.prepare(`
187
+ SELECT
188
+ skill_id as skillId,
189
+ skill_name as skillName,
190
+ source,
191
+ best_accuracy as bestAccuracy,
192
+ best_security as bestSecurity,
193
+ composite_score as compositeScore,
194
+ best_model as bestModel,
195
+ repo_url as repoUrl,
196
+ avg_tokens as avgTokens,
197
+ avg_cost as avgCost,
198
+ last_tested as lastTested,
199
+ total_runs as totalRuns
200
+ FROM leaderboard
201
+ WHERE skill_name = ?
202
+ `).bind(skillName).first();
203
+
204
+ if (!skill) {
205
+ return c.json({ error: 'Skill not found' }, 404);
206
+ }
207
+
208
+ // Get result history
209
+ const history = await c.env.DB.prepare(`
210
+ SELECT
211
+ id,
212
+ accuracy,
213
+ model,
214
+ tokens_total as tokensTotal,
215
+ duration_ms as durationMs,
216
+ cost_usd as costUsd,
217
+ tool_count as toolCount,
218
+ security_score as securityScore,
219
+ created_at as date
220
+ FROM results
221
+ WHERE skill_id = ?
222
+ ORDER BY created_at DESC
223
+ LIMIT 20
224
+ `).bind(skill.skillId).all();
225
+
226
+ const formattedHistory = history.results?.map((row: Record<string, unknown>) => ({
227
+ id: row.id,
228
+ accuracy: row.accuracy,
229
+ model: row.model,
230
+ tokensTotal: row.tokensTotal ?? null,
231
+ durationMs: row.durationMs ?? null,
232
+ costUsd: row.costUsd ?? null,
233
+ toolCount: row.toolCount ?? null,
234
+ securityScore: row.securityScore ?? null,
235
+ date: row.date ? new Date((row.date as number) * 1000).toISOString() : null,
236
+ })) || [];
237
+
238
+ return c.json({
239
+ ...skill,
240
+ lastTested: skill.lastTested
241
+ ? new Date((skill.lastTested as number) * 1000).toISOString()
242
+ : null,
243
+ history: formattedHistory,
244
+ });
245
+ } catch (error) {
246
+ console.error('Error fetching skill:', error);
247
+ return c.json({ error: 'Internal server error' }, 500);
248
+ }
249
+ });
250
+
251
+ /**
252
+ * GET /api/result/:id - Get full result detail (parsed raw_json)
253
+ */
254
+ apiRouter.get('/result/:id', async (c) => {
255
+ try {
256
+ const id = c.req.param('id');
257
+
258
+ const result = await c.env.DB.prepare(`
259
+ SELECT raw_json FROM results WHERE id = ?
260
+ `).bind(id).first();
261
+
262
+ if (!result?.raw_json) {
263
+ return c.json({ error: 'Result not found or no detailed data available' }, 404);
264
+ }
265
+
266
+ return c.json(JSON.parse(result.raw_json as string));
267
+ } catch (error) {
268
+ console.error('Error fetching result detail:', error);
269
+ return c.json({ error: 'Internal server error' }, 500);
270
+ }
271
+ });
272
+
273
+ /**
274
+ * POST /api/verify - Verify API key
275
+ */
276
+ apiRouter.post('/verify', async (c) => {
277
+ try {
278
+ const authHeader = c.req.header('Authorization');
279
+ if (!authHeader?.startsWith('Bearer ')) {
280
+ return c.json({ valid: false }, 401);
281
+ }
282
+
283
+ const apiKey = authHeader.slice(7);
284
+ const isValid = await verifyApiKey(c.env.DB, apiKey);
285
+
286
+ return c.json({ valid: isValid });
287
+ } catch (error) {
288
+ return c.json({ valid: false }, 500);
289
+ }
290
+ });
291
+
292
+ /**
293
+ * Verify API key against database
294
+ */
295
+ async function verifyApiKey(db: D1Database, apiKey: string): Promise<boolean> {
296
+ const keyHash = await hashApiKey(apiKey);
297
+
298
+ const result = await db.prepare(`
299
+ SELECT id FROM api_keys WHERE key_hash = ?
300
+ `).bind(keyHash).first();
301
+
302
+ return result !== null;
303
+ }
304
+
305
+ /**
306
+ * Verify API key and return associated user info
307
+ */
308
+ async function verifyApiKeyAndGetInfo(db: D1Database, apiKey: string): Promise<ApiKeyInfo | null> {
309
+ const keyHash = await hashApiKey(apiKey);
310
+
311
+ const result = await db.prepare(`
312
+ SELECT github_username, github_avatar FROM api_keys WHERE key_hash = ?
313
+ `).bind(keyHash).first();
314
+
315
+ if (!result) {
316
+ return null;
317
+ }
318
+
319
+ return {
320
+ githubUsername: result.github_username as string | null,
321
+ githubAvatar: result.github_avatar as string | null,
322
+ };
323
+ }
324
+
325
+ /**
326
+ * Update API key last used timestamp
327
+ */
328
+ async function updateApiKeyLastUsed(db: D1Database, apiKey: string): Promise<void> {
329
+ const keyHash = await hashApiKey(apiKey);
330
+
331
+ await db.prepare(`
332
+ UPDATE api_keys SET last_used_at = unixepoch() WHERE key_hash = ?
333
+ `).bind(keyHash).run();
334
+ }
335
+
336
+ /**
337
+ * Hash API key for storage using Web Crypto API
338
+ */
339
+ async function hashApiKey(apiKey: string): Promise<string> {
340
+ const encoder = new TextEncoder();
341
+ const data = encoder.encode(apiKey);
342
+ const hashBuffer = await crypto.subtle.digest('SHA-256', data);
343
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
344
+ return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
345
+ }
346
+
347
+ /**
348
+ * Ensure skill exists in database
349
+ */
350
+ async function ensureSkillExists(
351
+ db: D1Database,
352
+ skillId: string,
353
+ skillName: string,
354
+ source: string
355
+ ): Promise<void> {
356
+ const existing = await db.prepare(`
357
+ SELECT id FROM skills WHERE id = ?
358
+ `).bind(skillId).first();
359
+
360
+ if (!existing) {
361
+ await db.prepare(`
362
+ INSERT INTO skills (id, name, source) VALUES (?, ?, ?)
363
+ `).bind(skillId, skillName, source).run();
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Get skill rank in leaderboard
369
+ */
370
+ async function getSkillRank(db: D1Database, skillId: string): Promise<number | null> {
371
+ const result = await db.prepare(`
372
+ SELECT COUNT(*) + 1 as rank
373
+ FROM leaderboard
374
+ WHERE composite_score > (
375
+ SELECT composite_score FROM leaderboard WHERE skill_id = ?
376
+ )
377
+ `).bind(skillId).first();
378
+
379
+ return result?.rank as number || null;
380
+ }