ticketlens 0.38.26 → 0.38.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.38.26",
3
+ "version": "0.38.27",
4
4
  "description": "Jira CLI for developers — fetch ticket context, triage your queue, and stop tab-switching. Zero dependencies, all local.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,12 @@ import { readMachineSecret, readOrCreateMachineSecret, signHmac, verifyHmac } fr
12
12
  const TOKEN_FILE = 'cli-token.json';
13
13
  // Distinct from license.mjs's LICENSE_HMAC_SALT so a license.json signature
14
14
  // can never be replayed as a valid cli-token.json tier signature, or vice versa.
15
- const CLI_TOKEN_HMAC_SALT = 'tl-tok-v1';
15
+ export const CLI_TOKEN_HMAC_SALT = 'tl-tok-v1';
16
+ // Shorter than license.mjs's 30-day GRACE_DAYS — a team seat being removed is a
17
+ // security action by the manager, not a billing lapse, so the offline window a
18
+ // revoked member's stale local access can survive on should be tighter.
19
+ const TEAM_TIER_GRACE_DAYS = 7;
20
+ const MS_PER_DAY = 86400000;
16
21
 
17
22
  export function cliTokenPath(configDir = DEFAULT_CONFIG_DIR) {
18
23
  return join(configDir, TOKEN_FILE);
@@ -34,21 +39,38 @@ export function readCliToken(configDir = DEFAULT_CONFIG_DIR) {
34
39
  // Lets team-invited members get Pro-equivalent CLI access without a personal
35
40
  // TL- key, mirroring what the manager already pays for.
36
41
  //
37
- // Signed with a machine-bound HMAC so `tier` can't be hand-edited in the file
38
- // to escalate entitlement. A missing or invalid signature is treated as no
39
- // tier at all (falls back to free) rather than trusted — self-heals on the
40
- // next `tl login`/`tl sync`, both of which re-write and re-sign it.
42
+ // Signed with a machine-bound HMAC so `tier`/`syncedAt` can't be hand-edited in
43
+ // the file to escalate or extend entitlement. A missing or invalid signature is
44
+ // treated as no tier at all (falls back to free) rather than trusted — self-heals
45
+ // on the next `tl login`/`tl sync`, both of which re-write and re-sign it. A file
46
+ // with no `syncedAt` (written before this grace-period check existed) is rejected
47
+ // outright for the same reason, rather than treated as permanently fresh.
48
+ //
49
+ // TEAM_TIER_GRACE_DAYS bounds how long a synced team-seat tier stays valid without
50
+ // a fresh login/sync — mirrors license.mjs's offline GRACE_DAYS so a team member
51
+ // removed server-side can't keep local access indefinitely just by never syncing.
41
52
  export function readCliTokenTier(configDir = DEFAULT_CONFIG_DIR) {
42
53
  const p = cliTokenPath(configDir);
43
54
  if (!existsSync(p)) return null;
44
55
  try {
45
56
  const data = JSON.parse(readFileSync(p, 'utf8'));
46
- if (typeof data.tier !== 'string' || typeof data.tierSig !== 'string') return null;
57
+ if (typeof data.tier !== 'string' || typeof data.tierSig !== 'string' || typeof data.syncedAt !== 'string') {
58
+ return null;
59
+ }
47
60
  const secret = readMachineSecret(configDir);
48
61
  if (!secret) return null;
49
- return verifyHmac(data.tierSig, { tier: data.tier }, `${CLI_TOKEN_HMAC_SALT}:${secret}`)
50
- ? data.tier
51
- : null;
62
+ const signed = { tier: data.tier, syncedAt: data.syncedAt };
63
+ if (!verifyHmac(data.tierSig, signed, `${CLI_TOKEN_HMAC_SALT}:${secret}`)) return null;
64
+
65
+ // An unparseable syncedAt gives NaN — rejected on its own line rather than
66
+ // leaning on NaN quietly failing the grace-period comparison below.
67
+ const syncedAtMs = new Date(data.syncedAt).getTime();
68
+ if (Number.isNaN(syncedAtMs)) return null;
69
+
70
+ const daysSinceSync = (Date.now() - syncedAtMs) / MS_PER_DAY;
71
+ if (daysSinceSync > TEAM_TIER_GRACE_DAYS) return null;
72
+
73
+ return data.tier;
52
74
  } catch {
53
75
  return null;
54
76
  }
@@ -60,8 +82,9 @@ export function saveCliToken(token, configDir = DEFAULT_CONFIG_DIR, tier = undef
60
82
  let payload = { token };
61
83
  if (tier !== undefined) {
62
84
  const secret = readOrCreateMachineSecret(configDir);
63
- const tierSig = signHmac({ tier }, `${CLI_TOKEN_HMAC_SALT}:${secret}`);
64
- payload = { token, tier, tierSig };
85
+ const syncedAt = new Date().toISOString();
86
+ const tierSig = signHmac({ tier, syncedAt }, `${CLI_TOKEN_HMAC_SALT}:${secret}`);
87
+ payload = { token, tier, syncedAt, tierSig };
65
88
  }
66
89
  writeFileSync(p, JSON.stringify(payload, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
67
90
  chmodSync(p, 0o600);
@@ -22,6 +22,16 @@ export const getApiBase = () => apiBase();
22
22
  // Strip the api. subdomain to get the console base URL (e.g. api.ticketlens.app → ticketlens.app)
23
23
  export const getConsoleBase = () => getApiBase().replace('://api.', '://');
24
24
 
25
+ /**
26
+ * Server wins on tier same as every other field this file syncs — a missing or
27
+ * non-string `tier` in the response must clear a stale locally-elevated tier
28
+ * rather than leave it untouched, so a downgrade (or a malformed response) can
29
+ * never be silently ignored.
30
+ */
31
+ function resolveTierFromResponse(json) {
32
+ return typeof json?.tier === 'string' ? json.tier : 'free';
33
+ }
34
+
25
35
  /**
26
36
  * Convert a server profile (snake_case) to the CLI profile shape (camelCase).
27
37
  * Returns { name, profileData } — no credential fields.
@@ -90,7 +100,7 @@ export async function syncProfiles({
90
100
  let json;
91
101
  try { json = await res.json(); } catch { return { error: 'invalid-json' }; }
92
102
 
93
- if (typeof json?.tier === 'string') saveCliTokenTier(json.tier, configDir);
103
+ saveCliTokenTier(resolveTierFromResponse(json), configDir);
94
104
  saveTeams(Array.isArray(json?.teams) ? json.teams : [], configDir);
95
105
 
96
106
  const remoteProfiles = Array.isArray(json?.profiles) ? json.profiles : [];
@@ -159,6 +169,14 @@ export async function checkTeamMembershipUpdate({ configDir = DEFAULT_CONFIG_DIR
159
169
  let json;
160
170
  try { json = await res.json(); } catch { return { updated: false }; }
161
171
 
172
+ // Refresh the signed tier from the same payload this check already fetches —
173
+ // this fires on every `ticketlens TICKET-123` fetch, so it shrinks the real-world
174
+ // exposure window down from the grace period (cli-auth.mjs's TEAM_TIER_GRACE_DAYS)
175
+ // to "next fetch" for anyone who ever runs one. The grace period remains the
176
+ // backstop for local-only commands (history, schedule --local, review) that never
177
+ // reach this function at all.
178
+ saveCliTokenTier(resolveTierFromResponse(json), configDir);
179
+
162
180
  // Sanitized immediately on receipt — remoteTeams feeds both the banner
163
181
  // text below (built before saveTeams' own sanitization would ever run)
164
182
  // and the persisted cache, so both must see the cleaned name.