Skip to content

Commit 3a87b4e

Browse files
committed
Licence key comms tested
1 parent c0bf801 commit 3a87b4e

3 files changed

Lines changed: 110 additions & 33 deletions

File tree

vs-code-extension/src/LicenceActivationService.ts

Lines changed: 51 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -146,11 +146,17 @@ export async function loadCachedLicenceState(
146146
}
147147

148148
/**
149-
* Activate a licence key: verify cryptographically, persist state, and
150-
* fire a background server notification.
149+
* Activate a licence key: verify cryptographically, check the server for
150+
* revocation (with a short timeout), persist state, and return the result.
151151
*
152-
* Returns the resulting LicenceState immediately after local verification.
153-
* The server POST happens in the background and does not block the result.
152+
* Flow:
153+
* 1. Verify Ed25519 signature locally (instant, offline).
154+
* 2. If valid, POST to the server with a 3-second timeout.
155+
* 3. If the server responds 403/404 (revoked/unknown), return invalid.
156+
* 4. If the server confirms (200) or is unreachable/errors, return valid.
157+
*
158+
* This ensures that revoked keys are caught immediately when online, while
159+
* offline activation still works via the cryptographic signature alone.
154160
*/
155161
export async function activateLicenceKey(
156162
key: string,
@@ -171,16 +177,18 @@ export async function activateLicenceKey(
171177
return invalidLicenceState();
172178
}
173179

180+
// Check with the server (short timeout -- don't block the user long).
181+
const revoked = await checkServerRevocationStatus(trimmed, context);
182+
if (revoked) {
183+
await clearPersistedState(context.secrets);
184+
return invalidLicenceState();
185+
}
186+
174187
// Build and persist valid state.
175188
const product = result.product as LicenceProduct;
176189
const state: LicenceState = { status: 'valid', product };
177190
await persistLicenceState(context.secrets, trimmed, state);
178191

179-
// Background server notification (non-blocking).
180-
notifyServer(trimmed, context).catch(() => {
181-
// Silently ignore -- server activation is best-effort.
182-
});
183-
184192
return state;
185193
}
186194

@@ -235,26 +243,44 @@ export async function checkServerForRevocation(
235243
}
236244
}
237245

238-
// ── Background server notification ─────────────────────────────────────────
246+
// ── Server revocation check at activation ──────────────────────────────────
239247

