Skip to content

Commit b357308

Browse files
committed
feat: Enhance backend sync service with backend-specific file locks and UTC-based daily rollups
- Refactor sync lock acquisition and release methods to support backend-specific locks. - Introduce daily rollups for session data to ensure accurate token attribution across days. - Update session file cache structure to include daily rollups for consistent reporting. - Modify CopilotTokenTracker to calculate and store daily interaction counts and model usage. - Adjust date handling to use UTC for consistent period boundaries in statistics. - Improve performance by leveraging pre-computed daily rollups when available.
1 parent 66484ff commit b357308

9 files changed

Lines changed: 573 additions & 275 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/routes/dashboard.ts

Lines changed: 134 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,11 +105,15 @@ dashboard.get('/auth/github/callback', async (c) => {
105105
// Optional org membership check
106106
const allowedOrg = process.env.ALLOWED_GITHUB_ORG;
107107
if (allowedOrg) {
108+
// Prefer a server-side PAT (already SSO-authorized) so the user's OAuth token
109+
// doesn't need read:org or SAML SSO authorization.
110+
const checkToken = process.env.GITHUB_ORG_CHECK_TOKEN || accessToken;
108111
try {
109112
const memberRes = await fetch(`https://api.github.com/orgs/${allowedOrg}/members/${userData.login}`, {
110113
headers: {
111-
Authorization: `Bearer ${accessToken}`,
114+
Authorization: `Bearer ${checkToken}`,
112115
'User-Agent': 'copilot-sharing-server/1.0',
116+
Accept: 'application/vnd.github+json',
113117
},
114118
signal: AbortSignal.timeout(10_000),
115119
});
@@ -142,6 +146,80 @@ dashboard.get('/auth/logout', (c) => {
142146
return c.redirect('/dashboard');
143147
});
144148

149+
/** POST /auth/pat — Sign in with a GitHub Personal Access Token. */
150+
dashboard.post('/auth/pat', async (c) => {
151+
const body = await c.req.parseBody();
152+
const token = (body['pat'] as string ?? '').trim();
153+
154+
if (!token) {
155+
return c.html(errorPage('No token provided.'), 400);
156+
}
157+
158+
// Fetch the authenticated user
159+
let userData: { id: number; login: string; name: string | null; avatar_url: string };
160+
try {
161+
const userRes = await fetch('https://api.github.com/user', {
162+
headers: {
163+
Authorization: `Bearer ${token}`,
164+
'User-Agent': 'copilot-sharing-server/1.0',
165+
Accept: 'application/vnd.github+json',
166+
},
167+
signal: AbortSignal.timeout(10_000),
168+
});
169+
if (!userRes.ok) {
170+
return c.html(errorPage('Invalid token or unable to verify GitHub identity.'), 401);
171+
}
172+
userData = await userRes.json() as typeof userData;
173+
} catch (err) {
174+
return c.html(errorPage(`Failed to reach GitHub: ${String(err)}`), 502);
175+
}
176+
177+
// Optional org membership check
178+
const allowedOrg = process.env.ALLOWED_GITHUB_ORG;
179+
if (allowedOrg) {
180+
try {
181+
const memberRes = await fetch(`https://api.github.com/user/memberships/orgs/${allowedOrg}`, {
182+
headers: {
183+
Authorization: `Bearer ${token}`,
184+
'User-Agent': 'copilot-sharing-server/1.0',
185+
Accept: 'application/vnd.github+json',
186+
},
187+
signal: AbortSignal.timeout(10_000),
188+
});
189+
if (memberRes.status === 403) {
190+
return c.html(errorPage(
191+
`Access denied: your token is not authorized for the "${allowedOrg}" organization. ` +
192+
`If this org uses SAML SSO, go to github.com → Settings → Personal access tokens, ` +
193+
`click your token, and grant SSO access to the "${allowedOrg}" org.`
194+
), 403);
195+
}
196+
if (memberRes.status !== 200) {
197+
return c.html(errorPage(`Access denied: you are not a member of the "${allowedOrg}" organization.`), 403);
198+
}
199+
const membership = await memberRes.json() as { state: string };
200+
if (membership.state !== 'active') {
201+
return c.html(errorPage(`Access denied: your membership in the "${allowedOrg}" organization is not active.`), 403);
202+
}
203+
} catch {
204+
return c.html(errorPage('Unable to verify organization membership. Please try again.'), 502);
205+
}
206+
}
207+
208+
const user = upsertUser(userData.id, userData.login, userData.name, userData.avatar_url);
209+
const claims = makeClaims(user.id);
210+
const sessionValue = encodeSession(claims);
211+
212+
setCookie(c, COOKIE_NAME, sessionValue, {
213+
httpOnly: true,
214+
secure: process.env.NODE_ENV === 'production',
215+
sameSite: 'Lax',
216+
maxAge: SESSION_MAX_AGE,
217+
path: '/',
218+
});
219+
220+
return c.redirect('/dashboard');
221+
});
222+
145223
// ── Dashboard ─────────────────────────────────────────────────────────────────
146224

147225
/** Redirect root to dashboard. */
@@ -676,14 +754,42 @@ ${body}
676754
}
677755

678756
function loginPage(): string {
757+
const oauthAvailable = !!process.env.GITHUB_CLIENT_ID;
758+
const oauthSection = oauthAvailable ? `
759+
<a href="/auth/github" class="btn btn-primary" style="font-size:1rem; padding:12px 28px">
760+
Sign in with GitHub (OAuth)
761+
</a>
762+
<p style="color:#8b949e; margin-top:8px; font-size:0.85rem">
763+
Note: requires org admin approval for SSO organizations.
764+
</p>
765+
<div style="color:#8b949e; margin: 24px 0; font-size:0.9rem">— or —</div>` : '';
766+
679767
return layout('Sign In', `
680768
<div class="header"><h1>🤖 Copilot Token Tracker Sharing</h1></div>
681769
<div class="content" style="text-align:center; margin-top: 80px; align-items:center">
682770
<h2 style="color:#e6edf3">Sign in to view your usage dashboard</h2>
683771
<p style="color:#8b949e">Your data is linked to your GitHub account. No account creation needed.</p>
684-
<a href="/auth/github" class="btn btn-primary" style="font-size:1rem; padding:12px 28px">
685-
Sign in with GitHub
686-
</a>
772+
${oauthSection}
773+
<form method="POST" action="/auth/pat" style="max-width:420px; margin:0 auto; text-align:left">
774+
<label style="color:#8b949e; font-size:0.9rem; display:block; margin-bottom:6px">
775+
Sign in with a GitHub Personal Access Token (PAT)
776+
</label>
777+
<input
778+
type="password"
779+
name="pat"
780+
placeholder="ghp_..."
781+
required
782+
autocomplete="off"
783+
style="width:100%; padding:10px 12px; border-radius:6px; border:1px solid #30363d;
784+
background:#161b22; color:#e6edf3; font-size:0.95rem; box-sizing:border-box"
785+
/>
786+
<p style="color:#8b949e; font-size:0.8rem; margin:6px 0 12px">
787+
Needs <code>read:user</code> scope (and <code>read:org</code> + SSO authorization if your org enforces SAML SSO).
788+
</p>
789+
<button type="submit" class="btn btn-primary" style="width:100%; font-size:1rem; padding:10px">
790+
Sign in with PAT
791+
</button>
792+
</form>
687793
<p style="color:#8b949e; margin-top:32px; font-size:0.85rem">
688794
The VS Code extension uploads data automatically using your existing GitHub session —
689795
no separate sign-in required.
@@ -1269,6 +1375,30 @@ var CHART_DATA = ${safeJson(chartData)};
12691375
</script>
12701376
<script>${_chartJsCode}</script>
12711377
<script>${interactiveJs}</script>
1378+
<script>
1379+
// Re-compute "Today" stats using browser's local timezone (server pre-renders in UTC)
1380+
(function() {
1381+
function fmtLocal(n) {
1382+
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
1383+
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
1384+
return String(n);
1385+
}
1386+
var todayLocal = new Date().toLocaleDateString('sv-SE'); // YYYY-MM-DD in local timezone
1387+
var todayData = CHART_DATA.filter(function(r) { return r.day === todayLocal; });
1388+
var inputTokens = todayData.reduce(function(s, r) { return s + r.inputTokens; }, 0);
1389+
var outputTokens = todayData.reduce(function(s, r) { return s + r.outputTokens; }, 0);
1390+
var interactions = todayData.reduce(function(s, r) { return s + r.interactions; }, 0);
1391+
var daysActive = todayData.length > 0 ? 1 : 0;
1392+
var panel = document.getElementById('stats-today');
1393+
if (panel) {
1394+
var values = panel.querySelectorAll('.stat-card .value');
1395+
if (values[0]) values[0].textContent = fmtLocal(inputTokens);
1396+
if (values[1]) values[1].textContent = fmtLocal(outputTokens);
1397+
if (values[2]) values[2].textContent = fmtLocal(interactions);
1398+
if (values[3]) values[3].textContent = String(daysActive);
1399+
}
1400+
})();
1401+
</script>
12721402
<script>${fluencyJs}</script>`);
12731403
}
12741404

vscode-extension/src/backend/configPanel.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export interface BackendConfigPanelState {
1010
privacyBadge: string;
1111
isConfigured: boolean;
1212
authStatus: string;
13+
githubTokenAvailable: boolean;
1314
shareConsentAt?: string;
1415
lastSyncAt?: number;
1516
}
@@ -291,6 +292,9 @@ export class BackendConfigPanel implements vscode.Disposable {
291292
</div>
292293
<div id="enabledToggleTeam-help" class="helper">Independent of Azure Storage — both backends can sync simultaneously.</div>
293294
<div class="status-line" id="lastSyncLine" style="margin-top: 12px;" role="status"></div>
295+
<div id="githubAuthWarning" style="display:none; margin-top: 12px; padding: 10px 12px; background: #4d2c00; border-left: 3px solid #f0883e; border-radius: 4px; font-size: 12px; color: #e5c07b;">
296+
⚠️ <strong>GitHub sign-in required.</strong> The extension uses your VS Code GitHub session as the upload token. Open the <strong>Accounts</strong> menu (bottom-left) and grant access to <em>AI Engineering Fluency</em>, then wait for the next sync.
297+
</div>
294298
</div>
295299
<div class="card">
296300
<h3>Connection</h3>
@@ -540,6 +544,8 @@ export class BackendConfigPanel implements vscode.Disposable {
540544
byId('privacyBadge').innerText = 'Privacy: ' + state.privacyBadge;
541545
byId('authBadge').innerText = state.authStatus;
542546
byId('backendStateBadge').innerText = (state.draft.enabled || state.draft.sharingServerEnabled) ? 'Backend: Enabled' : 'Backend: Disabled';
547+
const showGithubWarning = state.draft.sharingServerEnabled && !state.githubTokenAvailable;
548+
byId('githubAuthWarning').style.display = showGithubWarning ? 'block' : 'none';
543549
updateLastSyncLine(state.lastSyncAt);
544550
545551
// Update overview details

vscode-extension/src/backend/facade.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,12 +627,14 @@ export class BackendFacade {
627627
: "Auth: Shared Key missing on this machine"
628628
: "Auth: Entra ID (RBAC)";
629629
const lastSyncAt = this.deps.context?.globalState?.get<number>('backend.lastSyncAt');
630+
const githubTokenAvailable = !!(this.deps.getGithubToken?.());
630631
return {
631632
draft,
632633
sharedKeySet,
633634
privacyBadge,
634635
isConfigured: this.isConfigured(settings),
635636
authStatus,
637+
githubTokenAvailable,
636638
shareConsentAt: settings.shareConsentAt,
637639
lastSyncAt,
638640
};

0 commit comments

Comments
 (0)