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}
tests/unit/uplink/license.test.tsmodified+87-2
Changes to tests/unit/uplink/license.test.ts
@@ -1,6 +1,9 @@
11import type { LicenseState } from '../../../app/Uplink/license'
2import { describe, expect, it } from 'bun:test'
3import { checkLicenseKey, describeLicense, isLicensed, keyFromLink, normalizeKey, OFFLINE_GRACE_MS, portalUrl } from '../../../app/Uplink/license'
2import { afterEach, describe, expect, it } from 'bun:test'
3import { mkdtempSync, rmSync } from 'node:fs'
4import { tmpdir } from 'node:os'
5import { join } from 'node:path'
6import { checkLicenseKey, describeLicense, isLicensed, keyFromLink, normalizeKey, OFFLINE_GRACE_MS, parseLicenseState, portalUrl, readLicenseState, writeLicenseState } from '../../../app/Uplink/license'
47
58const NOW = Date.parse('2026-10-01T12:00:00Z')
69const KEY = 'UPLK-ABCD-EFGH-JKLM-NPQR'
@@ -68,3 +71,85 @@ describe('activation links', () => {
6871 expect(normalizeKey('UPLK-0000-1111-2222-3333')).toBeNull()
6972 })
7073})
74
75const dirs: string[] = []
76function tempDir(): string {
77 const dir = mkdtempSync(join(tmpdir(), 'uplink-license-'))
78 dirs.push(dir)
79 return dir
80}
81afterEach(() => {
82 for (const dir of dirs.splice(0))
83 rmSync(dir, { recursive: true, force: true })
84})
85
86describe('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})