also looking at this
fix(app): refuse a license cache this build cannot vouch for
#47
2 files
+158
-7
| @@ -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 | } |