Skip to content

Commit c484108

Browse files
committed
Release v2.2.5
1 parent 02dc7e5 commit c484108

4 files changed

Lines changed: 68 additions & 52 deletions

File tree

vs-code-extension/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "as-notes",
33
"displayName": "AS Notes - Personal Knowledge Management System (PKMS)",
44
"description": "VS Code Personal Knowledge Management System (PKMS) - Markdown Editing, Wikilinks (inc. Nested), Tasks, Kanban, Files, Publish to HTML, Daily Journal, Encrypted Notes",
5-
"version": "2.2.4",
5+
"version": "2.2.5",
66
"publisher": "appsoftwareltd",
77
"license": "Elastic-2.0",
88
"icon": "images/icon.png",

vs-code-extension/src/LicenceActivationService.ts

Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@
1212
* 5. A periodic background validation (POST /api/v1/licence/validate)
1313
* runs every 24 hours to detect revocation.
1414
*
15-
* ## Grace period
15+
* ## Offline fallback
1616
*
17-
* When the server is unreachable, the extension falls back to the
18-
* cached token state for up to 7 days from the last successful
19-
* validation. After that, pro features are disabled.
17+
* When the server is unreachable, the extension falls back to a
18+
* format-based check: if the licence key matches the ASNO-XXXX format
19+
* regex, Pro Editor access is granted with `serverUnreachable: true`.
20+
* This is a temporary measure until cryptographic offline keys are
21+
* implemented.
2022
*/
2123

2224
import * as vscode from 'vscode';
@@ -36,7 +38,6 @@ const SECRET_LAST_VALIDATED = 'as-notes.lastValidated';
3638
const SECRET_LICENCE_STATE = 'as-notes.licenceState';
3739

3840
const DEFAULT_BASE_URL = 'https://www.asnotes.io';
39-
const GRACE_PERIOD_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
4041

4142
const MAX_RETRIES = 3;
4243
const RETRY_DELAYS_MS = [1_000, 4_000, 16_000];
@@ -95,6 +96,7 @@ async function fetchWithRetry(
9596
method: 'POST',
9697
headers: { 'Content-Type': 'application/json' },
9798
body: JSON.stringify(body),
99+
signal: AbortSignal.timeout(10_000),
98100
});
99101
// Only retry on 5xx
100102
if (response.status >= 500 && attempt < MAX_RETRIES) {
@@ -238,8 +240,8 @@ export async function activateWithServer(
238240
throw new Error(`Unexpected status ${response.status}`);
239241

240242
} catch (err) {
241-
// Server unreachable or all retries exhausted — apply grace period
242-
return applyGracePeriod(persisted);
243+
// Server unreachable or all retries exhausted — fall back to format check
244+
return applyFormatFallback(context.secrets, key);
243245
}
244246
}
245247

@@ -293,29 +295,26 @@ export async function validateWithServer(
293295
throw new Error(`Unexpected status ${response.status}`);
294296

295297
} catch {
296-
// Server unreachable — apply grace period
297-
return applyGracePeriod(persisted);
298+
// Server unreachable — fall back to format check on persisted key
299+
return applyFormatFallback(context.secrets, persisted.key ?? '');
298300
}
299301
}
300302

301-
// ── Grace period ───────────────────────────────────────────────────────────
303+
// ── Offline format fallback ─────────────────────────────────────────────────
302304

