Skip to content

Commit 5bf78be

Browse files
authored
Merge pull request #671 from rajbos/rajbos/fluency-shared-server
feat: add AI fluency score to sharing server dashboard
2 parents 9a33177 + afe198c commit 5bf78be

16 files changed

Lines changed: 992 additions & 298 deletions

File tree

sharing-server/.env.example

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,25 @@ SESSION_SECRET=change_me_to_a_random_secret
1212
# Public base URL of this server (used in OAuth redirect URI and links)
1313
BASE_URL=http://localhost:3000
1414

15-
# Optional: restrict to members of a specific GitHub organization
16-
# Leave empty to allow any GitHub user
15+
# Optional: restrict dashboard access and data uploads to members of this GitHub org.
16+
# Leave empty to allow any authenticated GitHub user.
17+
# Example: ALLOWED_GITHUB_ORG=my-company
1718
ALLOWED_GITHUB_ORG=
19+
20+
# Optional: server-side GitHub PAT used to verify org membership (see ALLOWED_GITHUB_ORG).
21+
#
22+
# When ALLOWED_GITHUB_ORG is set, the server calls the GitHub API to check whether
23+
# the authenticated user is a member of that org. By default it uses the user's own
24+
# OAuth token, which fails for orgs that enforce SAML SSO (GitHub returns 403 because
25+
# the user's token is not SSO-authorized for the org).
26+
#
27+
# Setting this to a PAT that belongs to the server operator (who IS a member and has
28+
# authorized the token for SSO) bypasses that limitation: the server checks membership
29+
# on the user's behalf without requiring anything extra from the end user.
30+
#
31+
# How to create:
32+
# 1. Go to https://github.com/settings/tokens → Generate new token (classic)
33+
# 2. Grant the "read:org" scope
34+
# 3. Click "Configure SSO" next to the token and authorize it for ALLOWED_GITHUB_ORG
35+
# 4. Paste the generated token below
36+
GITHUB_ORG_CHECK_TOKEN=

sharing-server/src/auth.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ const ipRateMap = new Map<string, { count: number; resetAt: number }>();
3030
const IP_RATE_MAX = 200;
3131
const IP_RATE_WINDOW_MS = 60 * 1000; // 1 minute per IP
3232

33+
/**
34+
* Validates a GitHub Bearer token supplied by the client (e.g. the VS Code extension).
35+
* Resolves the token to a local user row, or returns null if the token is invalid,
36+
* the GitHub API is unreachable, or the user is not a member of ALLOWED_GITHUB_ORG.
37+
*
38+
* Results are cached for 10 minutes (positive) or 1 minute (negative) to reduce
39+
* outbound GitHub API calls.
40+
*/
3341
export async function validateGitHubToken(token: string): Promise<UserRow | null> {
3442
const cacheKey = createHash('sha256').update(token).digest('hex');
3543

@@ -83,11 +91,25 @@ export async function validateGitHubToken(token: string): Promise<UserRow | null
8391
return user;
8492
}
8593

