also looking at this
fix(app): refuse a license cache this build cannot vouch for
#47
2 files
+158
-7
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -27,9 +27,23 @@ const KEYCHAIN_SERVICE = 'com.stacksjs.uplink' | ||
| 27 | 27 | const KEYCHAIN_ACCOUNT = 'license-key' |
| 28 | 28 | const CACHE_PATH = join(DATA_DIR, 'license.json') |
| 29 | 29 | |
| 30 | /** | |
| 31 | * Stamped into the cache file, and bumped whenever `LicenseState` changes | |
| 32 | * shape. A file carrying any other version is refused rather than read. | |
| 33 | * | |
| 34 | * Refusing costs one request to the license server, which the launch makes | |
| 35 | * anyway. Reading an older shape as though it were this one is how a Mac ends | |
| 36 | * up entitled for two weeks on a field that used to mean something else, with | |
| 37 | * nothing in the file to say which build wrote it. | |
| 38 | */ | |
| 39 | const STATE_VERSION = 1 | |
| 40 | ||
| 41 | export const PLANS = ['monthly', 'yearly', 'lifetime'] as const | |
| 42 | export type Plan = typeof PLANS[number] | |
| 43 | ||
| 30 | 44 | export interface LicenseState { |
| 31 | 45 | valid: boolean |
| 32 | plan: 'monthly' | 'yearly' | 'lifetime' | null | |
| 46 | plan: Plan | null | |
| 33 | 47 | status: string |
| 34 | 48 | email: string | null |
| 35 | 49 | /** ISO time the paid period ends; null for lifetime. */ |
| @@ -87,7 +101,7 @@ export async function checkLicenseKey(key: string, fetcher: typeof fetch = fetch | ||
| 87 | 101 | kind: 'answered', |
| 88 | 102 | state: { |
| 89 | 103 | valid: body.valid === true, |
| 90 | plan: body.plan ?? null, | |
| 104 | plan: planOf(body.plan), | |
| 91 | 105 | status: String(body.status ?? ''), |
| 92 | 106 | email: body.email ?? null, |
| 93 | 107 | expiresAt: body.expiresAt ?? null, |
| @@ -160,15 +174,67 @@ export function readLicenseState(path = CACHE_PATH): LicenseState | null { | ||
| 160 | 174 | if (!existsSync(path)) |
| 161 | 175 | return null |
| 162 | 176 | try { |
| 163 | const state = JSON.parse(readFileSync(path, 'utf8')) as LicenseState | |
| 164 | return typeof state.checkedAt === 'number' ? state : null | |
| 177 | return parseLicenseState(JSON.parse(readFileSync(path, 'utf8'))) | |
| 165 | 178 | } |
| 166 | 179 | catch { |
| 180 | // Not JSON at all. | |
| 167 | 181 | return null |
| 168 | 182 | } |
| 169 | 183 | } |
| 170 | 184 | |
| 185 | /** | |
| 186 | * The cached answer, or null for anything this build cannot vouch for. | |
| 187 | * | |
| 188 | * Null is not a failure worth reporting: the key is in the Keychain, the | |
| 189 | * launch asks the server, and an online Mac never notices. A Mac that is | |
| 190 | * offline at the moment it upgrades is asked to connect once, which is the | |
| 191 | * right thing to ask and the wrong thing to guess. | |
| 192 | */ | |
| 193 | export function parseLicenseState(input: unknown): LicenseState | null { | |
| 194 | if (!input || typeof input !== 'object') | |
| 195 | return null | |
| 196 | const state = input as Record<string, unknown> | |
| 197 | ||
| 198 | // 0.1.4 wrote no version and the shape has not changed since, so an | |
| 199 | // unstamped file is this one. Refusing those would have locked out exactly | |
| 200 | // the Macs this cache exists for, the ones that are not online. | |
| 201 | const version = state.version === undefined ? STATE_VERSION : state.version | |
| 202 | if (version !== STATE_VERSION) | |
| 203 | return null | |
| 204 | ||
| 205 | // `valid` and `checkedAt` are the two that decide whether Uplink answers at | |
| 206 | // all, and the rest is what the menubar prints. A file that is wrong about | |
| 207 | // any of them was not written by this build, so none of it is evidence. | |
| 208 | if (typeof state.valid !== 'boolean' || typeof state.checkedAt !== 'number' || !Number.isFinite(state.checkedAt)) | |
| 209 | return null | |
| 210 | if (state.plan !== null && planOf(state.plan) === null) | |
| 211 | return null | |
| 212 | if (typeof state.status !== 'string' || typeof state.endsAtPeriodEnd !== 'boolean') | |
| 213 | return null | |
| 214 | if (state.email !== null && typeof state.email !== 'string') | |
| 215 | return null | |
| 216 | if (state.expiresAt !== null && typeof state.expiresAt !== 'string') | |
| 217 | return null | |
| 218 | ||
| 219 | return { | |
| 220 | valid: state.valid, | |
| 221 | plan: planOf(state.plan), | |
| 222 | status: state.status, | |
| 223 | email: state.email, | |
| 224 | expiresAt: state.expiresAt, | |
| 225 | endsAtPeriodEnd: state.endsAtPeriodEnd, | |
| 226 | checkedAt: state.checkedAt, | |
| 227 | } | |
| 228 | } | |
| 229 | ||
| 230 | function planOf(value: unknown): Plan | null { | |
| 231 | return typeof value === 'string' && (PLANS as readonly string[]).includes(value) ? value as Plan : null | |
| 232 | } | |
| 233 | ||
| 171 | 234 | export function writeLicenseState(state: LicenseState | null, path = CACHE_PATH): void { |
| 172 | 235 | mkdirSync(join(path, '..'), { recursive: true }) |
| 173 | writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 }) | |
| 236 | // The version leads, so the first line of the file says which build's | |
| 237 | // meaning the rest carries. | |
| 238 | const body = state === null ? null : { version: STATE_VERSION, ...state } | |
| 239 | writeFileSync(path, `${JSON.stringify(body, null, 2)}\n`, { mode: 0o600 }) | |
| 174 | 240 | } |
| @@ -1,6 +1,9 @@ | ||
| 1 | 1 | import type { LicenseState } from '../../../app/Uplink/license' |
| 2 | import { describe, expect, it } from 'bun:test' | |
| 3 | import { checkLicenseKey, describeLicense, isLicensed, keyFromLink, normalizeKey, OFFLINE_GRACE_MS, portalUrl } from '../../../app/Uplink/license' | |
| 2 | import { afterEach, describe, expect, it } from 'bun:test' | |
| 3 | import { mkdtempSync, rmSync } from 'node:fs' | |
| 4 | import { tmpdir } from 'node:os' | |
| 5 | import { join } from 'node:path' | |
| 6 | import { checkLicenseKey, describeLicense, isLicensed, keyFromLink, normalizeKey, OFFLINE_GRACE_MS, parseLicenseState, portalUrl, readLicenseState, writeLicenseState } from '../../../app/Uplink/license' | |
| 4 | 7 | |
| 5 | 8 | const NOW = Date.parse('2026-10-01T12:00:00Z') |
| 6 | 9 | const KEY = 'UPLK-ABCD-EFGH-JKLM-NPQR' |
| @@ -68,3 +71,85 @@ describe('activation links', () => { | ||
| 68 | 71 | expect(normalizeKey('UPLK-0000-1111-2222-3333')).toBeNull() |
| 69 | 72 | }) |
| 70 | 73 | }) |
| 74 | ||
| 75 | const dirs: string[] = [] | |
| 76 | function tempDir(): string { | |
| 77 | const dir = mkdtempSync(join(tmpdir(), 'uplink-license-')) | |
| 78 | dirs.push(dir) | |
| 79 | return dir | |
| 80 | } | |
| 81 | afterEach(() => { | |
| 82 | for (const dir of dirs.splice(0)) | |
| 83 | rmSync(dir, { recursive: true, force: true }) | |
| 84 | }) | |
| 85 | ||
| 86 | describe('the cached answer', () => { | |
| 87 | it('reads back exactly what it wrote', () => { | |
| 88 | const path = join(tempDir(), 'license.json') | |
| 89 | writeLicenseState(state(), path) | |
| 90 | expect(readLicenseState(path)).toEqual(state()) | |
| 91 | ||
| 92 | writeLicenseState(state({ plan: 'lifetime', expiresAt: null, email: null }), path) | |
| 93 | expect(readLicenseState(path)).toEqual(state({ plan: 'lifetime', expiresAt: null, email: null })) | |
| 94 | }) | |
| 95 | ||
| 96 | /** | |
| 97 | * 0.1.4 shipped before the version existed, so a Mac activated then has an | |
| 98 | * unstamped file. Refusing it would ask exactly the wrong people, the ones | |
| 99 | * off-grid, to connect. The shape has not changed, so it is this version. | |
| 100 | */ | |
| 101 | it('accepts the unstamped file 0.1.4 wrote', () => { | |
| 102 | const { version, ...unstamped } = { version: 1, ...state() } | |
| 103 | expect(version).toBe(1) | |
| 104 | expect(parseLicenseState(unstamped)).toEqual(state()) | |
| 105 | }) | |
| 106 | ||
| 107 | it('refuses a version it does not understand rather than reading it', () => { | |
| 108 | expect(parseLicenseState({ ...state(), version: 2 })).toBeNull() | |
| 109 | expect(parseLicenseState({ ...state(), version: 0 })).toBeNull() | |
| 110 | expect(parseLicenseState({ ...state(), version: 'one' })).toBeNull() | |
| 111 | }) | |
| 112 | ||
| 113 | /** | |
| 114 | * The file entitles a Mac for two weeks without reaching the server, so | |
| 115 | * anything it is wrong about makes the whole file not evidence. Null just | |
| 116 | * means the launch asks the server, which it does anyway. | |
| 117 | */ | |
| 118 | it('refuses a file that is wrong about any field', () => { | |
| 119 | expect(parseLicenseState({ ...state(), valid: 'true' })).toBeNull() | |
| 120 | expect(parseLicenseState({ ...state(), checkedAt: '123' })).toBeNull() | |
| 121 | expect(parseLicenseState({ ...state(), checkedAt: Number.NaN })).toBeNull() | |
| 122 | expect(parseLicenseState({ ...state(), plan: 'enterprise' })).toBeNull() | |
| 123 | expect(parseLicenseState({ ...state(), status: 42 })).toBeNull() | |
| 124 | expect(parseLicenseState({ ...state(), endsAtPeriodEnd: 'no' })).toBeNull() | |
| 125 | expect(parseLicenseState({ ...state(), email: 5 })).toBeNull() | |
| 126 | expect(parseLicenseState({ ...state(), expiresAt: 0 })).toBeNull() | |
| 127 | // And the shapes that are not a state at all. | |
| 128 | for (const body of [null, undefined, [], 'UPLK', 7, {}]) | |
| 129 | expect(parseLicenseState(body)).toBeNull() | |
| 130 | }) | |
| 131 | ||
| 132 | it('survives a file that is missing or not JSON', () => { | |
| 133 | const path = join(tempDir(), 'license.json') | |
| 134 | expect(readLicenseState(path)).toBeNull() | |
| 135 | Bun.write(path, '{ not json') | |
| 136 | expect(readLicenseState(path)).toBeNull() | |
| 137 | }) | |
| 138 | ||
| 139 | /** | |
| 140 | * `checkLicenseKey` writes what the server said, so a plan name this build | |
| 141 | * does not know must be refused there rather than stored and refused on the | |
| 142 | * next read, which would cost the Mac its cache for no reason. | |
| 143 | */ | |
| 144 | it('never stores a plan it would refuse to read back', async () => { | |
| 145 | const result = await checkLicenseKey(KEY, answering(200, { valid: true, plan: 'enterprise', status: 'active', email: null, expiresAt: null, endsAtPeriodEnd: false }), NOW) | |
| 146 | expect(result.kind).toBe('answered') | |
| 147 | if (result.kind !== 'answered') | |
| 148 | return | |
| 149 | expect(result.state.plan).toBeNull() | |
| 150 | ||
| 151 | const path = join(tempDir(), 'license.json') | |
| 152 | writeLicenseState(result.state, path) | |
| 153 | expect(readLicenseState(path)).toEqual(result.state) | |
| 154 | }) | |
| 155 | }) | |