303-
function applyGracePeriod(persisted: {
304-
token: string | undefined;
305-
lastValidated: number | undefined;
306-
state: LicenceState | undefined;
307-
}): LicenceState {
308-
if (!persisted.token || !persisted.lastValidated || !persisted.state) {
309-
return { ...defaultLicenceState(), serverUnreachable: true };
310-
}
311-
312-
const elapsed = Date.now() - persisted.lastValidated;
313-
if (elapsed <= GRACE_PERIOD_MS) {
314-
// Within grace period — maintain current state
315-
return { ...persisted.state, serverUnreachable: true };
305+
/**
306+
* When the server is unreachable, grant Pro Editor access if the key
307+
* passes the ASNO-XXXX-XXXX-XXXX-XXXX format check. This is a temporary
308+
* fallback until cryptographic offline keys are implemented.
309+
*
310+
* Persists the resulting state to SecretStorage so it survives restarts.
311+
*/
312+
async function applyFormatFallback(secrets: vscode.SecretStorage, key: string): Promise<LicenceState> {
313+
if (validateLicenceKeyFormat(key) === 'valid') {
314+
const state: LicenceState = { status: 'valid', product: 'pro_editor', serverUnreachable: true };
315+
await persistLicenceState(secrets, '', key, state);
316+
return state;
316317
}
317-
318-
// Grace period exceeded
319318
return { ...defaultLicenceState(), serverUnreachable: true };
320319
}
321320

vs-code-extension/src/extension.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -505,7 +505,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<{ exte
505505
() => activateWithServer(key, context).then((state) => {
506506
const wasValid = hasProEditorAccess(licenceState);
507507
licenceState = state;
508-
if (licenceState.serverUnreachable) {
508+
if (licenceState.serverUnreachable && licenceState.status !== 'valid') {
509509
showServerUnreachableWarning();
510510
} else if (licenceState.status === 'invalid' || licenceState.status === 'not-entered') {
511511
showLicenceWarning();

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

Lines changed: 42 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -167,17 +167,8 @@ describe('activateWithServer', () => {
167167
vi.unstubAllGlobals();
168168
});
169169

170-
it('applies grace period when server is unreachable and within 7 days', async () => {
170+
it('falls back to regex: returns pro_editor when server unreachable and key format is valid', async () => {
171171
vi.useFakeTimers({ shouldAdvanceTime: true });
172-
const now = Date.now();
173-
const token = buildTestJwt({
174-
product: 'pro_editor',
175-
exp: pastUnix(1), // expired
176-
});
177-
mockSecrets.set('as-notes.activationToken', token);
178-
mockSecrets.set('as-notes.licenceKey', VALID_KEY);
179-
mockSecrets.set('as-notes.lastValidated', (now - 2 * 86_400_000).toString()); // 2 days ago
180-
mockSecrets.set('as-notes.licenceState', JSON.stringify({ status: 'valid', product: 'pro_editor' }));
181172

182173
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
183174

@@ -187,29 +178,28 @@ describe('activateWithServer', () => {
187178
const result = await resultPromise;
188179
expect(result.status).toBe('valid');
189180
expect(result.product).toBe('pro_editor');
181+
expect(result.serverUnreachable).toBe(true);
182+
183+
// Verify state was persisted
184+
expect(mockSecrets.get('as-notes.licenceKey')).toBe(VALID_KEY);
185+
const persisted = JSON.parse(mockSecrets.get('as-notes.licenceState')!);
186+
expect(persisted.status).toBe('valid');
187+
expect(persisted.product).toBe('pro_editor');
190188

191189
vi.unstubAllGlobals();
192190
vi.useRealTimers();
193191
});
194192

195-
it('returns not-entered when grace period exceeded', async () => {
193+
it('falls back to regex: returns not-entered when server unreachable and key format is invalid', async () => {
196194
vi.useFakeTimers({ shouldAdvanceTime: true });
197-
const now = Date.now();
198-
const token = buildTestJwt({
199-
product: 'pro_editor',
200-
exp: pastUnix(1),
201-
});
202-
mockSecrets.set('as-notes.activationToken', token);
203-
mockSecrets.set('as-notes.licenceKey', VALID_KEY);
204-
mockSecrets.set('as-notes.lastValidated', (now - 8 * 86_400_000).toString()); // 8 days ago
205-
mockSecrets.set('as-notes.licenceState', JSON.stringify({ status: 'valid', product: 'pro_editor' }));
206195

207196
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
208197

209-
const resultPromise = activateWithServer(VALID_KEY, buildContext() as any);
198+
const resultPromise = activateWithServer('ASNO-ZZZZ-ZZZZ-ZZZZ-ZZZZ', buildContext() as any);
210199
await vi.advanceTimersByTimeAsync(25_000);
211200
const result = await resultPromise;
212-
expect(result.status).toBe('not-entered'); // falls back to default
201+
// Invalid format returns 'invalid' before reaching the server, so this never hits the fallback
202+
expect(result.status).toBe('invalid');
213203
expect(result.product).toBeNull();
214204

215205
vi.unstubAllGlobals();
@@ -284,13 +274,12 @@ describe('validateWithServer', () => {
284274
vi.unstubAllGlobals();
285275
});
286276

287-
it('applies grace period when server unreachable during validation', async () => {
277+
it('falls back to regex: returns pro_editor when server unreachable during validation', async () => {
288278
vi.useFakeTimers({ shouldAdvanceTime: true });
289-
const now = Date.now();
290279
const token = buildTestJwt({ product: 'pro_editor', exp: futureUnix(365) });
291280
mockSecrets.set('as-notes.activationToken', token);
292281
mockSecrets.set('as-notes.licenceKey', VALID_KEY);
293-
mockSecrets.set('as-notes.lastValidated', (now - 86_400_000).toString()); // 1 day ago
282+
mockSecrets.set('as-notes.lastValidated', (Date.now() - 86_400_000).toString());
294283
mockSecrets.set('as-notes.licenceState', JSON.stringify({ status: 'valid', product: 'pro_editor' }));
295284

296285
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
@@ -300,8 +289,36 @@ describe('validateWithServer', () => {
300289
const result = await resultPromise;
301290
expect(result.status).toBe('valid');
302291
expect(result.product).toBe('pro_editor');
292+
expect(result.serverUnreachable).toBe(true);
293+
294+
// Verify state was persisted
295+
const persisted = JSON.parse(mockSecrets.get('as-notes.licenceState')!);
296+
expect(persisted.status).toBe('valid');
297+
expect(persisted.product).toBe('pro_editor');
303298

304299
vi.unstubAllGlobals();
305300
vi.useRealTimers();
306301
});
302+
303+
it('persisted fallback state survives restart via loadCachedLicenceState', async () => {
304+
vi.useFakeTimers({ shouldAdvanceTime: true });
305+
306+
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
307+
308+
// Activate with server unreachable — fallback persists state
309+
const activatePromise = activateWithServer(VALID_KEY, buildContext() as any);
310+
await vi.advanceTimersByTimeAsync(25_000);
311+
const result = await activatePromise;
312+
expect(result.status).toBe('valid');
313+
314+
vi.unstubAllGlobals();
315+
vi.useRealTimers();
316+
317+
// Simulate restart: load cached state (no server call)
318+
const { loadCachedLicenceState } = await import('../LicenceActivationService.js');
319+
const cachedState = await loadCachedLicenceState(buildContext() as any);
320+
expect(cachedState.status).toBe('valid');
321+
expect(cachedState.product).toBe('pro_editor');
322+
expect(cachedState.serverUnreachable).toBe(true);
323+
});
307324
});

0 commit comments

Comments
 (0)