@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.
- package/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/cd45cc5264daa1c125545b5b4c0756df95d8b6ac5900ecf52323d90f61a47f2d.sqlite +0 -0
- package/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/fc50b649db51ed0c303ff2c4b7c0eca2da269cc3dfc7ce40615fc37a7b53366c.sqlite +0 -0
- package/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/fc50b649db51ed0c303ff2c4b7c0eca2da269cc3dfc7ce40615fc37a7b53366c.sqlite-shm +0 -0
- package/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/fc50b649db51ed0c303ff2c4b7c0eca2da269cc3dfc7ce40615fc37a7b53366c.sqlite-wal +0 -0
- package/.wrangler/tmp/bundle-lfa2r7/checked-fetch.js +30 -0
- package/.wrangler/tmp/bundle-lfa2r7/middleware-insertion-facade.js +11 -0
- package/.wrangler/tmp/bundle-lfa2r7/middleware-loader.entry.ts +134 -0
- package/.wrangler/tmp/bundle-lfa2r7/strip-cf-connecting-ip-header.js +13 -0
- package/.wrangler/tmp/dev-IDqSK4/worker-entry-point.js +4918 -0
- package/.wrangler/tmp/dev-IDqSK4/worker-entry-point.js.map +8 -0
- package/package.json +22 -0
- package/src/assets/favicon.png +0 -0
- package/src/assets/skillmark-thumb.png +0 -0
- package/src/db/d1-database-schema.sql +69 -0
- package/src/db/migrations/001-add-github-oauth-and-user-session-tables.sql +40 -0
- package/src/db/migrations/002-add-security-benchmark-columns.sql +30 -0
- package/src/db/migrations/003-add-repo-url-and-update-composite-formula.sql +27 -0
- package/src/routes/api-endpoints-handler.ts +380 -0
- package/src/routes/github-oauth-authentication-handler.ts +427 -0
- package/src/routes/html-pages-renderer.ts +2263 -0
- package/src/routes/static-assets-handler.ts +58 -0
- package/src/worker-entry-point.ts +143 -0
- package/tsconfig.json +19 -0
- 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
|
+
}
|