@permute/sdk 0.8.0 → 0.8.1

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/README.md CHANGED
@@ -20,6 +20,10 @@ bunx @permute/sdk auth logout
20
20
 
21
21
  Choose an organization, optionally limit the connection to one workspace, then select resources and permissions to approve in Permute. The CLI stores renewable credentials in your operating system’s credential store (no plaintext fallback). Active logins have no fixed maximum lifetime and expire after 30 days without renewal; company sign-in rules can require earlier authentication. Logging out revokes access before removing the local credential. Signing in again revokes the previous stored CLI session.
22
22
 
23
+ The SDK reuses credentials in memory between requests and reloads the OS store before renewal or when another process changes the saved login. The local auth directory holds only a lock and a non-secret change marker; tokens stay out of ordinary files. Current server permissions and revocation still apply to every request.
24
+
25
+ The CLI prints the issuer and `permute-cli` entry name to stderr before accessing the OS store. macOS controls its own permission dialog, which may identify the Node or Bun process. SDK applications stay silent by default and can supply `onCredentialStoreAccess: (issuer) => { /* show a notice */ }` to `OAuthLogin`; the callback runs once before the default OS store is accessed and receives no credentials.
26
+
23
27
  ```ts
24
28
  import { OAuthLogin } from '@permute/sdk/auth';
25
29
 
@@ -8,6 +8,8 @@ export interface OAuthOptions {
8
8
  apiUrl?: string;
9
9
  store?: CredentialStore;
10
10
  fetch?: typeof fetch;
11
+ /** Called once before the default OS credential store is accessed; no tokens are passed. */
12
+ onCredentialStoreAccess?: (issuer: string) => void;
11
13
  }
12
14
  export declare class OAuthLogin {
13
15
  readonly issuer: string;
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  import { createHash, randomBytes } from 'node:crypto';
3
3
  import { setTimeout } from 'node:timers/promises';
4
4
  import { PermuteClient } from '../client.js';
5
- import { SystemCredentialStore } from './store.js';
5
+ import { TOKEN_REFRESH_BUFFER_MS, SystemCredentialStore, } from './store.js';
6
6
  export { SystemCredentialStore } from './store.js';
7
7
  export const DEFAULT_ISSUER = 'https://auth.permute.ai';
8
8
  const CLIENT_ID = 'permute-cli';
@@ -30,7 +30,7 @@ export class OAuthLogin {
30
30
  constructor(options = {}) {
31
31
  this.issuer = this.endpoint(options.issuer ?? DEFAULT_ISSUER);
32
32
  this.apiUrl = this.endpoint(options.apiUrl ?? 'https://api.permute.ai');
33
- this.store = options.store ?? new SystemCredentialStore(this.issuer);
33
+ this.store = options.store ?? new SystemCredentialStore(this.issuer, options.onCredentialStoreAccess);
34
34
  this.fetchImpl = options.fetch ?? fetch;
35
35
  }
36
36
  endpoint(value) {
@@ -65,7 +65,7 @@ export class OAuthLogin {
65
65
  throw new Error('Invalid credential response');
66
66
  return parsed.data;
67
67
  }
68
- async persist(tokens) {
68
+ async persist(tokens, previous) {
69
69
  const credentials = {
70
70
  issuer: this.issuer,
71
71
  apiUrl: this.apiUrl,
@@ -77,9 +77,9 @@ export class OAuthLogin {
77
77
  expiresAt: Date.now() + tokens.expires_in * 1000,
78
78
  };
79
79
  try {
80
- const previous = await this.store.read();
81
- if (previous && previous.sessionId !== tokens.session_id)
82
- await this.revoke(previous.refresh);
80
+ const existing = previous ?? (await this.store.read());
81
+ if (existing && existing.sessionId !== tokens.session_id)
82
+ await this.revoke(existing.refresh);
83
83
  await this.store.write(credentials);
84
84
  }
85
85
  catch (error) {
@@ -172,14 +172,14 @@ export class OAuthLogin {
172
172
  const current = await this.store.read();
173
173
  if (!current || current.apiUrl !== this.apiUrl)
174
174
  throw new Error('No login for this server. Run permute auth login.');
175
- if (current.expiresAt > Date.now() + 60_000)
175
+ if (current.expiresAt > Date.now() + TOKEN_REFRESH_BUFFER_MS)
176
176
  return current;
177
177
  const tokens = this.tokens(await this.post('/token', {
178
178
  client_id: CLIENT_ID,
179
179
  grant_type: 'refresh_token',
180
180
  refresh_token: current.refresh,
181
181
  }));
182
- return this.persist(tokens);
182
+ return this.persist(tokens, current);
183
183
  });
184
184
  }
185
185
  async client() {
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ export declare const TOKEN_REFRESH_BUFFER_MS = 60000;
2
3
  declare const LoginCredentialsSchema: z.ZodObject<{
3
4
  issuer: z.ZodString;
4
5
  apiUrl: z.ZodString;
@@ -16,11 +17,16 @@ export interface CredentialStore {
16
17
  remove(): Promise<void>;
17
18
  exclusive<T>(run: () => Promise<T>): Promise<T>;
18
19
  }
19
- /** Tokens never fall back to a plaintext file. The disk lock contains no credentials. */
20
+ /** Credentials are cached in memory only; disk holds a lock and a non-secret change marker. */
20
21
  export declare class SystemCredentialStore implements CredentialStore {
21
22
  private readonly issuer;
23
+ private readonly onAccess?;
22
24
  private readonly account;
23
- constructor(issuer: string);
25
+ private readonly directory;
26
+ private readonly revisionPath;
27
+ private cached?;
28
+ private announced;
29
+ constructor(issuer: string, onAccess?: ((issuer: string) => void) | undefined);
24
30
  private entry;
25
31
  read(): Promise<{
26
32
  issuer: string;
@@ -32,8 +38,10 @@ export declare class SystemCredentialStore implements CredentialStore {
32
38
  refresh: string;
33
39
  expiresAt: number;
34
40
  } | null>;
41
+ private validate;
35
42
  write(value: LoginCredentials): Promise<void>;
36
43
  remove(): Promise<void>;
44
+ private markChanged;
37
45
  exclusive<T>(run: () => Promise<T>): Promise<T>;
38
46
  }
39
47
  export {};
@@ -1,9 +1,10 @@
1
1
  import { z } from 'zod';
2
- import { createHash } from 'node:crypto';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
3
  import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
- import { mkdir, rm } from 'node:fs/promises';
5
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
6
6
  import { setTimeout } from 'node:timers/promises';
7
+ export const TOKEN_REFRESH_BUFFER_MS = 60_000;
7
8
  const LoginCredentialsSchema = z.object({
8
9
  issuer: z.string().url(),
9
10
  apiUrl: z.string().url(),
@@ -14,39 +15,74 @@ const LoginCredentialsSchema = z.object({
14
15
  refresh: z.string().min(1),
15
16
  expiresAt: z.number().finite(),
16
17
  });
17
- /** Tokens never fall back to a plaintext file. The disk lock contains no credentials. */
18
+ /** Credentials are cached in memory only; disk holds a lock and a non-secret change marker. */
18
19
  export class SystemCredentialStore {
19
20
  issuer;
21
+ onAccess;
20
22
  account;
21
- constructor(issuer) {
23
+ directory = join(homedir(), '.config', 'permute', 'auth');
24
+ revisionPath;
25
+ cached;
26
+ announced = false;
27
+ constructor(issuer, onAccess) {
22
28
  this.issuer = issuer;
29
+ this.onAccess = onAccess;
23
30
  this.account = createHash('sha256').update(issuer).digest('hex');
31
+ this.revisionPath = join(this.directory, `${this.account}.revision`);
24
32
  }
25
33
  async entry() {
26
34
  const { AsyncEntry } = await import('@napi-rs/keyring');
35
+ if (!this.announced) {
36
+ this.onAccess?.(this.issuer);
37
+ this.announced = true;
38
+ }
27
39
  return new AsyncEntry('permute-cli', this.account, { linux: { store: 'secret-service' } });
28
40
  }
29
41
  async read() {
42
+ const revision = await readFile(this.revisionPath, 'utf8').catch((error) => {
43
+ if (error.code === 'ENOENT')
44
+ return null;
45
+ throw error;
46
+ });
47
+ if (this.cached?.revision === revision && this.cached.value.expiresAt > Date.now() + TOKEN_REFRESH_BUFFER_MS)
48
+ return { ...this.cached.value };
49
+ this.cached = undefined;
30
50
  const value = await (await this.entry()).getPassword();
31
51
  if (!value)
32
52
  return null;
33
- const parsed = LoginCredentialsSchema.safeParse(JSON.parse(value));
53
+ const credentials = this.validate(JSON.parse(value));
54
+ this.cached = { revision, value: credentials };
55
+ return { ...credentials };
56
+ }
57
+ validate(value) {
58
+ const parsed = LoginCredentialsSchema.safeParse(value);
34
59
  if (!parsed.success || parsed.data.issuer !== this.issuer)
35
60
  throw new Error('Stored login is invalid. Run permute auth login again.');
36
61
  return parsed.data;
37
62
  }
38
63
  async write(value) {
39
- await (await this.entry()).setPassword(JSON.stringify(value));
64
+ const credentials = this.validate(value);
65
+ this.cached = undefined;
66
+ await (await this.entry()).setPassword(JSON.stringify(credentials));
67
+ const revision = await this.markChanged();
68
+ this.cached = { revision, value: credentials };
40
69
  }
41
70
  async remove() {
71
+ this.cached = undefined;
42
72
  const entry = await this.entry();
43
73
  if (await entry.getPassword())
44
74
  await entry.deletePassword();
75
+ await this.markChanged();
76
+ }
77
+ async markChanged() {
78
+ await mkdir(this.directory, { recursive: true, mode: 0o700 });
79
+ const revision = randomUUID();
80
+ await writeFile(this.revisionPath, revision, { mode: 0o600 });
81
+ return revision;
45
82
  }
46
83
  async exclusive(run) {
47
- const directory = join(homedir(), '.config', 'permute', 'auth');
48
- await mkdir(directory, { recursive: true, mode: 0o700 });
49
- const lock = join(directory, `${this.account}.lock`);
84
+ await mkdir(this.directory, { recursive: true, mode: 0o700 });
85
+ const lock = join(this.directory, `${this.account}.lock`);
50
86
  const deadline = Date.now() + 30_000;
51
87
  while (true) {
52
88
  const acquired = await mkdir(lock, { mode: 0o700 }).then(() => true, (error) => {
package/dist/cli.js CHANGED
@@ -90,7 +90,14 @@ export async function runCli(args = process.argv.slice(2)) {
90
90
  }
91
91
  if (!!values.issuer !== !!values['api-url'])
92
92
  throw new Error('Provide both --issuer and --api-url for another environment.');
93
- const login = new OAuthLogin({ issuer: values.issuer, apiUrl: values['api-url'] });
93
+ const login = new OAuthLogin({
94
+ issuer: values.issuer,
95
+ apiUrl: values['api-url'],
96
+ onCredentialStoreAccess: (issuer) => {
97
+ const store = process.platform === 'darwin' ? 'macOS Keychain' : 'your OS credential store';
98
+ console.error(`Permute CLI is accessing ${store} for ${issuer} (entry: permute-cli).`);
99
+ },
100
+ });
94
101
  if (command === 'logout') {
95
102
  await login.logout();
96
103
  console.log('CLI login revoked and removed from this device.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@permute/sdk",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "TypeScript client for the Permute public API",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",