@oml/cli 0.14.17 → 0.16.0

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/src/auth/auth.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  // Copyright (c) 2026 Modelware. All rights reserved.
2
2
 
3
- import { exchangeGitHubToken, refreshSupabaseAccessToken } from '@oml/platform';
3
+ import { exchangeGitHubToken, refreshSupabaseAccessToken, verifyEntitlementsToken } from '@oml/platform';
4
4
  import chalk from 'chalk';
5
+ import * as crypto from 'node:crypto';
6
+ import type { JsonWebKey } from 'node:crypto';
5
7
  import * as fs from 'node:fs/promises';
6
8
  import * as os from 'node:os';
7
9
  import * as path from 'node:path';
@@ -23,7 +25,10 @@ const KEYCHAIN_SERVICE = 'oml-code';
23
25
  const ACCESS_TOKEN_KEY = 'oml.cli.access_token';
24
26
  const REFRESH_TOKEN_KEY = 'oml.cli.refresh_token';
25
27
  const EXPIRES_AT_KEY = 'oml.cli.expires_at';
26
- const PROFILE_PATH = path.join(os.homedir(), '.oml', 'cli-profile.json');
28
+ const ENTITLEMENT_EXPIRY_KEY = 'oml.cli.entitlement_expiry';
29
+ const ENTITLEMENT_FEATURES_KEY = 'oml.cli.entitlement_features';
30
+ const ENTITLEMENT_TOKEN_KEY = 'oml.cli.entitlement_token';
31
+ const ENTITLEMENT_PUBKEY_KEY = 'oml.cli.entitlement_pubkey';
27
32
  const LOCK_PATH = path.join(os.homedir(), '.oml', 'credentials.lock');
28
33
  const SESSION_EXPIRATION_LEEWAY_MS = 20_000;
29
34
  const LOCK_TIMEOUT_MS = 5_000;
