ReviewOS

also looking at this

stacks/uplink

fix(app): refuse a license cache this build cannot vouch for

#47
Merged glennmichael123 wants to merge fix/license-state-version into main
2 files +158 -7
app/Uplink/license.tsmodified+71-5
Changes to app/Uplink/license.ts
@@ -27,9 +27,23 @@ const KEYCHAIN_SERVICE = 'com.stacksjs.uplink'
2727const KEYCHAIN_ACCOUNT = 'license-key'
2828const CACHE_PATH = join(DATA_DIR, 'license.json')
2929
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 */
39const STATE_VERSION = 1
40
41export const PLANS = ['monthly', 'yearly', 'lifetime'] as const
42export type Plan = typeof PLANS[number]
43
3044export interface LicenseState {
3145 valid: boolean
32 plan: 'monthly' | 'yearly' | 'lifetime' | null
46 plan: Plan | null
3347 status: string
3448 email: string | null
3549 /** ISO time the paid period ends; null for lifetime. */
@@ -87,7 +101,7 @@ export async function checkLicenseKey(key: string, fetcher: typeof fetch = fetch
87101 kind: 'answered',
88102 state: {
89103 valid: body.valid === true,
90 plan: body.plan ?? null,
104 plan: planOf(body.plan),
91105 status: String(body.status ?? ''),
92106 email: body.email ?? null,
93107 expiresAt: body.expiresAt ?? null,
@@ -160,15 +174,67 @@ export function readLicenseState(path = CACHE_PATH): LicenseState | null {
160174 if (!existsSync(path))
161175 return null
162176 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')))
165178 }
166179 catch {
180 // Not JSON at all.
167181 return null
168182 }
169183}
170184
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 */
193export 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
230function planOf(value: unknown): Plan | null {
231 return typeof value === 'string' && (PLANS as readonly string[]).includes(value) ? value as Plan : null
232}
233
171234export function writeLicenseState(state: LicenseState | null, path = CACHE_PATH): void {
172235 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 })
174240}