240-
async function notifyServer(
248+
/**
249+
* POST to the server to check whether a key has been revoked.
250+
* Returns true if the server explicitly reports the key as revoked (403/404).
251+
* Returns false (not revoked) if the server confirms (200), is unreachable,
252+
* times out, or returns any other error -- offline-first, optimistic.
253+
*
254+
* Uses a short 3-second timeout so the user isn't blocked long.
255+
*/
256+
async function checkServerRevocationStatus(
241257
key: string,
242258
context: vscode.ExtensionContext,
243-
): Promise<void> {
244-
const baseUrl = getBaseUrl();
245-
const response = await fetch(`${baseUrl}/api/v1/licence/activate`, {
246-
method: 'POST',
247-
headers: { 'Content-Type': 'application/json' },
248-
body: JSON.stringify({
249-
licenceKey: key,
250-
deviceId: vscode.env.machineId,
251-
deviceInfo: `VS Code ${vscode.version} / ${getOsPlatformLabel()}`,
252-
}),
253-
signal: AbortSignal.timeout(10_000),
254-
});
255-
256-
if (response.ok) {
259+
): Promise<boolean> {
260+
try {
261+
const baseUrl = getBaseUrl();
262+
const response = await fetch(`${baseUrl}/api/v1/licence/activate`, {
263+
method: 'POST',
264+
headers: { 'Content-Type': 'application/json' },
265+
body: JSON.stringify({
266+
licenceKey: key,
267+
deviceId: vscode.env.machineId,
268+
deviceInfo: `VS Code ${vscode.version} / ${getOsPlatformLabel()}`,
269+
}),
270+
signal: AbortSignal.timeout(3_000),
271+
});
272+
273+
// Record successful server contact.
257274
await context.secrets.store(SECRET_LAST_SERVER_CHECK, Date.now().toString());
275+
276+
if (response.status === 403 || response.status === 404) {
277+
return true; // revoked
278+
}
279+
280+
return false; // confirmed or unknown error -- not revoked
281+
} catch {
282+
// Unreachable / timeout -- not revoked (optimistic).
283+
return false;
258284
}
259285
}
260286

vs-code-extension/src/extension.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -488,16 +488,39 @@ export async function activate(context: vscode.ExtensionContext): Promise<{ exte
488488
);
489489
/** Validate a licence key with appropriate UI feedback. */
490490
function validateLicenceKeyWithUI(key: string): void {
491-
activateLicenceKey(key, context).then((state) => {
492-
const wasValid = hasProEditorAccess(licenceState);
491+
const activation = activateLicenceKey(key, context);
492+
493+
// Only show a progress spinner if the server check takes a while.
494+
// Fast paths (ECONNREFUSED, local-only) resolve in <500ms and skip the spinner.
495+
const progressTimer = setTimeout(() => {
496+
vscode.window.withProgress(
497+
{ location: vscode.ProgressLocation.Notification, title: 'AS Notes: Verifying licence key...' },
498+
() => activation,
499+
);
500+
}, 500);
501+
502+
activation.then((state) => {
503+
clearTimeout(progressTimer);
504+
493505
licenceState = state;
494506
if (licenceState.status === 'invalid' || licenceState.status === 'not-entered') {
495507
showLicenceWarning();
496-
} else if (licenceState.status === 'valid' && !wasValid) {
497-
vscode.window.showInformationMessage('AS Notes: Licence activated successfully \u2714');
508+
} else if (licenceState.status === 'valid') {
509+
510+
// Show licence activated regardless of whether licence was valid before or
511+
// if product has changed - the user should always see confirmation
512+
//
513+
// const previousProduct = licenceState.product;
514+
// const wasValid = hasProEditorAccess(licenceState);
515+
// ...
516+
// ... && (!wasValid || licenceState.product !== previousProduct)
517+
518+
const tierLabel = licenceState.product === 'pro_ai_sync' ? 'Pro AI & Sync' : 'Pro Editor';
519+
vscode.window.showInformationMessage(`AS Notes: Licence activated - ${tierLabel} \u2714`);
498520
}
499521
updateFullModeStatusBar();
500522
}).catch((err) => {
523+
clearTimeout(progressTimer);
501524
console.warn('as-notes: licence validation failed:', err);
502525
});
503526
}

vs-code-extension/src/test/LicenceActivationService.test.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,17 +88,16 @@ describe('activateLicenceKey', () => {
8888
expect(mockSecrets.has('as-notes.licenceKey')).toBe(false);
8989
});
9090

91-
it('returns valid pro_editor on successful verification', async () => {
91+
it('returns valid pro_editor when server confirms (200)', async () => {
9292
mockVerifyResult.mockReturnValue(validResult('pro_editor'));
93-
// Stub fetch so background notification doesn't fail
9493
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200 }));
9594

9695
const result = await activateLicenceKey('ASNO-VALID-KEY', buildContext() as any);
9796
expect(result.status).toBe('valid');
9897
expect(result.product).toBe('pro_editor');
9998
});
10099

101-
it('returns valid pro_ai_sync on successful verification', async () => {
100+
it('returns valid pro_ai_sync when server confirms (200)', async () => {
102101
mockVerifyResult.mockReturnValue(validResult('pro_ai_sync'));
103102
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200 }));
104103

@@ -107,6 +106,35 @@ describe('activateLicenceKey', () => {
107106
expect(result.product).toBe('pro_ai_sync');
108107
});
109108

109+
it('returns invalid when server reports 403 (revoked)', async () => {
110+
mockVerifyResult.mockReturnValue(validResult('pro_editor'));
111+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 403 }));
112+
113+
const result = await activateLicenceKey('ASNO-VALID-KEY', buildContext() as any);
114+
expect(result.status).toBe('invalid');
115+
expect(result.product).toBeNull();
116+
expect(mockSecrets.has('as-notes.licenceKey')).toBe(false);
117+
expect(mockSecrets.has('as-notes.licenceState')).toBe(false);
118+
});
119+
120+
it('returns invalid when server reports 404 (not found)', async () => {
121+
mockVerifyResult.mockReturnValue(validResult('pro_editor'));
122+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 }));
123+
124+
const result = await activateLicenceKey('ASNO-VALID-KEY', buildContext() as any);
125+
expect(result.status).toBe('invalid');
126+
expect(result.product).toBeNull();
127+
});
128+
129+
it('returns valid when server returns 500 (optimistic)', async () => {
130+
mockVerifyResult.mockReturnValue(validResult('pro_editor'));
131+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
132+
133+
const result = await activateLicenceKey('ASNO-VALID-KEY', buildContext() as any);
134+
expect(result.status).toBe('valid');
135+
expect(result.product).toBe('pro_editor');
136+
});
137+
110138
it('persists state to SecretStorage on success', async () => {
111139
mockVerifyResult.mockReturnValue(validResult('pro_editor'));
112140
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200 }));
@@ -126,7 +154,7 @@ describe('activateLicenceKey', () => {
126154
expect(result).not.toHaveProperty('serverUnreachable');
127155
});
128156

129-
it('returns valid even when server notification fails (non-blocking)', async () => {
157+
it('returns valid when server is unreachable (offline-first)', async () => {
130158
mockVerifyResult.mockReturnValue(validResult('pro_editor'));
131159
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
132160

0 commit comments

Comments
 (0)