@@ -37,17 +42,6 @@ type KeytarModule = {
37
42
 
38
43
  let keytarModulePromise: Promise<KeytarModule> | undefined;
39
44
 
40
- type Provider = 'github';
41
-
42
- type StoredProfile = {
43
- provider: Provider;
44
- userId: string;
45
- userLabel?: string;
46
- email: string | null;
47
- tier?: string;
48
- signedInAt: string;
49
- };
50
-
51
45
  type StoredCredential = {
52
46
  accessToken: string;
53
47
  refreshToken: string;
@@ -77,10 +71,9 @@ export class OmlCliAuthService {
77
71
 
78
72
  const existing = await this.tryGetValidSnapshot();
79
73
  if (existing) {
80
- const profile = await readProfile();
81
- console.error(chalk.green(
82
- `Already signed in as ${profile?.userLabel ?? profile?.email ?? profile?.userId ?? 'current user'}.`
83
- ));
74
+ const claims = decodeJwtClaims(existing.accessToken);
75
+ const label = claims.userLabel ?? claims.email ?? 'current user';
76
+ console.error(chalk.green(`Already signed in as ${label}.`));
84
77
  return;
85
78
  }
86
79
 
@@ -90,33 +83,14 @@ export class OmlCliAuthService {
90
83
  refreshToken: session.refreshToken,
91
84
  expiresAtMs: session.expiresAtMs,
92
85
  });
93
- await writeProfile({
94
- provider: session.provider,
95
- userId: session.userId,
96
- userLabel: session.userLabel,
97
- email: session.email,
98
- tier: session.tier,
99
- signedInAt: new Date().toISOString(),
100
- });
86
+ void fetchAndSaveEntitlementsPubkey(resolveApiBaseUrl(), this).catch(() => undefined);
101
87
  const summary = session.userLabel ?? session.email ?? 'signed-in user';
102
- console.error(chalk.green(`Signed in as ${summary} via ${session.provider}.`));
88
+ console.error(chalk.green(`Signed in as ${summary} via GitHub.`));
103
89
  }
104
90
 
105
91
  async logout(): Promise<void> {
106
- const activeServers = await listActiveServers();
107
92
  await deleteCredential();
108
- await deleteProfile();
109
- if (activeServers.length > 0) {
110
- console.error(chalk.yellow('Stored OAuth credentials were cleared. Running servers were not stopped:'));
111
- for (const server of activeServers) {
112
- console.error(`- ${server.workspaceRoot ?? '(unknown workspace)'} on port ${server.port} (pid ${server.pid})`);
113
- }
114
- if (process.env[API_KEY_ENV]?.trim()) {
115
- console.error(chalk.yellow('OML_PLATFORM_API_KEY is still set, so future starts may use API-key auth.'));
116
- }
117
- return;
118
- }
119
- console.error(chalk.green('Stored OAuth credentials were cleared.'));
93
+ console.error(chalk.green('Signed out.'));
120
94
  if (process.env[API_KEY_ENV]?.trim()) {
121
95
  console.error(chalk.yellow('OML_PLATFORM_API_KEY is still set, so future starts may use API-key auth.'));
122
96
  }
@@ -127,27 +101,85 @@ export class OmlCliAuthService {
127
101
  if (apiKey) {
128
102
  console.error('Auth mode: api_key');
129
103
  console.error('Account: resolved by OML Platform from OML_PLATFORM_API_KEY');
130
- const profile = await readProfile();
131
- if (profile) {
132
- console.error(`Stored OAuth user: ${profile.userLabel ?? profile.email ?? profile.userId}`);
133
- }
134
104
  return;
135
105
  }
136
106
 
137
107
  const credential = await readCredential();
138
- const profile = await readProfile();
139
- if (!credential || !profile) {
108
+ if (!credential) {
140
109
  console.error(chalk.yellow('Not signed in.'));
141
110
  return;
142
111
  }
112
+ const claims = decodeJwtClaims(credential.accessToken);
143
113
  console.error('Auth mode: oauth');
144
- console.error(`Provider: ${profile.provider}`);
145
- console.error(`User ID: ${profile.userId}`);
146
- console.error(`User label: ${profile.userLabel ?? '(not set)'}`);
147
- console.error(`Email: ${profile.email ?? '(not set)'}`);
148
- console.error(`Tier: ${profile.tier ?? '(not set)'}`);
149
- console.error(`Signed in at: ${profile.signedInAt}`);
150
- console.error(`Access token expires at: ${new Date(credential.expiresAtMs).toISOString()}`);
114
+ console.error(`User ID: ${claims.userId ?? '(unknown)'}`);
115
+ console.error(`User label: ${claims.userLabel ?? '(not set)'}`);
116
+ console.error(`Email: ${claims.email ?? '(not set)'}`);
117
+ console.error(`Tier: ${claims.tier ?? '(not set)'}`);
118
+ console.error(`Token expires at: ${new Date(credential.expiresAtMs).toISOString()}`);
119
+ }
120
+
121
+ async getDeviceId(): Promise<string> {
122
+ return getOrCreateDeviceId();
123
+ }
124
+
125
+ async getEntitlementCache(): Promise<{ expiry: number; featureIds: string[]; token?: string } | null> {
126
+ try {
127
+ const keytar = await getKeytarModule();
128
+ const [expiryRaw, featuresRaw, tokenRaw, pubkeyRaw] = await Promise.all([
129
+ keytar.getPassword(KEYCHAIN_SERVICE, ENTITLEMENT_EXPIRY_KEY),
130
+ keytar.getPassword(KEYCHAIN_SERVICE, ENTITLEMENT_FEATURES_KEY),
131
+ keytar.getPassword(KEYCHAIN_SERVICE, ENTITLEMENT_TOKEN_KEY),
132
+ keytar.getPassword(KEYCHAIN_SERVICE, ENTITLEMENT_PUBKEY_KEY),
133
+ ]);
134
+ const expiry = Number(expiryRaw ?? NaN);
135
+ if (!Number.isFinite(expiry) || expiry <= Date.now() || !featuresRaw) {
136
+ return null;
137
+ }
138
+ const featureIds = JSON.parse(featuresRaw) as unknown;
139
+ if (!Array.isArray(featureIds) || !featureIds.every((x) => typeof x === 'string')) {
140
+ return null;
141
+ }
142
+ let token: string | undefined;
143
+ if (tokenRaw && pubkeyRaw) {
144
+ try {
145
+ const deviceId = await getOrCreateDeviceId();
146
+ const valid = await verifyEntitlementsToken(tokenRaw, deviceId, JSON.parse(pubkeyRaw) as JsonWebKey);
147
+ if (valid) {
148
+ token = tokenRaw;
149
+ }
150
+ } catch {
151
+ // Verification failed — proceed without token; gate will re-fetch.
152
+ }
153
+ }
154
+ return { expiry, featureIds: featureIds as string[], token };
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
159
+
160
+ async saveEntitlementCache(expiry: number, featureIds: string[], token?: string): Promise<void> {
161
+ try {
162
+ const keytar = await getKeytarModule();
163
+ const writes: Promise<void>[] = [
164
+ keytar.setPassword(KEYCHAIN_SERVICE, ENTITLEMENT_EXPIRY_KEY, String(expiry)),
165
+ keytar.setPassword(KEYCHAIN_SERVICE, ENTITLEMENT_FEATURES_KEY, JSON.stringify(featureIds)),
166
+ ];
167
+ if (token) {
168
+ writes.push(keytar.setPassword(KEYCHAIN_SERVICE, ENTITLEMENT_TOKEN_KEY, token));
169
+ }
170
+ await Promise.all(writes);
171
+ } catch {
172
+ // Credential store unavailable — skip silently.
173
+ }
174
+ }
175
+
176
+ async saveEntitlementsPubkey(pubkeyJwk: JsonWebKey): Promise<void> {
177
+ try {
178
+ const keytar = await getKeytarModule();
179
+ await keytar.setPassword(KEYCHAIN_SERVICE, ENTITLEMENT_PUBKEY_KEY, JSON.stringify(pubkeyJwk));
180
+ } catch {
181
+ // Credential store unavailable — skip silently.
182
+ }
151
183
  }
152
184
 
153
185
  async ensureAuthenticated(operationName: string): Promise<void> {
@@ -198,6 +230,10 @@ export class OmlCliAuthService {
198
230
  }
199
231
  }
200
232
 
233
+ async hasStoredCredential(): Promise<boolean> {
234
+ return (await readCredential()) !== undefined;
235
+ }
236
+
201
237
  private async refreshCredential(): Promise<StoredCredential> {
202
238
  const session = await readCredential();
203
239
  if (!session?.refreshToken) {
@@ -222,18 +258,10 @@ export class OmlCliAuthService {
222
258
  expiresAtMs: Date.now() + (refreshed.expires_in * 1000),
223
259
  };
224
260
  await writeCredential(updatedSession);
225
- const profile = await readProfile();
226
- if (profile) {
227
- await writeProfile({
228
- ...profile,
229
- email: refreshed.email ?? profile.email,
230
- });
231
- }
232
261
  return updatedSession;
233
262
  } catch (error) {
234
263
  if (isUnauthorizedError(error)) {
235
264
  await deleteCredential();
236
- await deleteProfile();
237
265
  throw new Error('Authentication refresh failed because the stored credential was revoked. Run \'oml login\' again.');
238
266
  }
239
267
  throw new Error('Authentication refresh failed. Check your network connection or sign in again with \'oml login\'.');
@@ -272,77 +300,46 @@ async function deleteCredential(): Promise<void> {
272
300
  keytar.deletePassword(KEYCHAIN_SERVICE, ACCESS_TOKEN_KEY),
273
301
  keytar.deletePassword(KEYCHAIN_SERVICE, REFRESH_TOKEN_KEY),
274
302
  keytar.deletePassword(KEYCHAIN_SERVICE, EXPIRES_AT_KEY),
303
+ keytar.deletePassword(KEYCHAIN_SERVICE, ENTITLEMENT_EXPIRY_KEY),
304
+ keytar.deletePassword(KEYCHAIN_SERVICE, ENTITLEMENT_FEATURES_KEY),
305
+ keytar.deletePassword(KEYCHAIN_SERVICE, ENTITLEMENT_TOKEN_KEY),
306
+ keytar.deletePassword(KEYCHAIN_SERVICE, ENTITLEMENT_PUBKEY_KEY),
275
307
  ]);
276
308
  }
277
309
 
278
- async function readProfile(): Promise<StoredProfile | undefined> {
310
+ async function getOrCreateDeviceId(): Promise<string> {
279
311
  try {
280
- const content = await fs.readFile(PROFILE_PATH, 'utf-8');
281
- const data = JSON.parse(content) as Partial<StoredProfile>;
282
- if (!data.provider || !data.userId || !data.signedInAt) {
283
- return undefined;
284
- }
285
- return {
286
- provider: data.provider,
287
- userId: data.userId,
288
- userLabel: data.userLabel,
289
- email: data.email ?? null,
290
- tier: data.tier,
291
- signedInAt: data.signedInAt,
292
- };
312
+ return (await fs.readFile('/etc/machine-id', 'utf-8')).trim();
293
313
  } catch {
294
- return undefined;
314
+ try {
315
+ return (await fs.readFile(LOCAL_MACHINE_ID_PATH, 'utf-8')).trim();
316
+ } catch {
317
+ const id = crypto.randomUUID();
318
+ await fs.mkdir(path.dirname(LOCAL_MACHINE_ID_PATH), { recursive: true });
319
+ await fs.writeFile(LOCAL_MACHINE_ID_PATH, id, { encoding: 'utf-8', mode: 0o600 });
320
+ return id;
321
+ }
295
322
  }
296
323
  }
297
324
 
298
- async function writeProfile(profile: StoredProfile): Promise<void> {
299
- await fs.mkdir(path.dirname(PROFILE_PATH), { recursive: true });
300
- await fs.writeFile(PROFILE_PATH, `${JSON.stringify(profile, null, 2)}\n`, 'utf-8');
301
- }
302
-
303
- async function deleteProfile(): Promise<void> {
304
- try {
305
- await fs.unlink(PROFILE_PATH);
306
- } catch (error) {
307
- if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
308
- throw error;
309
- }
325
+ async function fetchAndSaveEntitlementsPubkey(apiBaseUrl: string, authService: OmlCliAuthService): Promise<void> {
326
+ const response = await fetch(`${apiBaseUrl}/entitlements/pubkey`);
327
+ if (!response.ok) {
328
+ return;
310
329
  }
330
+ const jwk = await response.json() as JsonWebKey;
331
+ await authService.saveEntitlementsPubkey(jwk);
311
332
  }
312
333
 
313
334
  async function authenticateWithGitHub(): Promise<{
314
- provider: Provider;
315
- userId: string;
316
335
  userLabel?: string;
317
336
  email: string | null;
318
- tier?: string;
319
337
  accessToken: string;
320
338
  refreshToken: string;
321
339
  expiresAtMs: number;
322
340
  }> {
323
341
  const clientId = resolveClientId();
324
- const params = new URLSearchParams({
325
- client_id: clientId,
326
- scope: 'read:user user:email'
327
- });
328
- const response = await fetch(GITHUB_DEVICE_CODE_URL, {
329
- method: 'POST',
330
- headers: {
331
- accept: 'application/json',
332
- 'content-type': 'application/x-www-form-urlencoded'
333
- },
334
- body: params
335
- });
336
- if (!response.ok) {
337
- throw new Error(`GitHub device authorization failed: HTTP ${response.status} ${response.statusText}`);
338
- }
339
- const device = await response.json() as GitHubDeviceCodeResponse;
340
- if (!device.device_code || !device.user_code || !device.verification_uri) {
341
- throw new Error('GitHub device authorization response was incomplete.');
342
- }
343
-
344
- printDeviceFlowInstructions('GitHub', device.verification_uri, device.user_code);
345
- const token = await pollForGitHubAccessToken(clientId, device);
342
+ const token = await authenticateWithGitHubDevice(clientId);
346
343
  const userResponse = await fetch(GITHUB_USER_URL, {
347
344
  headers: {
348
345
  accept: 'application/vnd.github+json',
@@ -360,17 +357,39 @@ async function authenticateWithGitHub(): Promise<{
360
357
  const platformSession = await exchangeGitHubToken(resolveApiBaseUrl(), token);
361
358
 
362
359
  return {
363
- provider: 'github',
364
- userId: platformSession.user_id,
365
360
  userLabel: user.login,
366
361
  email: platformSession.email,
367
- tier: platformSession.tier,
368
362
  accessToken: platformSession.access_token,
369
363
  refreshToken: platformSession.refresh_token,
370
364
  expiresAtMs: Date.now() + platformSession.expires_in * 1000,
371
365
  };
372
366
  }
373
367
 
368
+ async function authenticateWithGitHubDevice(clientId: string): Promise<string> {
369
+ const params = new URLSearchParams({
370
+ client_id: clientId,
371
+ scope: 'read:user user:email'
372
+ });
373
+ const response = await fetch(GITHUB_DEVICE_CODE_URL, {
374
+ method: 'POST',
375
+ headers: {
376
+ accept: 'application/json',
377
+ 'content-type': 'application/x-www-form-urlencoded'
378
+ },
379
+ body: params
380
+ });
381
+ if (!response.ok) {
382
+ throw new Error(`GitHub device authorization failed: HTTP ${response.status} ${response.statusText}`);
383
+ }
384
+ const device = await response.json() as GitHubDeviceCodeResponse;
385
+ if (!device.device_code || !device.user_code || !device.verification_uri) {
386
+ throw new Error('GitHub device authorization response was incomplete.');
387
+ }
388
+
389
+ printDeviceFlowInstructions('GitHub', device.verification_uri, device.user_code);
390
+ return await pollForGitHubAccessToken(clientId, device);
391
+ }
392
+
374
393
  function printDeviceFlowInstructions(providerName: string, verificationUri: string, userCode: string): void {
375
394
  console.error(chalk.cyan(`${providerName} sign-in required.`));
376
395
  console.error(`Open: ${verificationUri}`);
@@ -444,37 +463,6 @@ async function acquireCredentialLock(): Promise<{ release: () => Promise<void> }
444
463
  throw new Error('Timed out waiting for the CLI credential lock.');
445
464
  }
446
465
 
447
- async function listActiveServers(): Promise<Array<{ pid: number; port: number; workspaceRoot?: string }>> {
448
- const baseDir = path.join(os.homedir(), '.oml', 'workspaces');
449
- try {
450
- const workspaceDirs = await fs.readdir(baseDir);
451
- const active: Array<{ pid: number; port: number; workspaceRoot?: string }> = [];
452
- for (const workspaceDir of workspaceDirs) {
453
- const lockFile = path.join(baseDir, workspaceDir, 'server.lock');
454
- try {
455
- const raw = await fs.readFile(lockFile, 'utf-8');
456
- const parsed = JSON.parse(raw) as { pid?: unknown; port?: unknown; workspaceRoot?: unknown };
457
- const pid = Number(parsed.pid);
458
- const port = Number(parsed.port);
459
- if (!Number.isFinite(pid) || !Number.isFinite(port)) {
460
- continue;
461
- }
462
- process.kill(Math.trunc(pid), 0);
463
- active.push({
464
- pid: Math.trunc(pid),
465
- port: Math.trunc(port),
466
- workspaceRoot: typeof parsed.workspaceRoot === 'string' ? parsed.workspaceRoot : undefined,
467
- });
468
- } catch {
469
- // ignore malformed or stale lock entries
470
- }
471
- }
472
- return active;
473
- } catch {
474
- return [];
475
- }
476
- }
477
-
478
466
  function resolveClientId(): string {
479
467
  const configured = process.env.OML_AUTH_GITHUB_CLIENT_ID?.trim() || DEFAULT_GITHUB_CLIENT_ID;
480
468
  if (configured) {
@@ -506,25 +494,149 @@ function isUnauthorizedError(error: unknown): boolean {
506
494
  return /\b401\b/.test(message);
507
495
  }
508
496
 
497
+ const CREDENTIALS_FILE_PATH = path.join(os.homedir(), '.oml', 'credentials.json');
498
+ const LOCAL_MACHINE_ID_PATH = path.join(os.homedir(), '.oml', 'machine-id');
499
+
500
+ // Derive a 256-bit encryption key bound to this machine and user.
501
+ // Uses /etc/machine-id (Linux) or a locally generated UUID as the key material,
502
+ // combined with the user's home directory so the key is user-specific too.
503
+ async function deriveMachineKey(): Promise<Buffer> {
504
+ const machineId = await getOrCreateDeviceId();
505
+ const ikm = Buffer.from(`${machineId}:${os.homedir()}`, 'utf-8');
506
+ return new Promise<Buffer>((resolve, reject) => {
507
+ crypto.hkdf('sha256', ikm, Buffer.alloc(0), 'oml-cli-credentials-v1', 32, (err, key) => {
508
+ if (err) { reject(err); } else { resolve(Buffer.from(key)); }
509
+ });
510
+ });
511
+ }
512
+
513
+ function encryptValue(value: string, key: Buffer): string {
514
+ const iv = crypto.randomBytes(12);
515
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
516
+ const encrypted = Buffer.concat([cipher.update(value, 'utf-8'), cipher.final()]);
517
+ const tag = cipher.getAuthTag();
518
+ return JSON.stringify({
519
+ v: 1,
520
+ iv: iv.toString('base64'),
521
+ tag: tag.toString('base64'),
522
+ data: encrypted.toString('base64'),
523
+ });
524
+ }
525
+
526
+ function decryptValue(stored: string, key: Buffer): string {
527
+ const parsed = JSON.parse(stored) as { v?: number; iv: string; tag: string; data: string };
528
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(parsed.iv, 'base64'));
529
+ decipher.setAuthTag(Buffer.from(parsed.tag, 'base64'));
530
+ return decipher.update(Buffer.from(parsed.data, 'base64'), undefined, 'utf-8') + decipher.final('utf-8');
531
+ }
532
+
533
+ class FileKeytar implements KeytarModule {
534
+ private keyPromise: Promise<Buffer> | undefined;
535
+
536
+ private getKey(): Promise<Buffer> {
537
+ if (!this.keyPromise) {
538
+ this.keyPromise = deriveMachineKey();
539
+ }
540
+ return this.keyPromise;
541
+ }
542
+
543
+ private async read(): Promise<Record<string, string>> {
544
+ try {
545
+ const content = await fs.readFile(CREDENTIALS_FILE_PATH, 'utf-8');
546
+ return JSON.parse(content) as Record<string, string>;
547
+ } catch {
548
+ return {};
549
+ }
550
+ }
551
+
552
+ private async write(store: Record<string, string>): Promise<void> {
553
+ await fs.mkdir(path.dirname(CREDENTIALS_FILE_PATH), { recursive: true });
554
+ await fs.writeFile(CREDENTIALS_FILE_PATH, `${JSON.stringify(store, null, 2)}\n`, { encoding: 'utf-8', mode: 0o600 });
555
+ }
556
+
557
+ private storeKey(service: string, account: string): string {
558
+ return `${service}:${account}`;
559
+ }
560
+
561
+ async getPassword(service: string, account: string): Promise<string | null> {
562
+ const store = await this.read();
563
+ const raw = store[this.storeKey(service, account)];
564
+ if (raw === undefined || raw === null) {
565
+ return null;
566
+ }
567
+ try {
568
+ const key = await this.getKey();
569
+ return decryptValue(raw, key);
570
+ } catch {
571
+ // Unreadable entry (wrong machine, corrupt, or legacy plaintext) — treat as missing.
572
+ return null;
573
+ }
574
+ }
575
+
576
+ async setPassword(service: string, account: string, password: string): Promise<void> {
577
+ const [store, key] = await Promise.all([this.read(), this.getKey()]);
578
+ store[this.storeKey(service, account)] = encryptValue(password, key);
579
+ await this.write(store);
580
+ }
581
+
582
+ async deletePassword(service: string, account: string): Promise<boolean> {
583
+ const store = await this.read();
584
+ const k = this.storeKey(service, account);
585
+ if (!(k in store)) {
586
+ return false;
587
+ }
588
+ delete store[k];
589
+ await this.write(store);
590
+ return true;
591
+ }
592
+ }
593
+
509
594
  async function getKeytarModule(): Promise<KeytarModule> {
510
595
  if (!keytarModulePromise) {
511
596
  keytarModulePromise = import('keytar')
512
597
  .then((loaded) => loaded.default as KeytarModule)
513
598
  .catch((error: unknown) => {
599
+ const message = error instanceof Error ? error.message : String(error);
600
+ if (/libsecret|dlopen|ERR_DLOPEN_FAILED/i.test(message)) {
601
+ console.error(chalk.yellow(
602
+ 'System keychain unavailable; credentials will be stored encrypted at ' +
603
+ CREDENTIALS_FILE_PATH + ' using a machine-bound key. ' +
604
+ 'Set OML_PLATFORM_API_KEY for non-interactive environments.'
605
+ ));
606
+ return new FileKeytar();
607
+ }
514
608
  keytarModulePromise = undefined;
515
- throw new Error(formatKeytarLoadError(error));
609
+ throw new Error(`Secure credential storage is unavailable: ${message}`);
516
610
  });
517
611
  }
518
612
  return keytarModulePromise;
519
613
  }
520
614
 
521
- function formatKeytarLoadError(error: unknown): string {
522
- const message = error instanceof Error ? error.message : String(error);
523
- if (/libsecret|dlopen|ERR_DLOPEN_FAILED/i.test(message)) {
524
- return 'Secure credential storage is unavailable (missing system keychain runtime, e.g. libsecret on Linux). ' +
525
- 'Install the OS keychain runtime or set OML_PLATFORM_API_KEY for non-interactive mode.';
615
+ type JwtClaims = {
616
+ userId?: string;
617
+ userLabel?: string;
618
+ email?: string;
619
+ tier?: string;
620
+ };
621
+
622
+ function decodeJwtClaims(token: string): JwtClaims {
623
+ try {
624
+ const payload = token.split('.')[1];
625
+ if (!payload) { return {}; }
626
+ const json = JSON.parse(Buffer.from(payload.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf-8')) as Record<string, unknown>;
627
+ const userMeta = json['user_metadata'];
628
+ const appMeta = json['app_metadata'];
629
+ return {
630
+ userId: typeof json['sub'] === 'string' ? json['sub'] : undefined,
631
+ email: typeof json['email'] === 'string' ? json['email'] : undefined,
632
+ userLabel: userMeta && typeof (userMeta as Record<string, unknown>)['user_name'] === 'string'
633
+ ? (userMeta as Record<string, unknown>)['user_name'] as string : undefined,
634
+ tier: appMeta && typeof (appMeta as Record<string, unknown>)['tier'] === 'string'
635
+ ? (appMeta as Record<string, unknown>)['tier'] as string : undefined,
636
+ };
637
+ } catch {
638
+ return {};
526
639
  }
527
- return `Secure credential storage is unavailable: ${message}`;
528
640
  }
529
641
 
530
642
  type GitHubDeviceCodeResponse = {