86-
async function checkOrgMembership(token: string, username: string, org: string): Promise<boolean> {
94+
/**
95+
* Checks whether `username` is an active public member of `org`.
96+
*
97+
* Uses GITHUB_ORG_CHECK_TOKEN (a server-configured PAT) when set, so that the
98+
* check works even when the org enforces SAML SSO — the server operator's PAT
99+
* is pre-authorized for the org, meaning the end user's token never needs
100+
* read:org scope or SSO authorization.
101+
*
102+
* Falls back to the user's own token if no server PAT is configured (works for
103+
* orgs with public membership and no SAML enforcement).
104+
*/
105+
async function checkOrgMembership(userToken: string, username: string, org: string): Promise<boolean> {
106+
// Prefer a server-side PAT (already SSO-authorized) so the user's OAuth token
107+
// doesn't need read:org or SAML SSO authorization.
108+
const checkToken = process.env.GITHUB_ORG_CHECK_TOKEN || userToken;
87109
try {
88110
const res = await fetch(`https://api.github.com/orgs/${org}/members/${username}`, {
89111
headers: {
90-
Authorization: `Bearer ${token}`,
112+
Authorization: `Bearer ${checkToken}`,
91113
'User-Agent': 'copilot-sharing-server/1.0',
92114
Accept: 'application/vnd.github+json',
93115
},
@@ -99,6 +121,7 @@ async function checkOrgMembership(token: string, username: string, org: string):
99121
}
100122
}
101123

124+
/** Returns true if the IP address is within the pre-auth rate limit window, false if it should be blocked. */
102125
export function checkIpRateLimit(ip: string): boolean {
103126
const now = Date.now();
104127
const entry = ipRateMap.get(ip);
@@ -111,6 +134,7 @@ export function checkIpRateLimit(ip: string): boolean {
111134
return true;
112135
}
113136

137+
/** Returns true if the user is within the upload rate limit window, false if they should be blocked. */
114138
export function checkUploadRateLimit(userId: number): boolean {
115139
const now = Date.now();
116140
const entry = uploadRateMap.get(userId);
@@ -125,6 +149,11 @@ export function checkUploadRateLimit(userId: number): boolean {
125149

126150
export type AuthVariables = { user: UserRow };
127151

152+
/**
153+
* Hono middleware that enforces Bearer token authentication on API routes.
154+
* Applies IP-level rate limiting before token validation, then resolves the
155+
* token to a user and stores it in the Hono context for downstream handlers.
156+
*/
128157
export async function requireBearerAuth(c: Context, next: Next): Promise<Response | void> {
129158
const ip = c.req.header('x-forwarded-for') ?? c.req.header('x-real-ip') ?? 'unknown';
130159

sharing-server/src/db.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface UserRow {
1111
created_at: string;
1212
last_seen_at: string | null;
1313
is_admin: number;
14+
fluency_score_json: string | null;
1415
}
1516

1617
export interface UploadRow {
@@ -29,6 +30,7 @@ export interface UploadRow {
2930
interactions: number;
3031
schema_version: number;
3132
uploaded_at: string;
33+
fluency_json: string | null;
3234
}
3335

3436
export interface UploadEntry {
@@ -43,6 +45,7 @@ export interface UploadEntry {
4345
inputTokens: number;
4446
outputTokens: number;
4547
interactions: number;
48+
fluencyMetrics?: Record<string, unknown>;
4649
}
4750

4851
let _db: DatabaseSync | undefined;
@@ -79,6 +82,7 @@ const UPLOADS_TABLE_DDL = `
7982
interactions INTEGER NOT NULL DEFAULT 0,
8083
schema_version INTEGER NOT NULL DEFAULT 3,
8184
uploaded_at TEXT DEFAULT (datetime('now')),
85+
fluency_json TEXT,
8286
UNIQUE(user_id, dataset_id, day, model, workspace_id, machine_id, editor)
8387
)`;
8488

@@ -147,6 +151,22 @@ function initSchema(db: DatabaseSync): void {
147151
CREATE INDEX IF NOT EXISTS idx_uploads_user_day ON usage_uploads(user_id, day);
148152
CREATE INDEX IF NOT EXISTS idx_uploads_dataset ON usage_uploads(dataset_id, day);
149153
`);
154+
155+
// Add fluency_json column if it doesn't exist (migration for existing DBs)
156+
const cols = db
157+
.prepare("PRAGMA table_info(usage_uploads)")
158+
.all() as unknown as Array<{ name: string }>;
159+
if (!cols.some(c => c.name === 'fluency_json')) {
160+
db.exec('ALTER TABLE usage_uploads ADD COLUMN fluency_json TEXT');
161+
}
162+
163+
// Add fluency_score_json column to users if it doesn't exist (migration for existing DBs)
164+
const userCols = db
165+
.prepare("PRAGMA table_info(users)")
166+
.all() as unknown as Array<{ name: string }>;
167+
if (!userCols.some(c => c.name === 'fluency_score_json')) {
168+
db.exec('ALTER TABLE users ADD COLUMN fluency_score_json TEXT');
169+
}
150170
}
151171

152172
export function upsertUser(
@@ -178,17 +198,19 @@ export function getUserByGithubId(githubId: number): UserRow | undefined {
178198

179199
export function upsertUpload(userId: number, entry: UploadEntry): void {
180200
const editor = ((entry.editor ?? '').trim() || 'VS Code').slice(0, 100);
201+
const fluencyJson = entry.fluencyMetrics ? JSON.stringify(entry.fluencyMetrics) : null;
181202
getDb().prepare(`
182203
INSERT INTO usage_uploads
183204
(user_id, dataset_id, day, model, workspace_id, workspace_name, machine_id, machine_name,
184-
editor, input_tokens, output_tokens, interactions)
185-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
205+
editor, input_tokens, output_tokens, interactions, fluency_json)
206+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
186207
ON CONFLICT(user_id, dataset_id, day, model, workspace_id, machine_id, editor) DO UPDATE SET
187208
workspace_name = excluded.workspace_name,
188209
machine_name = excluded.machine_name,
189210
input_tokens = excluded.input_tokens,
190211
output_tokens = excluded.output_tokens,
191212
interactions = excluded.interactions,
213+
fluency_json = excluded.fluency_json,
192214
uploaded_at = datetime('now')
193215
`).run(
194216
userId,
@@ -203,6 +225,7 @@ export function upsertUpload(userId: number, entry: UploadEntry): void {
203225
entry.inputTokens,
204226
entry.outputTokens,
205227
entry.interactions,
228+
fluencyJson,
206229
);
207230
}
208231

@@ -233,3 +256,14 @@ export function getUploadsForUser(userId: number, days = 30): UploadRow[] {
233256
export function getAllUsers(): UserRow[] {
234257
return getDb().prepare('SELECT * FROM users ORDER BY created_at DESC').all() as unknown as UserRow[];
235258
}
259+
260+
export function upsertUserFluencyScore(userId: number, scoreJson: string): void {
261+
getDb().prepare(`
262+
UPDATE users SET fluency_score_json = ? WHERE id = ?
263+
`).run(scoreJson, userId);
264+
}
265+
266+
export function getUserFluencyScore(userId: number): string | null {
267+
const row = getDb().prepare('SELECT fluency_score_json FROM users WHERE id = ?').get(userId) as unknown as { fluency_score_json: string | null } | undefined;
268+
return row?.fluency_score_json ?? null;
269+
}

sharing-server/src/routes/api.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Hono } from 'hono';
22
import { requireBearerAuth, checkUploadRateLimit, type AuthVariables } from '../auth.js';
3-
import { upsertUpload, deleteUploadsForDays, getUploadsForUser, getDb, type UploadEntry } from '../db.js';
3+
import { upsertUpload, deleteUploadsForDays, getUploadsForUser, getDb, upsertUserFluencyScore, type UploadEntry } from '../db.js';
44

55
const MAX_STRING_LENGTHS = {
66
model: 128,
@@ -111,6 +111,35 @@ api.get('/data', requireBearerAuth, (c) => {
111111
return c.json(data);
112112
});
113113

114+
/**
115+
* POST /api/fluency-score — Store the extension's locally-computed fluency score.
116+
* Body: { overallStage, overallLabel, categories, computedAt }
117+
* This is the authoritative score: the server dashboard uses it directly instead of
118+
* re-computing from aggregated upload blobs.
119+
*/
120+
api.post('/fluency-score', requireBearerAuth, async (c) => {
121+
const user = c.get('user');
122+
123+
let body: unknown;
124+
try {
125+
body = await c.req.json();
126+
} catch {
127+
return c.json({ error: 'Invalid JSON body.' }, 400);
128+
}
129+
130+
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
131+
return c.json({ error: 'Body must be a JSON object.' }, 400);
132+
}
133+
134+
const b = body as Record<string, unknown>;
135+
if (typeof b.overallStage !== 'number' || !Array.isArray(b.categories)) {
136+
return c.json({ error: '"overallStage" (number) and "categories" (array) are required.' }, 400);
137+
}
138+
139+
upsertUserFluencyScore(user.id, JSON.stringify(body));
140+
return c.json({ ok: true });
141+
});
142+
114143
// ── Helpers ──────────────────────────────────────────────────────────────────
115144

116145
function clampDays(raw: string | undefined): number {
@@ -175,6 +204,11 @@ function validateEntry(entry: unknown): string | null {
175204
return `"editor" too long (max ${MAX_STRING_LENGTHS.editor})`;
176205
}
177206
}
207+
if (e.fluencyMetrics !== undefined && e.fluencyMetrics !== null) {
208+
if (typeof e.fluencyMetrics !== 'object' || Array.isArray(e.fluencyMetrics)) {
209+
return '"fluencyMetrics" must be an object';
210+
}
211+
}
178212

179213
return null;
180214
}

0 commit comments

Comments
 (0)